Compare commits

..
Author SHA1 Message Date
legop3 0df4a5ec89 smaller cooldown 2026-09-18 16:48:24 -04:00
legop3 8e301e7db7 remove embed stuff 2026-09-18 16:36:19 -04:00
legop3 b712fb89c6 omarchy 2026-09-18 16:05:10 -04:00
legop3 fe2c29371f fix discord bot config restart and startup connection retry
Container image / image (push) Failing after 1m1s
2026-09-18 14:41:11 -04:00
legop3 61ee692815 Merge pull request #27 from legop3/homeassistant-accessories
Homeassistant accessories
2026-09-18 14:27:27 -04:00
8 changed files with 760 additions and 285 deletions
@@ -6,12 +6,14 @@ const { MessageFlags } = require('discord.js');
function createChannelIO({ client, logger, sanitizeMentions }) { function createChannelIO({ client, logger, sanitizeMentions }) {
const channelCache = new Map(); const channelCache = new Map();
const typingMessageCache = new Map(); const typingMessageCache = new Map();
let stopped = false;
async function fetchChannel(id) { async function fetchChannel(id) {
if (!id) return null; if (stopped || !client.isReady() || !id) return null;
if (channelCache.has(id)) return channelCache.get(id); if (channelCache.has(id)) return channelCache.get(id);
try { try {
const channel = await client.channels.fetch(id); const channel = await client.channels.fetch(id);
if (stopped) return null;
if (channel) { if (channel) {
channelCache.set(id, channel); channelCache.set(id, channel);
return channel; return channel;
@@ -68,6 +70,7 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
const content = `-# *${username} is typing...*`; const content = `-# *${username} is typing...*`;
try { try {
const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]}); const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]});
if (stopped) return;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
clearTypingMessage(entry.guildId, typingId); clearTypingMessage(entry.guildId, typingId);
}, 20000); }, 20000);
@@ -78,6 +81,12 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
} }
return { return {
stop() {
stopped = true;
for (const record of typingMessageCache.values()) clearTimeout(record.timeoutId);
typingMessageCache.clear();
channelCache.clear();
},
fetchChannel, fetchChannel,
sendToChannel, sendToChannel,
clearTypingMessage, clearTypingMessage,
@@ -18,6 +18,7 @@ module.exports = {
adminAlerts: '123456789012345678', adminAlerts: '123456789012345678',
replay: '123456789012345678', replay: '123456789012345678',
humanAlerts: '123456789012345678', humanAlerts: '123456789012345678',
liveStatus: '',
}, },
roles: { roles: {
stalkerPing: '123456789012345678', stalkerPing: '123456789012345678',
@@ -31,6 +32,7 @@ module.exports = {
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }), token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }), guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
channels: strictObject({ channels: strictObject({
liveStatus: string({ title: 'Live status', description: 'Dedicated live fleet report channel ID. The bot deletes ALL other messages here. Requires View Channel, Read Message History, Send Messages, and Manage Messages. Leave empty to disable.', examples: ['123456789012345678'], maxLength: 100 }),
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }), general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }), announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }), adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }),
@@ -0,0 +1,94 @@
// Owns connection replacement and recovery from failed initial logins.
function createConnectionManager({ createRuntime, logger }) {
let desired = { enabled: false, token: '' };
let revision = 0;
let runtime = null;
let queue = Promise.resolve();
let retryTimer = null;
let retryDelay = 5000;
function enqueue(operation) {
const pending = queue.then(operation);
// Report failures without poisoning subsequent configuration changes.
queue = pending.catch((err) => logger.error('Discord connection update failed', err.message));
return pending;
}
async function stop() {
const previous = runtime;
runtime = null;
if (!previous) return;
previous.stop();
// discord.js cannot cancel login while gateway discovery is pending.
// Let that attempt settle before destroying it, otherwise it can open a
// socket after destroy() has already run and leave an orphan connection.
await previous.login;
await previous.client.destroy();
}
function scheduleRetry(version) {
if (version !== revision || !desired.enabled) return;
const delay = retryDelay;
retryDelay = Math.min(retryDelay * 2, 60000);
logger.warn('Retrying Discord login', { delayMs: delay });
retryTimer = setTimeout(() => {
retryTimer = null;
enqueue(() => start(version));
}, delay);
retryTimer.unref?.();
}
async function start(version) {
if (version !== revision || !desired.enabled) return;
await stop();
if (version !== revision || !desired.enabled) return;
const current = createRuntime();
runtime = current;
const { client } = current;
client.on('error', (err) => logger.error('Discord client error', err.message));
client.on('shardError', (err, shardId) => {
logger.warn('Discord connection error', { shardId, error: err.message });
});
client.on('shardDisconnect', (event, shardId) => {
logger.warn('Discord disconnected', { shardId, code: event.code });
});
client.on('clientReady', () => { retryDelay = 5000; });
// Login must not block configuration saves while the network is down.
// Each attempt has its own client, so stale login completion/cleanup cannot
// clear the credentials or stop the connection of its replacement.
current.login = client.login(desired.token).catch((err) => {
if (version !== revision || runtime !== current) return;
logger.error('Discord login failed', err.message);
enqueue(async () => {
if (version !== revision || runtime !== current) return;
await stop();
// Credentials require an operator edit; retry network/service failures.
if (err.code === 'TokenInvalid' || err.status === 401) return;
scheduleRetry(version);
});
});
}
function configure({ enabled, token }) {
const next = { enabled: Boolean(enabled), token };
if (next.enabled === desired.enabled && next.token === desired.token) return queue;
desired = next;
const version = ++revision;
clearTimeout(retryTimer);
retryTimer = null;
retryDelay = 5000;
return enqueue(async () => {
if (version !== revision) return;
if (desired.enabled) await start(version);
else await stop();
});
}
return {
configure,
refresh() { runtime?.refresh(); },
};
}
module.exports = { createConnectionManager };
@@ -0,0 +1,146 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { createConnectionManager } = require('./connection');
const flush = () => new Promise((resolve) => setImmediate(resolve));
function deferred() {
let resolve;
let reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function setup(t, attempts = []) {
t.mock.timers.enable({ apis: ['setTimeout'] });
const runtimes = [];
const logs = [];
const manager = createConnectionManager({
logger: {
warn: (...args) => logs.push(args),
error: (...args) => logs.push(args),
},
createRuntime() {
const attempt = attempts[runtimes.length] || {};
const client = new EventEmitter();
const runtime = {
client,
stopped: false,
destroyed: false,
refresh() {},
stop() { runtime.stopped = true; },
};
client.login = async (token) => {
runtime.token = token;
await attempt.login?.();
};
client.destroy = async () => {
await attempt.destroy?.();
runtime.destroyed = true;
};
runtimes.push(runtime);
return runtime;
},
});
return { manager, runtimes, logs };
}
test('retries a network failure at startup with a fresh client', async (t) => {
const { manager, runtimes } = setup(t, [{ login: () => { throw new Error('getaddrinfo EAI_AGAIN'); } }]);
await manager.configure({ enabled: true, token: 'token' });
await flush();
assert.equal(runtimes[0].stopped, true);
assert.equal(runtimes[0].destroyed, true);
t.mock.timers.tick(4999);
await flush();
assert.equal(runtimes.length, 1);
t.mock.timers.tick(1);
await flush();
assert.equal(runtimes.length, 2);
assert.equal(runtimes[1].token, 'token');
assert.notEqual(runtimes[0].client, runtimes[1].client);
});
test('backs off repeated failures and cancels retries when disabled', async (t) => {
const failed = { login: () => { throw new Error('Network unavailable'); } };
const { manager, runtimes, logs } = setup(t, Array(8).fill(failed));
await manager.configure({ enabled: true, token: 'token' });
await flush();
for (const delay of [5000, 10000, 20000, 40000, 60000]) {
t.mock.timers.tick(delay);
await flush();
}
assert.deepEqual(logs.filter(([message]) => message === 'Retrying Discord login').map(([, data]) => data.delayMs),
[5000, 10000, 20000, 40000, 60000, 60000]);
await manager.configure({ enabled: false, token: 'token' });
t.mock.timers.tick(120000);
await flush();
assert.equal(runtimes.length, 6);
});
test('waits for logout before logging in with a changed token', async (t) => {
const logout = deferred();
const { manager, runtimes } = setup(t, [{ destroy: () => logout.promise }]);
await manager.configure({ enabled: true, token: 'old' });
const change = manager.configure({ enabled: true, token: 'new' });
await flush();
assert.equal(runtimes[0].stopped, true);
assert.equal(runtimes.length, 1);
logout.resolve();
await change;
assert.equal(runtimes[0].destroyed, true);
assert.equal(runtimes[1].token, 'new');
});
test('retires a pending login before replacement and ignores its stale failure', async (t) => {
const login = deferred();
const { manager, runtimes } = setup(t, [{ login: () => login.promise }]);
await manager.configure({ enabled: true, token: 'old' });
const change = manager.configure({ enabled: true, token: 'new' });
await flush();
assert.equal(runtimes[0].stopped, true);
assert.equal(runtimes[0].destroyed, false);
login.reject(new Error('Old connection failed'));
await change;
assert.equal(runtimes[0].destroyed, true);
assert.equal(runtimes[1].token, 'new');
t.mock.timers.tick(120000);
await flush();
assert.equal(runtimes.length, 2);
});
test('re-enabling creates a fresh client; unrelated config edits do not reconnect', async (t) => {
const { manager, runtimes } = setup(t);
await manager.configure({ enabled: true, token: 'token' });
await manager.configure({ enabled: true, token: 'token', channels: { general: 'new-channel' } });
assert.equal(runtimes.length, 1);
await manager.configure({ enabled: false, token: 'token' });
assert.equal(runtimes[0].destroyed, true);
await manager.configure({ enabled: true, token: 'token' });
assert.equal(runtimes.length, 2);
});
test('invalid credentials wait for a token edit instead of retrying', async (t) => {
const { manager, runtimes } = setup(t, [{ login: () => {
throw Object.assign(new Error('Invalid token'), { code: 'TokenInvalid' });
} }]);
await manager.configure({ enabled: true, token: 'bad' });
await flush();
t.mock.timers.tick(120000);
await flush();
assert.equal(runtimes.length, 1);
await manager.configure({ enabled: true, token: 'fixed' });
assert.equal(runtimes[1].token, 'fixed');
});
test('client and gateway errors are logged without throwing', async (t) => {
const { manager, runtimes, logs } = setup(t);
await manager.configure({ enabled: true, token: 'token' });
runtimes[0].client.emit('error', new Error('Client failure'));
runtimes[0].client.emit('shardError', new Error('Gateway failure'), 0);
runtimes[0].client.emit('shardDisconnect', { code: 1006 }, 0);
assert.deepEqual(logs.map(([message]) => message), [
'Discord client error', 'Discord connection error', 'Discord disconnected',
]);
});
+293 -278
View File
@@ -22,8 +22,8 @@ const { MODES, getMode, setMode } = require('../modeManager');
const { sendExternalMessage, sendExternalTyping } = require('../chatService'); const { sendExternalMessage, sendExternalTyping } = require('../chatService');
const { commandReplyToText } = require('../chatService/commandResultFormatter'); const { commandReplyToText } = require('../chatService/commandResultFormatter');
const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2'); const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2');
const { getActiveDrivers } = require('../turnService'); const { getActiveDrivers, turnEvents } = require('../turnService');
const { getNickname } = require('../nicknameService'); const { getNickname, nicknameEvents } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService'); const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService'); const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
@@ -67,12 +67,14 @@ const {
} = require('../privateRoverAccessRequestService'); } = require('../privateRoverAccessRequestService');
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const { createPresenceManager } = require('./presence'); const { createPresenceManager } = require('./presence');
const { createConnectionManager } = require('./connection');
const { createChannelIO } = require('./channelIO'); const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('../operatorCommandService'); const { createCommandHandlers } = require('../operatorCommandService');
const greenModeService = require('../greenModeService'); const greenModeService = require('../greenModeService');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter'); const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations'); const { createIntegrations } = require('./integrations');
const { createFleetDailyReports } = require('./fleetDailyReports'); const { createFleetDailyReports } = require('./fleetDailyReports');
const { createLiveStatus } = require('./liveStatus');
const fleetReportService = require('../fleetReportService'); const fleetReportService = require('../fleetReportService');
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService'); const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
const { const {
@@ -86,9 +88,7 @@ const {
buildStatusMessage, buildStatusMessage,
} = require('../replayDeliveryService/workflow'); } = require('../replayDeliveryService/workflow');
// Discord helper modules retain references to these objects. Mutating those // Keep configuration object identities stable for the active runtime's helpers.
// references on configuration application updates command and integration
// behavior without registering a second tree of Discord/event listeners.
const config = structuredClone(loadConfig()); const config = structuredClone(loadConfig());
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
let enabled = Boolean(discordConfig.enabled); let enabled = Boolean(discordConfig.enabled);
@@ -106,11 +106,6 @@ const intents = [
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
]; ];
const client = new Client({
intents,
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
});
function sanitizeMentions(text) { function sanitizeMentions(text) {
if (!text) return ''; if (!text) return '';
return String(text) return String(text)
@@ -156,300 +151,322 @@ function countReady() {
return { ready, total }; return { ready, total };
} }
const channelIO = createChannelIO({ // Each connection owns its listeners, timers, and cached Discord objects.
client, // Rebuilding them prevents a reconnect from retaining a destroyed client.
logger, function createDiscordRuntime() {
sanitizeMentions, const client = new Client({
}); intents,
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
});
const channelIO = createChannelIO({
client,
logger,
sanitizeMentions,
});
const presence = createPresenceManager({ const presence = createPresenceManager({
client, client,
logger, logger,
getMode, getMode,
getGlobalObjective, getGlobalObjective,
countReady, countReady,
}); });
const replayCaption = createReplayCaptionBuilder({ const replayCaption = createReplayCaptionBuilder({
io, io,
rovers, rovers,
getActiveDrivers, getActiveDrivers,
getNickname, getNickname,
sanitizeMentions, sanitizeMentions,
}); });
// Discord is the preferred replay host only while this optional feature is // Discord is the preferred replay host only while this optional feature is
// active. The core replay delivery service owns generation and automatically // active. The core replay delivery service owns generation and automatically
// falls back to its local media store when any operation below fails. // falls back to its local media store when any operation below fails.
registerPreferredDeliveryProvider({ const unregisterReplayProvider = registerPreferredDeliveryProvider({
async begin(job) { async begin(job) {
const channelId = discordConfig.channels?.replay; const channelId = discordConfig.channels?.replay;
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled'); if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS); const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
if (!progressMessage) throw new Error('Discord replay progress message could not be sent'); if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
const channel = await channelIO.fetchChannel(channelId); const channel = await channelIO.fetchChannel(channelId);
return { return {
channelId, channelId,
progressMessage, progressMessage,
stopTyping: startDiscordTypingLoop(channel, logger, 'web replay delivery'), stopTyping: startDiscordTypingLoop(channel, logger, 'web replay delivery'),
}; };
}, },
async deliver({ job, context, buffer, usedSources = job.sources, missingSources = [] }) { async deliver({ job, context, buffer, usedSources = job.sources, missingSources = [] }) {
const progressMessage = context?.progressMessage; const progressMessage = context?.progressMessage;
try { try {
if (progressMessage?.edit) { if (progressMessage?.edit) {
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
}
// Every delivery path uses the job creation time, so a Discord upload
// and a server-hosted fallback always expose the same replay filename.
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
if (!uploadMessage) throw new Error('Discord upload did not return a message');
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: firstAttachmentFromMessage(uploadMessage), job });
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
if (progressMessage?.edit) {
// The attachment URL is already durable once Discord returns it. A
// cosmetic progress-edit failure must not trigger a duplicate local
// replay or replace the successful media payload sent to clients.
await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }).catch((err) => {
logger.warn('Discord replay uploaded but progress message update failed', { jobId: job.id, error: err.message });
});
}
return media;
} catch (err) {
err.progressMessage = progressMessage;
throw err;
} finally {
if (context?.stopTyping) context.stopTyping();
} }
// Every delivery path uses the job creation time, so a Discord upload },
// and a server-hosted fallback always expose the same replay filename. async completeFallback({ context, media }) {
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) }); const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, '');
const body = replayCaption.build({ job, usedSources, missingSources }); const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS); if (context?.progressMessage?.reply) {
if (!uploadMessage) throw new Error('Discord upload did not return a message'); await context.progressMessage.reply({
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: firstAttachmentFromMessage(uploadMessage), job }); content: `Replay hosted by the rover server: ${publicUrl}`,
if (!media) throw new Error('Discord upload did not include a replay attachment URL'); allowedMentions: DEFAULT_ALLOWED_MENTIONS,
if (progressMessage?.edit) {
// The attachment URL is already durable once Discord returns it. A
// cosmetic progress-edit failure must not trigger a duplicate local
// replay or replace the successful media payload sent to clients.
await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }).catch((err) => {
logger.warn('Discord replay uploaded but progress message update failed', { jobId: job.id, error: err.message });
}); });
} }
return media; },
} catch (err) { });
err.progressMessage = progressMessage;
throw err;
} finally {
if (context?.stopTyping) context.stopTyping();
}
},
async completeFallback({ context, media }) {
const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, '');
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
if (context?.progressMessage?.reply) {
await context.progressMessage.reply({
content: `Replay hosted by the rover server: ${publicUrl}`,
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
}
},
});
const commandDependencies = { const commandDependencies = {
logger, logger,
client, client,
io, io,
rovers, rovers,
roverManager, roverManager,
getMode, getMode,
MODES, MODES,
setMode, setMode,
lockRover, lockRover,
getNickname, getNickname,
getActiveDrivers, getActiveDrivers,
buildReplayVideo, buildReplayVideo,
getReplaySources, getReplaySources,
getDefaultDiscordSources, getDefaultDiscordSources,
validateSources, validateSources,
tryTriggerReplay, tryTriggerReplay,
getGlobalObjective, getGlobalObjective,
setGlobalObjective, setGlobalObjective,
clearGlobalObjective, clearGlobalObjective,
getAdminReason, getAdminReason,
setAdminReason, setAdminReason,
clearAdminReason, clearAdminReason,
// Room-light lock commands must use the same Home Assistant service instance // Room-light lock commands must use the same Home Assistant service instance
// as sockets, HA button triggers, and idle/darkness policies. Passing the // as sockets, HA button triggers, and idle/darkness policies. Passing the
// service into the shared command router keeps Discord and mirrored web-chat // service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here. // command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService, homeAssistantService,
homeAssistantActivitiesService, homeAssistantActivitiesService,
greenModeService, greenModeService,
liftService, liftService,
neatoService, neatoService,
isFeatureEnabled, isFeatureEnabled,
getGuildConfig, getGuildConfig,
setGuildConfig, setGuildConfig,
removeGuildConfig, removeGuildConfig,
normalizeMode, normalizeMode,
VALID_MODES, VALID_MODES,
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers, listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser, muteUser,
unmuteUser, unmuteUser,
listUsersForAdmin, listUsersForAdmin,
listUsersWithPermission, listUsersWithPermission,
listRegisteredPermissions, listRegisteredPermissions,
setUserPermission, setUserPermission,
sanitizeMentions, sanitizeMentions,
sendToChannel: channelIO.sendToChannel, sendToChannel: channelIO.sendToChannel,
isAdminUser, isAdminUser,
isLockdownAdminUser, isLockdownAdminUser,
discordConfig, discordConfig,
config, config,
}; };
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies); commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies); const commands = createCommandHandlers(commandDependencies);
const integrations = createIntegrations({ const integrations = createIntegrations({
logger,
client,
config,
discordConfig,
rovers,
roverManager,
getMode,
MODES,
getGlobalObjective,
getActiveDrivers,
getNickname,
subscribe,
sendExternalMessage,
sendExternalTyping,
getGuildConfig,
listGuildConfigs,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
attachPrivateAccessDmMessage,
getPrivateAccessRequestByMessageId,
approvePrivateAccessRequest,
denyPrivateAccessRequest,
getLockdownAdminIds,
isAdminUser,
isLockdownAdminUser,
sendToChannel: channelIO.sendToChannel,
fetchChannel: channelIO.fetchChannel,
clearTypingMessage: channelIO.clearTypingMessage,
sendTypingMessage: channelIO.sendTypingMessage,
schedulePresenceRotation: presence.schedulePresenceRotation,
buildReplayVideo,
sanitizeMentions,
});
const integrationHandlers = integrations.register();
function isTextCommand(content) {
// Both transports share this parser so command detection cannot drift from
// the dispatcher when an installation changes its prefix.
return parseCommandText(content, config).matched;
}
function isBridgeChannelMessage(message) {
if (!message?.guild?.id || !message?.channelId) return false;
const guildConfig = getGuildConfig(message.guild.id);
return Boolean(guildConfig?.channelId && String(message.channelId) === String(guildConfig.channelId));
}
function createBridgeMirroredCommandMessage(message) {
if (!isBridgeChannelMessage(message) || !isTextCommand(message.content)) return message;
const originalReply = message.reply.bind(message);
return Object.assign(Object.create(message), {
reply: async (payload) => {
const sent = await originalReply(payload);
const text = sanitizeMentions(commandReplyToText(payload));
if (text) {
// Discord command replies are mirrored to web chat by the Discord
// command adapter, not by the chat bridge. The bridge continues to
// ignore bot-authored Discord messages, which prevents typing helper
// messages and bot replies from feeding back into chat.
sendExternalMessage({
text,
nickname: client.user?.username || 'Rover bot',
role: 'admin',
roverId: null,
discordGuildId: message.guild.id,
discordGuildName: message.guild.name,
discordGuildIconUrl: message.guild.iconURL?.({ extension: 'png', size: 64 }) || null,
discordChannelId: message.channelId,
discordUserId: client.user?.id || null,
discordUserName: client.user?.username || 'Rover bot',
discordUserAvatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
bot: true,
profileImage: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
});
}
return sent;
},
});
}
client.on('messageCreate', async (message) => {
try {
await integrationHandlers.handleBridgeInbound(message);
const commandMessage = createBridgeMirroredCommandMessage(message);
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
} catch (err) {
logger.warn('Error handling Discord message', err.message);
}
});
let fleetDailyReports = null;
function restartFleetDailyReports() {
fleetDailyReports?.stop();
fleetDailyReports = createFleetDailyReports({
logger, logger,
client,
config,
discordConfig, discordConfig,
fleetConfig: config.fleetReports || {}, rovers,
fleetReportService,
roverManager, roverManager,
getMode,
MODES,
getGlobalObjective,
getActiveDrivers,
getNickname,
subscribe,
sendExternalMessage,
sendExternalTyping,
getGuildConfig,
listGuildConfigs,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
attachPrivateAccessDmMessage,
getPrivateAccessRequestByMessageId,
approvePrivateAccessRequest,
denyPrivateAccessRequest,
getLockdownAdminIds,
isAdminUser,
isLockdownAdminUser,
sendToChannel: channelIO.sendToChannel, sendToChannel: channelIO.sendToChannel,
fetchChannel: channelIO.fetchChannel,
clearTypingMessage: channelIO.clearTypingMessage,
sendTypingMessage: channelIO.sendTypingMessage,
schedulePresenceRotation: presence.schedulePresenceRotation,
buildReplayVideo,
sanitizeMentions,
}); });
fleetDailyReports.start();
const integrationHandlers = integrations.register();
const liveStatus = createLiveStatus({
client, logger, discordConfig, roverManager, getActiveDrivers, getNickname,
io, fetchChannel: channelIO.fetchChannel, sanitizeMentions,
turnEvents, nicknameEvents,
});
function isTextCommand(content) {
// Both transports share this parser so command detection cannot drift from
// the dispatcher when an installation changes its prefix.
return parseCommandText(content, config).matched;
}
function isBridgeChannelMessage(message) {
if (!message?.guild?.id || !message?.channelId) return false;
const guildConfig = getGuildConfig(message.guild.id);
return Boolean(guildConfig?.channelId && String(message.channelId) === String(guildConfig.channelId));
}
function createBridgeMirroredCommandMessage(message) {
if (!isBridgeChannelMessage(message) || !isTextCommand(message.content)) return message;
const originalReply = message.reply.bind(message);
return Object.assign(Object.create(message), {
reply: async (payload) => {
const sent = await originalReply(payload);
const text = sanitizeMentions(commandReplyToText(payload));
if (text) {
// Discord command replies are mirrored to web chat by the Discord
// command adapter, not by the chat bridge. The bridge continues to
// ignore bot-authored Discord messages, which prevents typing helper
// messages and bot replies from feeding back into chat.
sendExternalMessage({
text,
nickname: client.user?.username || 'Rover bot',
role: 'admin',
roverId: null,
discordGuildId: message.guild.id,
discordGuildName: message.guild.name,
discordGuildIconUrl: message.guild.iconURL?.({ extension: 'png', size: 64 }) || null,
discordChannelId: message.channelId,
discordUserId: client.user?.id || null,
discordUserName: client.user?.username || 'Rover bot',
discordUserAvatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
bot: true,
profileImage: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
});
}
return sent;
},
});
}
client.on('messageCreate', async (message) => {
try {
if (message.channelId === discordConfig.channels?.liveStatus?.trim()) {
liveStatus.update(true);
return;
}
await integrationHandlers.handleBridgeInbound(message);
const commandMessage = createBridgeMirroredCommandMessage(message);
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
} catch (err) {
logger.warn('Error handling Discord message', err.message);
}
});
let fleetDailyReports = null;
function restartFleetDailyReports() {
fleetDailyReports?.stop();
fleetDailyReports = createFleetDailyReports({
logger,
discordConfig,
fleetConfig: config.fleetReports || {},
fleetReportService,
roverManager,
sendToChannel: channelIO.sendToChannel,
});
fleetDailyReports.start();
}
client.on('clientReady', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
restartFleetDailyReports();
liveStatus.start();
});
return {
client,
refresh() {
if (client.isReady()) restartFleetDailyReports();
if (client.isReady()) liveStatus.start();
},
stop() {
for (const event of ['clientReady', 'messageCreate', 'typingStart', 'messageReactionAdd']) {
client.removeAllListeners(event);
}
integrationHandlers.stop();
unregisterReplayProvider();
presence.stop();
liveStatus.stop();
channelIO.stop();
fleetDailyReports?.stop();
},
};
} }
client.on('ready', () => { const connection = createConnectionManager({ createRuntime: createDiscordRuntime, logger });
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
restartFleetDailyReports();
});
function replaceObject(target, source = {}) { function replaceObject(target, source = {}) {
Object.keys(target).forEach((key) => delete target[key]); Object.keys(target).forEach((key) => delete target[key]);
Object.assign(target, structuredClone(source)); Object.assign(target, structuredClone(source));
} }
function applyDiscordConfig(nextDiscordConfig = {}) { async function applyDiscordConfig(nextDiscordConfig = {}) {
const wasEnabled = enabled;
const previousToken = discordConfig.token;
replaceObject(discordConfig, nextDiscordConfig); replaceObject(discordConfig, nextDiscordConfig);
config.discord = discordConfig; config.discord = discordConfig;
enabled = Boolean(discordConfig.enabled); enabled = Boolean(discordConfig.enabled);
await connection.configure(discordConfig);
if (!enabled) { connection.refresh();
fleetDailyReports?.stop();
fleetDailyReports = null;
if (wasEnabled) client.destroy();
return;
}
if (!wasEnabled || previousToken !== discordConfig.token) {
if (wasEnabled) client.destroy();
// Login health is reported by Discord itself; do not hold the committed
// configuration request open while an external network service connects.
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed after configuration change', err.message);
});
} else if (client.isReady()) {
restartFleetDailyReports();
}
} }
function applySharedConfigSection(section, value) { function applySharedConfigSection(section, value) {
config[section] = structuredClone(value); config[section] = structuredClone(value);
// Fleet delivery owns a timer derived from both Discord and fleet settings. // Fleet delivery owns a timer derived from both Discord and fleet settings.
// Reconnecting is unnecessary; rebuild only that scheduler when ready. // Reconnecting is unnecessary; rebuild only that scheduler when ready.
if (section === 'fleetReports' && client.isReady()) { if (section === 'fleetReports') {
restartFleetDailyReports(); connection.refresh();
} }
} }
@@ -458,10 +475,8 @@ registerConfigurationHandler('discord', applyDiscordConfig);
registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value)); registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value));
}); });
if (enabled) { connection.configure(discordConfig).catch((err) => {
client.login(discordConfig.token).catch((err) => { logger.error('Discord startup failed', err.message);
logger.error('Discord login failed', err.message); });
});
}
module.exports = {}; module.exports = {};
@@ -24,6 +24,7 @@ function createIntegrations(deps) {
const userAnnouncements = createUserAnnouncements({ ...deps, sendToChannel, schedulePresenceRotation }); const userAnnouncements = createUserAnnouncements({ ...deps, sendToChannel, schedulePresenceRotation });
function register() { function register() {
let stopped = false;
client.on('typingStart', (typing) => { client.on('typingStart', (typing) => {
chat.handleDiscordTypingStart(typing).catch((err) => logger.warn('Error handling Discord typing', err.message)); chat.handleDiscordTypingStart(typing).catch((err) => logger.warn('Error handling Discord typing', err.message));
}); });
@@ -33,14 +34,31 @@ function createIntegrations(deps) {
dm.handlePrivateAccessReaction(reaction, user).catch((err) => logger.warn('Error handling private access reaction', err.message)); dm.handlePrivateAccessReaction(reaction, user).catch((err) => logger.warn('Error handling private access reaction', err.message));
}); });
subscribe('*', handleBusEvent); // Only the active, ready connection consumes server events. Retain each
subscribe('*', userAnnouncements.handleBusEvent); // unsubscribe function so replacement clients never duplicate deliveries.
subscribe('verification.requested', dm.sendVerificationRequestDms); const unsubscribe = [];
subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms); function subscribeWhileReady(type, handler) {
subscribe('chat:message', chat.handleChatBridgeOutbound); unsubscribe.push(subscribe(type, (event) => {
subscribe('chat:typing', chat.handleChatTypingOutbound); Promise.resolve().then(() => {
if (!stopped && client.isReady()) return handler(event);
}).catch((err) => {
logger.warn('Error handling Discord integration event', { type, error: err.message });
});
}));
}
subscribeWhileReady('*', handleBusEvent);
subscribeWhileReady('*', userAnnouncements.handleBusEvent);
subscribeWhileReady('verification.requested', dm.sendVerificationRequestDms);
subscribeWhileReady('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms);
subscribeWhileReady('chat:message', chat.handleChatBridgeOutbound);
subscribeWhileReady('chat:typing', chat.handleChatTypingOutbound);
return { return {
stop() {
stopped = true;
unsubscribe.forEach((remove) => remove());
},
handleBridgeInbound: chat.handleBridgeInbound, handleBridgeInbound: chat.handleBridgeInbound,
handleBusEvent, handleBusEvent,
}; };
@@ -0,0 +1,184 @@
// Owns the single-message live report and the dedicated channel's cleanup.
const { PermissionFlagsBits } = require('discord.js');
const { buildRoverStatusSnapshot } = require('./batteryEmbeds');
function createLiveStatus({ client, logger, discordConfig, roverManager, getActiveDrivers,
getNickname, io, fetchChannel, sanitizeMentions,
turnEvents, nicknameEvents }) {
const COOLDOWN_MS = 2000;
let timer = null;
let sweepTimer = null;
let pending = false;
let cleanupNeeded = false;
let nextUpdateAt = 0;
let listening = false;
const unsubscribe = [];
let stopped = false;
let running = false;
let channelId = null;
let messageId = null;
let lastContent = null;
const clean = (value, limit = 120) => sanitizeMentions(String(value ?? ''))
.replace(/[\r\n]+/g, ' ').slice(0, limit);
function buildReport() {
const drivers = getActiveDrivers();
const roster = roverManager.getRoster();
const lines = [];
for (const rover of roster) {
const snapshot = buildRoverStatusSnapshot(roverManager.rovers.get(rover.id));
const driverId = drivers[rover.id];
const driver = driverId ? clean(getNickname(io.sockets.sockets.get(driverId)) || 'Someone', 32) : null;
const status = snapshot?.docked ? 'docked' : rover.needsHelp ? 'needs help'
: driver ? `${driver} driving` : rover.locked ? 'locked' : 'available';
lines.push(`${clean(rover.name, 60)}: ${status}`);
}
let content = lines.join('\n') || 'No rovers online.';
if (content.length > 2000) content = `${content.slice(0, 1950)}\n… More rovers online.`;
return { content, allowedMentions: { parse: [] } };
}
function schedule() {
if (stopped || running || timer || !pending || !client.isReady()
|| !discordConfig.channels?.liveStatus?.trim()) return;
// Use a fixed deadline rather than resetting a debounce on every sensor
// frame: a continuously changing rover must not postpone updates forever.
timer = setTimeout(() => {
timer = null;
flush();
}, Math.max(0, nextUpdateAt - Date.now()));
timer.unref?.();
}
function update(forceCleanup = false) {
if (stopped) return;
pending = true;
cleanupNeeded ||= forceCleanup;
schedule();
}
async function flush() {
if (stopped || running || !client.isReady()) return;
pending = false;
const target = discordConfig.channels?.liveStatus?.trim();
if (target !== channelId) {
channelId = target;
messageId = null;
lastContent = null;
}
if (!target) return;
running = true;
const cleanup = cleanupNeeded;
cleanupNeeded = false;
let usedDiscord = false;
const active = () => !stopped && client.isReady()
&& discordConfig.channels?.liveStatus?.trim() === target;
try {
const report = buildReport();
const replace = report.content !== lastContent;
// Sensor events can arrive many times per second. Compare the rendered
// report locally before consuming any Discord API capacity.
if (!replace && !cleanup) return;
usedDiscord = true;
const channel = await fetchChannel(target);
if (!active()) return;
if (!channel?.isTextBased() || !channel.guild || !channel.messages || !channel.send) {
throw new Error('Live status requires a guild text channel');
}
const permissions = channel.permissionsFor(client.user);
if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory,
PermissionFlagsBits.SendMessages, PermissionFlagsBits.ManageMessages])) {
throw new Error('Live status requires View Channel, Read Message History, Send Messages, and Manage Messages');
}
let found = false;
let before;
// Paginate the entire history, including pinned and old messages. Individual
// deletion also handles messages too old for Discord's bulk-delete endpoint.
while (active()) {
const messages = await channel.messages.fetch({ limit: 100, before, cache: false });
if (!active()) return;
for (const message of messages.values()) {
if (!active()) return;
if (!replace && message.id === messageId) {
found = true;
continue;
}
try {
await message.delete();
} catch (err) {
if (err.code !== 10008) throw err; // Already-deleted messages need no cleanup.
}
}
if (messages.size < 100) break;
before = messages.last().id;
}
if (!active()) return;
if (replace || !found) {
const message = await channel.send(report);
if (!active()) return;
messageId = message.id;
lastContent = report.content;
}
} catch (err) {
cleanupNeeded = true;
logger.warn('Discord live status update failed', { channelId: target, error: err.message });
} finally {
running = false;
// Cool down after completion, so slow deletions and rate-limited requests
// cannot overlap with another batch. Events received meanwhile stay pending.
if (usedDiscord) nextUpdateAt = Date.now() + COOLDOWN_MS;
schedule();
}
}
const onChange = () => update();
function onDelete(message) {
if (message.channelId === discordConfig.channels?.liveStatus?.trim()
&& message.id === messageId) update(true);
}
function onBulkDelete(messages) {
for (const message of messages.values()) onDelete(message);
}
return {
update,
start() {
if (stopped) return;
if (!listening) {
listening = true;
for (const event of ['rover', 'sensor', 'lock', 'private', 'help']) {
roverManager.managerEvents.on(event, onChange);
unsubscribe.push(() => roverManager.managerEvents.off(event, onChange));
}
turnEvents.on('activeDriver', onChange);
nicknameEvents.on('change', onChange);
client.on('messageDelete', onDelete);
client.on('messageDeleteBulk', onBulkDelete);
unsubscribe.push(
() => turnEvents.off('activeDriver', onChange),
() => nicknameEvents.off('change', onChange),
() => client.off('messageDelete', onDelete),
() => client.off('messageDeleteBulk', onBulkDelete),
);
}
clearInterval(sweepTimer);
sweepTimer = null;
if (discordConfig.channels?.liveStatus?.trim()) {
// Recover from missed gateway events and failed operations without polling
// for normal rover changes, which are handled by the subscriptions above.
sweepTimer = setInterval(() => update(true), 30000);
sweepTimer.unref?.();
}
update(true);
},
stop() {
stopped = true;
clearTimeout(timer);
clearInterval(sweepTimer);
unsubscribe.forEach((remove) => remove());
},
};
}
module.exports = { createLiveStatus };
@@ -7,6 +7,7 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
const PRESENCE_ROTATE_MS = 20000; const PRESENCE_ROTATE_MS = 20000;
let presenceInterval = null; let presenceInterval = null;
let presenceShowObjective = false; let presenceShowObjective = false;
let stopped = false;
function truncatePresenceText(text, maxLength) { function truncatePresenceText(text, maxLength) {
if (!text) return ''; if (!text) return '';
@@ -40,6 +41,7 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
} }
function schedulePresenceRotation() { function schedulePresenceRotation() {
if (stopped) return;
if (presenceInterval) { if (presenceInterval) {
clearInterval(presenceInterval); clearInterval(presenceInterval);
presenceInterval = null; presenceInterval = null;
@@ -59,6 +61,11 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
} }
return { return {
stop() {
stopped = true;
clearInterval(presenceInterval);
presenceInterval = null;
},
schedulePresenceRotation, schedulePresenceRotation,
}; };
} }