diff --git a/CHANGELOG.md b/CHANGELOG.md index 52b159d..d3c39ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.0.6 + +- Bidirectional + +## v1.0.5 + +- Action and player count + ## v1.0.3 - Changed to TS diff --git a/controller.ts b/controller.ts index 78fa507..577b48f 100644 --- a/controller.ts +++ b/controller.ts @@ -6,14 +6,20 @@ import {ChatEvent, ChatFromDiscordEvent} from './index.js'; /* Controller-side halves of the chat relay. * Outbound (Factorio -> Discord) talks to the REST API directly: no socket, no intents. * Inbound (Discord -> Factorio) needs messageCreate, so it owns a real gateway Client. - * Both come from the same token and are rebuilt by the same enqueue chain, so rotating - * the token can't leave one half running on the old credential. + * Both are built from the same token and rebuilt by the same enqueue chain, so a token + * rotation can't leave one half running on the old credential. + * + * Both directions read the single `discord_channel_mapping` field (instance name -> + * channel ID). The reverse lookup is built by inverting it, so the two directions can + * never disagree about which channel belongs to which instance. */ // 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; // Factorio chat scroll is a shared, scarce resource; be stricter than Discord is. @@ -36,7 +42,7 @@ function splitMessage(text: string, limit: number): string[] { return text.length === 0 ? [] : [text]; } - // Array.from, not slice, so we never cut an emoji's surrogate pair in half. + // Array.from, not slice, so we never cut a surrogate pair in half. const chars = Array.from(text); const chunks: string[] = []; let offset = 0; @@ -91,14 +97,15 @@ export default async function(context: ControllerPluginContext) { let client: Client | null = null; let shuttingDown = false; - // Log-once guards, cleared whenever the config field that feeds them changes. + // 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(); const warnedAmbiguous = new Set(); const warnedNoInstance = new Set(); const warnedSendFail = new Set(); - // Inbound channel -> instance names, rebuilt from config rather than inverted lazily. + // Inverted view of discord_channel_mapping, rebuilt rather than mutated in place. let channelToInstances = new Map(); const buckets = new Map(); @@ -113,16 +120,20 @@ export default async function(context: ControllerPluginContext) { const getToken = () => controller.config.get('chat_relay.discord_bot_token') as string | undefined; - // ---------------------------------------------------------------- outbound + // --------------------------------------------------------------- outbound const getChannelId = (instanceName: string): string | null => { - const mapping = controller.config.get('chat_relay.discord_channel_mapping') as Record | undefined; + const mapping = controller.config.get('chat_relay.discord_channel_mapping') as + Record | undefined; const channelId = mapping?.[instanceName]; if (!channelId) { if (!warnedNoMapping.has(instanceName)) { warnedNoMapping.add(instanceName); - logger.warn(`[Chat Relay] No Discord channel mapped for instance ${instanceName}; dropping its messages.`); + logger.warn( + `[Chat Relay] No Discord channel mapped for instance ${instanceName};` + + ' dropping its messages.', + ); } return null; @@ -135,21 +146,25 @@ export default async function(context: ControllerPluginContext) { if (shuttingDown) return; const discord = rest; - // not configured, or the token was rejected; connect() logged it + // Not configured, or the token was rejected. connect() already logged it. if (!discord) return; const channelId = getChannelId(instanceName); if (!channelId) return; + // 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')) { content = `${datetime} ${content}`; } + // 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); 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: []}}, }); @@ -166,20 +181,24 @@ export default async function(context: ControllerPluginContext) { logger.error(`[Chat Relay] Discord send failed for ${instanceName}:\n${stackOf(err)}`); } + // Don't keep hammering a channel that just rejected us. return; } } }; - // ----------------------------------------------------------------- inbound + // ---------------------------------------------------------------- inbound const rebuildInboundMap = () => { - const mapping = controller.config.get('chat_relay.discord_to_factorio_channel_mapping') as + const mapping = controller.config.get('chat_relay.discord_channel_mapping') as Record | undefined; const next = new Map(); for (const [instanceName, channelId] of Object.entries(mapping ?? {})) { + // A blank value would otherwise map the literal channel ''. + if (!channelId) continue; + const names = next.get(channelId) ?? []; names.push(instanceName); next.set(channelId, names); @@ -189,8 +208,8 @@ export default async function(context: ControllerPluginContext) { if (names.length > 1 && !warnedAmbiguous.has(channelId)) { warnedAmbiguous.add(channelId); logger.warn( - `[Chat Relay] Discord channel ${channelId} is mapped from ${names.length} instances` - + ` (${names.join(', ')}); messages are printed to all of them.`, + `[Chat Relay] Discord channel ${channelId} is mapped by ${names.length} instances` + + ` (${names.join(', ')}); its messages are printed in all of them.`, ); } } @@ -200,7 +219,7 @@ export default async function(context: ControllerPluginContext) { const hasInboundMapping = () => channelToInstances.size > 0; - /** Token bucket per channel. Discord is far chattier than Factorio chat ever is. */ + /** Token bucket per channel: Discord is far chattier than Factorio chat ever is. */ const takeToken = (channelId: string): boolean => { const now = Date.now(); let bucket = buckets.get(channelId); @@ -222,7 +241,7 @@ export default async function(context: ControllerPluginContext) { if (bucket.tokens < 1) { if (!bucket.warned) { bucket.warned = true; - logger.warn(`[Chat Relay] Rate limiting inbound chat from Discord channel ${channelId}; dropping.`); + logger.warn(`[Chat Relay] Rate limiting inbound chat from channel ${channelId}; dropping.`); } return false; @@ -232,62 +251,46 @@ export default async function(context: ControllerPluginContext) { return true; }; - const findInstanceId = (instanceName: string): number | null => { - for (const [id, instance] of controller.instances) { - if (instance.name === instanceName) return id; - } - - return null; - }; - const onDiscordMessage = async (message: Message) => { - if (shuttingDown || !client) return; + if (shuttingDown) return; // The loop guard. Our own outbound posts are authored by the bot, as are // webhooks and other bots, so Factorio -> Discord -> Factorio dies here. if (message.author.bot) return; - if (!message.inGuild()) return; // DMs and group DMs aren't mapped to anything + + // DMs, group DMs and other unmapped guilds have no instance behind them. + if (!message.inGuild()) return; const instanceNames = channelToInstances.get(message.channelId); - if (!instanceNames) return; // unmapped channel, another guild, or a thread - - const text = sanitiseInbound(message.content); - if (!text) return; + if (!instanceNames) return; + if (!message.content) return; if (!takeToken(message.channelId)) return; - const authorName = sanitiseInbound(message.member?.displayName ?? message.author.username, 40); + const datetime = message.createdAt.toISOString(); + const authorName = message.member?.displayName ?? message.author.username; for (const instanceName of instanceNames) { - const instanceId = findInstanceId(instanceName); - - if (instanceId === null) { - if (!warnedNoInstance.has(instanceName)) { - warnedNoInstance.add(instanceName); - logger.warn(`[Chat Relay] Instance ${instanceName} is mapped for inbound chat but does not exist.`); - } - - continue; - } - try { - await controller.sendTo(instanceId, new ChatFromDiscordEvent({ - author_name: authorName, - channel_id: message.channelId, - datetime: message.createdAt.toISOString(), - message: text, - })); + await controller.sendTo(instanceName, new ChatFromDiscordEvent( + authorName, + message.channelId, + datetime, + message.content, + )); } catch (err) { - // A stopped instance plus a busy channel would otherwise be a log flood. + // A stopped instance on a busy channel would otherwise be a log flood. if (!warnedSendFail.has(instanceName)) { warnedSendFail.add(instanceName); - logger.error(`[Chat Relay] Failed to relay Discord chat to ${instanceName}:\n${stackOf(err)}`); + logger.error( + `[Chat Relay] Failed to relay Discord chat to ${instanceName}:\n${stackOf(err)}`, + ); } } } }; - // ------------------------------------------------------------------ client + // ----------------------------------------------------------------- client const closeGateway = async () => { const old = client; @@ -302,7 +305,7 @@ export default async function(context: ControllerPluginContext) { } }; - /** Assumes `rest` is authenticated; the token it was built from is the live one. */ + /** Requires an authenticated `rest`; only ever called from connect()/syncGateway(). */ const openGateway = async () => { const token = getToken(); if (!token || !rest || client) return; @@ -318,6 +321,7 @@ export default async function(context: ControllerPluginContext) { logger.error(`[Chat Relay] Inbound handler failed:\n${stackOf(err)}`); }); }); + newClient.on('error', err => logger.error(`[Chat Relay] Discord gateway error:\n${stackOf(err)}`)); newClient.on('shardDisconnect', () => logger.warn('[Chat Relay] Discord gateway disconnected.')); @@ -336,7 +340,7 @@ export default async function(context: ControllerPluginContext) { } client = newClient; - logger.info('[Chat Relay] Discord gateway connected (inbound chat enabled).'); + logger.info('[Chat Relay] Discord gateway connected; inbound chat enabled.'); }; const syncGateway = async () => { @@ -349,6 +353,8 @@ export default async function(context: ControllerPluginContext) { }; const connect = async () => { + // Drop both halves first: the old REST client holds the previous token, and + // the old gateway is logged in with it. rest = null; await closeGateway(); @@ -361,6 +367,7 @@ export default async function(context: ControllerPluginContext) { const newRest = new REST({version: '10'}).setToken(token); try { + // Fail fast at startup instead of silently on the first chat line. const me = await newRest.get(Routes.user('@me')) as {id: string, username: string}; logger.info(`[Chat Relay] Authenticated with Discord as ${me.username} (${me.id}).`); } catch (err) { @@ -374,7 +381,7 @@ export default async function(context: ControllerPluginContext) { await syncGateway(); }; - // ------------------------------------------------------------------ wiring + // ----------------------------------------------------------------- wiring rebuildInboundMap(); @@ -390,7 +397,6 @@ export default async function(context: ControllerPluginContext) { } else if (field === 'chat_relay.discord_channel_mapping') { warnedNoMapping.clear(); warnedBadChannel.clear(); - } else if (field === 'chat_relay.discord_to_factorio_channel_mapping') { warnedAmbiguous.clear(); warnedNoInstance.clear(); warnedSendFail.clear(); @@ -403,6 +409,8 @@ export default async function(context: ControllerPluginContext) { }); controller.hooks.shutdown.attach(plugin.name, async () => { + // Nothing to tear down on the REST side; in-flight calls aren't cancellable + // and will finish or time out. The gateway does need an explicit destroy. shuttingDown = true; await closeGateway(); }); diff --git a/index.ts b/index.ts index ff550d4..373dc6b 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,11 @@ import type * as lib from '@clusterio/lib'; -import type {Instance, InstancePluginContext} from '@clusterio/host'; -import {Type, Static} from '@sinclair/typebox'; +import {Type} from '@sinclair/typebox'; +import type {Static} from '@sinclair/typebox'; + +/* Shared declarations for the chat relay plugin. + * `sanitiseForFactorio` is used by both processes: the controller cleans text + * before it leaves, and the instance cleans it again before it reaches game.print. + */ export const chatFromDiscord = Type.Object({ author_name: Type.String(), @@ -11,10 +16,25 @@ export const chatFromDiscord = Type.Object({ export type ChatFromDiscord = Static; -// Mirror whatever base class / constructor shape your ChatEvent uses. -export class ChatFromDiscordEvent extends Event { - constructor(data: ChatFromDiscord) { - super(chatFromDiscord, data); +export class ChatFromDiscordEvent { + declare ['constructor']: typeof ChatFromDiscordEvent; + static type = 'event' as const; + static src = 'controller' as const; + static dst = 'instance' as const; + static plugin = 'chat_relay' as const; + static permission = null; + + constructor( + public author_name: string, + public channel_id: string, + public datetime: string, + public message: string, + ) {} + + static jsonSchema = chatFromDiscord; + + static fromJSON(json: Static) { + return new this(json.author_name, json.channel_id, json.datetime, json.message); } } @@ -48,68 +68,69 @@ export function sanitiseForFactorio(text: string, limit = MAX_INBOUND_LENGTH): s } export class ChatEvent { - declare ['constructor']: typeof ChatEvent; - static type = 'event' as const; - static src = 'instance' as const; - static dst = 'controller' as const; - static plugin = 'chat_relay' as const; - static permission = null; + declare ['constructor']: typeof ChatEvent; + static type = 'event' as const; + static src = 'instance' as const; + static dst = 'controller' as const; + static plugin = 'chat_relay' as const; + static permission = null; - constructor( - public instance_name: string, - public datetime: string, - public message: string, - ) {} + constructor( + public instance_name: string, + public datetime: string, + public message: string, + ) {} - static jsonSchema = Type.Object({ - 'instance_name': Type.String(), - 'datetime': Type.String(), - 'message': Type.String(), - }); + static jsonSchema = Type.Object({ + 'instance_name': Type.String(), + 'datetime': Type.String(), + 'message': Type.String(), + }); - static fromJSON(json: Static) { - return new this(json.instance_name, json.datetime, json.message); - } + static fromJSON(json: Static) { + return new this(json.instance_name, json.datetime, json.message); + } } declare module '@clusterio/lib' { interface ControllerConfigFields { 'chat_relay.discord_bot_token': string; 'chat_relay.datetime_on_message': boolean; - 'chat_relay.discord_channel_mapping': Record; - } + 'chat_relay.discord_channel_mapping': Record; + } } export const plugin: lib.PluginDeclaration = { - name: 'chat_relay', - title: 'Chat Relay', - description: 'One way chat relay to Discord.', - webEntrypoint: './web', - instanceEntrypoint: 'dist/node/instance.js', - controllerEntrypoint: 'dist/node/controller.js', - controllerConfigFields: { - 'chat_relay.discord_bot_token': { - title: 'Discord Bot Token', - description: 'API Token', - type: 'string', - credential: ['controller'] - }, - 'chat_relay.datetime_on_message': { - title: 'Message Datetime', - description: 'Append datetime in front', - type: 'boolean', - initialValue: true - }, - 'chat_relay.discord_channel_mapping': { - title: 'Discord Channels', - description: 'Instance and Discord channel ID relations', - type: 'object', - initialValue: { - 'S1': '123' - }, - }, - }, - messages: [ - ChatEvent, - ], + name: 'chat_relay', + title: 'Chat Relay', + description: 'Bidirectional chat relay between Factorio and Discord.', + webEntrypoint: './web', + instanceEntrypoint: 'dist/node/instance.js', + controllerEntrypoint: 'dist/node/controller.js', + controllerConfigFields: { + 'chat_relay.discord_bot_token': { + title: 'Discord Bot Token', + description: 'API Token', + type: 'string', + credential: ['controller'], + }, + 'chat_relay.datetime_on_message': { + title: 'Message Datetime', + description: 'Append datetime in front', + type: 'boolean', + initialValue: true, + }, + 'chat_relay.discord_channel_mapping': { + title: 'Discord Channels', + description: 'Instance and Discord channel ID relations, used in both directions', + type: 'object', + initialValue: { + 'S1': '123', + }, + }, + }, + messages: [ + ChatEvent, + ChatFromDiscordEvent, + ], }; diff --git a/instance.ts b/instance.ts index 80a3c13..3cba068 100644 --- a/instance.ts +++ b/instance.ts @@ -3,7 +3,7 @@ import {ChatEvent, ChatFromDiscordEvent, sanitiseForFactorio} from './index.js'; interface ChatMessage { datetime: string; - message: string; + message: string; } function datetime(): string { @@ -25,8 +25,6 @@ function parse_save(line: string): number | null { return slot ? Number(slot[1]) : null; } -/* Instance-side half of inbound chat: turn a relayed Discord message into a chat line. */ - /** Escape a JS string for embedding in a double-quoted Lua literal. */ function luaString(text: string): string { return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; @@ -37,11 +35,17 @@ export default async function(context: InstancePluginContext) { const queue: ChatMessage[] = []; let flushing = false; + // Log-once guard: a busy channel plus a stopped instance or dead RCON would + // otherwise write one error per message. Re-armed on controller connect. + const warnedPrintFail = new Set(); + const flush = async () => { if (flushing || queue.length === 0 || !host.connector.connected) { return; } + flushing = true; + try { while (queue.length > 0 && host.connector.connected) { try { @@ -59,6 +63,7 @@ export default async function(context: InstancePluginContext) { instance.hooks.controllerConnectionEvent.attach(plugin.name, async (event) => { if (event === 'connect') { + warnedPrintFail.clear(); await flush(); } }); @@ -87,7 +92,7 @@ export default async function(context: InstancePluginContext) { await flush(); } else if (output.type === 'log') { - let message = output.message.trim(); + const message = output.message.trim(); const save_slot = parse_save(message); if (!save_slot) { @@ -114,7 +119,10 @@ export default async function(context: InstancePluginContext) { // so this cannot echo back out through the outbound half. await instance.sendRcon(`/sc game.print(${luaString(line)})`); } catch (err) { - logger.error(`[Chat Relay] Failed to print Discord message in game:\n${(err as Error).stack}`); + if (!warnedPrintFail.has(instance.name)) { + warnedPrintFail.add(instance.name); + logger.error(`[Chat Relay] Failed to print Discord message in game:\n${(err as Error).stack}`); + } } }); }