mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-18 18:40:47 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df4a5ec89 | ||
|
|
8e301e7db7 | ||
|
|
b712fb89c6 | ||
|
|
fe2c29371f | ||
|
|
61ee692815 |
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user