mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
239 lines
7.4 KiB
JavaScript
239 lines
7.4 KiB
JavaScript
// Discord User Announcements
|
|
// Purpose: Owns the public announcements channel policy.
|
|
// Scope: Keeps user-facing rover availability messages separate from admin alerts.
|
|
const { EmbedBuilder } = require('discord.js');
|
|
|
|
const PUBLIC_MODES = new Set(['open', 'turns']);
|
|
const WATCHED_EVENT_TYPES = new Set([
|
|
'mode.changed',
|
|
'globalObjective.updated',
|
|
'communityGoal.updated',
|
|
'rovers.allUnlocked',
|
|
]);
|
|
|
|
function createUserAnnouncements(deps) {
|
|
const {
|
|
discordConfig,
|
|
getMode,
|
|
rovers,
|
|
roverManager,
|
|
getGlobalObjective,
|
|
sendToChannel,
|
|
schedulePresenceRotation,
|
|
} = deps;
|
|
|
|
const announcementChannelId = discordConfig?.channels?.announcements || null;
|
|
const announcementRoleId = discordConfig?.roles?.announcementPing || null;
|
|
const siteUrl = discordConfig?.siteUrl ? String(discordConfig.siteUrl) : '';
|
|
|
|
let previousSnapshot = buildSnapshot();
|
|
let skippedFirstModeChange = false;
|
|
|
|
function isPublicMode(mode) {
|
|
return PUBLIC_MODES.has(String(mode || ''));
|
|
}
|
|
|
|
function getRecordName(record, fallback = 'A rover') {
|
|
return String(record?.meta?.name || record?.id || fallback);
|
|
}
|
|
|
|
function formatPercent(record) {
|
|
const percent = record?.batteryState?.percentDisplay;
|
|
return percent == null ? '' : `${percent}%`;
|
|
}
|
|
|
|
function getUnavailableReason(record) {
|
|
if (!record) return 'not connected';
|
|
if (record.locked) return record.lockReason ? `locked (${record.lockReason})` : 'locked';
|
|
if (!roverManager.canReplayRoverId(record.id)) return 'not public';
|
|
return 'not ready';
|
|
}
|
|
|
|
function formatRecordLine(record, includeReason = false) {
|
|
const name = getRecordName(record);
|
|
const percent = formatPercent(record);
|
|
const suffix = percent ? `, ${percent}` : '';
|
|
if (!includeReason) return `${name}${suffix}`;
|
|
return `${name} - ${getUnavailableReason(record)}${suffix}`;
|
|
}
|
|
|
|
function buildSnapshot() {
|
|
const mode = getMode();
|
|
const publicMode = isPublicMode(mode);
|
|
const objective = typeof getGlobalObjective === 'function' ? getGlobalObjective() : null;
|
|
const objectiveText = objective?.text ? String(objective.text).trim() : '';
|
|
const publicRecords = Array.from(rovers.values()).filter((record) => roverManager.canReplayRoverId(record?.id));
|
|
const readyRecords = publicMode ? publicRecords.filter((record) => !record.locked) : [];
|
|
const unavailableRecords = publicMode ? publicRecords.filter((record) => record.locked) : publicRecords;
|
|
|
|
return {
|
|
mode,
|
|
publicMode,
|
|
objectiveText,
|
|
totalCount: publicRecords.length,
|
|
readyCount: readyRecords.length,
|
|
readyIds: new Set(readyRecords.map((record) => String(record.id))),
|
|
readyRecords,
|
|
unavailableRecords,
|
|
};
|
|
}
|
|
|
|
function buildAvailabilityEmbed({ title, description, snapshot, color }) {
|
|
const embed = new EmbedBuilder().setTitle(title).setColor(color).setTimestamp(new Date());
|
|
if (description) embed.setDescription(description);
|
|
|
|
embed.addFields({
|
|
name: 'Mode',
|
|
value: String(snapshot.mode || 'unknown'),
|
|
inline: true,
|
|
});
|
|
|
|
embed.addFields({
|
|
name: 'Ready Count',
|
|
value: `${snapshot.readyCount}/${snapshot.totalCount}`,
|
|
inline: true,
|
|
});
|
|
|
|
if (snapshot.readyRecords.length) {
|
|
embed.addFields({
|
|
name: 'Ready',
|
|
value: snapshot.readyRecords.map((record) => formatRecordLine(record)).join(', ').slice(0, 1024),
|
|
inline: true,
|
|
});
|
|
} else {
|
|
embed.addFields({
|
|
name: 'Ready',
|
|
value: 'none',
|
|
inline: true,
|
|
});
|
|
}
|
|
|
|
if (snapshot.unavailableRecords.length) {
|
|
embed.addFields({
|
|
name: 'Not Ready',
|
|
value: snapshot.unavailableRecords.map((record) => formatRecordLine(record, true)).join(', ').slice(0, 1024),
|
|
inline: true,
|
|
});
|
|
}
|
|
|
|
if (snapshot.objectiveText) {
|
|
embed.addFields({
|
|
name: 'Objective',
|
|
value: snapshot.objectiveText.slice(0, 1024),
|
|
inline: true,
|
|
});
|
|
}
|
|
|
|
if (siteUrl) {
|
|
embed.addFields({
|
|
name: 'Join',
|
|
value: siteUrl,
|
|
inline: false,
|
|
});
|
|
}
|
|
|
|
return embed;
|
|
}
|
|
|
|
async function sendAnnouncement({ content, embeds, ping = false }) {
|
|
if (!announcementChannelId) return;
|
|
const shouldPing = Boolean(ping && announcementRoleId);
|
|
const body = shouldPing ? `<@&${announcementRoleId}> ${content || ''}`.trim() : content;
|
|
await sendToChannel(
|
|
announcementChannelId,
|
|
body,
|
|
{ embeds: Array.isArray(embeds) ? embeds : [] },
|
|
shouldPing ? { parse: [], roles: [announcementRoleId] } : { parse: [] },
|
|
!shouldPing,
|
|
);
|
|
}
|
|
|
|
function didObjectiveChange(prev, next) {
|
|
return String(prev?.objectiveText || '') !== String(next?.objectiveText || '');
|
|
}
|
|
|
|
// Mode-open pings: ping only when the server is switched to open/turns, never for the first mode event after startup.
|
|
async function maybeAnnouncePublicMode(event, next) {
|
|
if (event?.type !== 'mode.changed') return false;
|
|
if (!skippedFirstModeChange) {
|
|
skippedFirstModeChange = true;
|
|
return false;
|
|
}
|
|
if (!next.publicMode) return false;
|
|
|
|
await sendAnnouncement({
|
|
content: `Server mode changed to ${next.mode}.`,
|
|
ping: true,
|
|
embeds: [buildAvailabilityEmbed({
|
|
title: `Server Mode Changed To ${next.mode}`,
|
|
description: `The server was switched to **${next.mode}** mode.`,
|
|
snapshot: next,
|
|
color: 0x4caf50,
|
|
})],
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// All-unlocked pings: ping only when every rover has been unlocked.
|
|
async function maybeAnnounceAllUnlocked(event, next) {
|
|
if (event?.type !== 'rovers.allUnlocked') return false;
|
|
await sendAnnouncement({
|
|
content: 'All rovers are unlocked.',
|
|
ping: true,
|
|
embeds: [buildAvailabilityEmbed({
|
|
title: 'All Rovers Unlocked',
|
|
description: 'Every rover is now unlocked.',
|
|
snapshot: next,
|
|
color: 0x4caf50,
|
|
})],
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// Objective pings: ping whenever the objective text changes, and say the objective in the message text.
|
|
async function maybeAnnounceObjective(event, prev, next) {
|
|
if (event?.type !== 'globalObjective.updated' && event?.type !== 'communityGoal.updated') return false;
|
|
if (!didObjectiveChange(prev, next)) return false;
|
|
|
|
const objectiveText = next.objectiveText || 'cleared';
|
|
await sendAnnouncement({
|
|
content: `Objective changed to: ${objectiveText}`,
|
|
ping: true,
|
|
embeds: [buildAvailabilityEmbed({
|
|
title: 'Objective Changed',
|
|
description: next.objectiveText ? `New objective: ${next.objectiveText}` : 'The objective was cleared.',
|
|
snapshot: next,
|
|
color: 0x8bc34a,
|
|
})],
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async function handleBusEvent(event) {
|
|
if (!WATCHED_EVENT_TYPES.has(event?.type)) return;
|
|
|
|
const prev = previousSnapshot;
|
|
const next = buildSnapshot();
|
|
previousSnapshot = next;
|
|
if (schedulePresenceRotation) schedulePresenceRotation();
|
|
|
|
try {
|
|
if (await maybeAnnounceObjective(event, prev, next)) return;
|
|
if (await maybeAnnouncePublicMode(event, next)) return;
|
|
await maybeAnnounceAllUnlocked(event, next);
|
|
} catch (err) {
|
|
// The caller logs send failures; this catch keeps one announcement bug from breaking event dispatch.
|
|
const logger = deps.logger;
|
|
if (logger?.warn) logger.warn('User announcement failed', { type: event?.type, error: err.message });
|
|
}
|
|
}
|
|
|
|
return {
|
|
handleBusEvent,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
createUserAnnouncements,
|
|
};
|