diff --git a/controller.ts b/controller.ts index 148a50c..008b4a0 100644 --- a/controller.ts +++ b/controller.ts @@ -1,117 +1,202 @@ import type {ControllerPluginContext} from '@clusterio/controller'; +import {REST, Routes} from 'discord.js'; import {ChatEvent} from './index.js'; -import * as Discord from 'discord.js'; -const MAX_DISCORD_MESSAGE_LENGTH = 1950; +/* Controller-side half of the chat relay: Factorio chat -> Discord. + * One-way only, so this talks to the REST API directly instead of opening a + * gateway Client: no intents (and no privileged MessageContent verification), + * no login/destroy lifecycle, no reconnect state machine. +*/ + +// Discord's hard limit is 2000; keep headroom for future decoration. +const MAX_MESSAGE_LENGTH = 1950; + +// Only break on a word boundary if it gets us at least this far into the window. +const MIN_WORD_BREAK_FILL = 0.5; + +const ERROR_UNKNOWN_CHANNEL = 10003; + +interface DiscordError { + code?: number; + status?: number; + message?: string; + stack?: string; +} + +const stackOf = (err: unknown): string => (err as DiscordError)?.stack ?? String(err); +const codeOf = (err: unknown): number | undefined => (err as DiscordError)?.code; + +/** Split `text` into chunks of at most `limit` code points, preferring word boundaries. */ +function splitMessage(text: string, limit: number): string[] { + if (text.length <= limit) { + return text.length === 0 ? [] : [text]; + } + + // Array.from, not slice, so we never cut an emoji's surrogate pair in half. + const chars = Array.from(text); + const chunks: string[] = []; + let offset = 0; + + while (offset < chars.length) { + let end = Math.min(offset + limit, chars.length); + + if (end < chars.length) { + const window = chars.slice(offset, end); + let newline = -1; + let space = -1; + + for (let i = window.length - 1; i > 0; i--) { + if (window[i] === '\n') { + newline = i; + break; + } + + if (space === -1 && window[i] === ' ') { + space = i; + } + } + + const cut = newline > 0 ? newline : space; + if (cut >= window.length * MIN_WORD_BREAK_FILL) { + end = offset + cut; + } + } + + chunks.push(chars.slice(offset, end).join('').trim()); + offset = end; + + // Skip the whitespace we broke on so it doesn't open the next chunk. + while (offset < chars.length && /\s/.test(chars[offset])) { + offset++; + } + } + + return chunks.filter(chunk => chunk.length > 0); +} export default async function(context: ControllerPluginContext) { const {controller, logger, plugin} = context; - let client: Discord.Client | null = null; - const clientDestroy = async () => { - if (client) { - await client.destroy(); - } + let rest: REST | null = null; + let shuttingDown = false; - client = null; + // Log-once guards. A channel that is unmapped or unusable gets hit once per chat + // line, which is how a typo in the config turns into a 10k-line log file. + const warnedNoMapping = new Set(); + const warnedBadChannel = new Set(); + + // Serialise reconnects so two quick config changes can't interleave and have + // the older attempt null out the newer client. + let chain: Promise = Promise.resolve(); + const enqueue = (fn: () => Promise): Promise => { + const run = chain.then(fn, fn); + chain = run.catch(() => {}); + return run; }; const connect = async () => { - await clientDestroy(); - const token = controller.config.get('chat_relay.discord_bot_token') as string | undefined; + rest = null; + const token = controller.config.get('chat_relay.discord_bot_token') as string | undefined; if (!token) { logger.error('[Chat Relay] Discord bot token not configured.'); return; } - client = new Discord.Client({intents: [ - Discord.GatewayIntentBits.Guilds, - Discord.GatewayIntentBits.GuildMessages, - Discord.GatewayIntentBits.MessageContent, - ]}); - - logger.info('[Chat Relay] Logging into Discord.'); + const client = new REST({version: '10'}).setToken(token); try { - await client.login(token); + // Fails fast at startup instead of silently on the first chat line. + const me = await client.get(Routes.user('@me')) as {id: string, username: string}; + logger.info(`[Chat Relay] Authenticated with Discord as ${me.username} (${me.id}).`); } catch (err) { - logger.error(`[Chat Relay] Discord login error:\n${(err as Error).stack}`); - await clientDestroy(); + logger.error(`[Chat Relay] Discord token rejected:\n${stackOf(err)}`); return; } - logger.info('[Chat Relay] Logged in Discord successfully.'); + rest = client; }; - const sendMessage = async (instance_name: string, datetime: string, message: string) => { - if (!client) return; + const getChannelId = (instanceName: string): string | null => { + const mapping = controller.config.get('chat_relay.discord_channel_mapping') as Record | undefined; + const channelId = mapping?.[instanceName]; - const mapping = controller.config.get('chat_relay.discord_channel_mapping') as Record; - const channelId = mapping?.[instance_name]; + if (!channelId) { + if (!warnedNoMapping.has(instanceName)) { + warnedNoMapping.add(instanceName); + logger.warn(`[Chat Relay] No Discord channel mapped for instance ${instanceName}; dropping its messages.`); + } + + return null; + } + + return channelId; + }; + + const sendMessage = async (instanceName: string, datetime: string, message: string) => { + if (shuttingDown) return; + + const discord = rest; + // Not configured, or the token was rejected. connect() already logged. + if (!discord) return; + + const channelId = getChannelId(instanceName); if (!channelId) return; - let channel: Discord.Channel | null; - - try { - channel = await client.channels.fetch(channelId); - } catch (err) { - if ((err as {code?: number}).code !== 10003) { - logger.error(`[Chat Relay] Discord channel fetch error:\n${(err as Error).stack}`); - } - return; - } - - if (channel === null || !channel.isSendable()) { - logger.error(`[Chat Relay] Discord Channel ID ${channelId} is not a usable channel.`); - return; - } - - let msg = message; - + // Read the flag once, so it can't flip between chunks of one message. + let content = message; if (controller.config.get('chat_relay.datetime_on_message')) { - msg = `${datetime} ${msg}`; + content = `${datetime} ${content}`; } - const send = (content: string) => channel.send({content, allowedMentions: {parse: []}}); + // trim() here also means an empty chat line produces zero chunks rather than + // a 50006 'cannot send an empty message' from Discord. + const chunks = splitMessage(content.trim(), MAX_MESSAGE_LENGTH); - if (msg.length <= MAX_DISCORD_MESSAGE_LENGTH) { - await send(msg); - return; - } + for (const chunk of chunks) { + try { + // discord.js REST handles rate limits (429) and retries for us. + await discord.post(Routes.channelMessages(channelId), { + body: {content: chunk, allowedMentions: {parse: []}}, + }); + } catch (err) { + if (codeOf(err) === ERROR_UNKNOWN_CHANNEL) { + if (!warnedBadChannel.has(channelId)) { + warnedBadChannel.add(channelId); + logger.warn( + `[Chat Relay] Discord channel ${channelId} does not exist or is not visible` + + ` to the bot; dropping messages for ${instanceName}.`, + ); + } + } else { + logger.error(`[Chat Relay] Discord send failed for ${instanceName}:\n${stackOf(err)}`); + } - let remaining = msg; - - while (remaining.length > 0) { - let chunk = remaining.slice(0, MAX_DISCORD_MESSAGE_LENGTH); - const lastSpace = chunk.lastIndexOf(' '); - - if (lastSpace > 0) { - chunk = chunk.slice(0, lastSpace); - remaining = remaining.slice(lastSpace).trim(); - } else { - remaining = remaining.slice(MAX_DISCORD_MESSAGE_LENGTH).trim(); + // Don't keep hammering a channel that just rejected us. + return; } - - await send(chunk); } }; controller.handle(ChatEvent, async (request: ChatEvent) => { - const {instance_name, datetime, message} = request; - await sendMessage(instance_name, datetime, message); + await sendMessage(request.instance_name, request.datetime, request.message); }); controller.config.on('fieldChanged', (field) => { if (field === 'chat_relay.discord_bot_token') { - connect().catch(err => { - logger.error(`[Chat Relay] Discord bot token:\n${(err as Error).stack}`); + enqueue(connect).catch(err => { + logger.error(`[Chat Relay] Discord reconnect failed:\n${stackOf(err)}`); }); + } else if (field === 'chat_relay.discord_channel_mapping') { + warnedNoMapping.clear(); + warnedBadChannel.clear(); } }); controller.hooks.shutdown.attach(plugin.name, async () => { - await clientDestroy(); + // Nothing to tear down without a gateway; just stop accepting new sends. + // In-flight REST calls aren't cancellable and will finish or time out. + shuttingDown = true; }); await connect();