From bd7d027702e6b1febe4d46e3ac1a095a32bf3a2d Mon Sep 17 00:00:00 2001 From: PHIDIAS Date: Tue, 13 Oct 2026 01:37:47 +0900 Subject: [PATCH] 1.0.6 RC2 --- controller.ts | 288 +++++++++++++++++++++++++++++++++++++++++++------- index.ts | 45 ++++++++ instance.ts | 27 ++++- package.json | 2 +- 4 files changed, 320 insertions(+), 42 deletions(-) diff --git a/controller.ts b/controller.ts index 008b4a0..78fa507 100644 --- a/controller.ts +++ b/controller.ts @@ -1,21 +1,25 @@ import type {ControllerPluginContext} from '@clusterio/controller'; -import {REST, Routes} from 'discord.js'; -import {ChatEvent} from './index.js'; +import {Client, GatewayIntentBits, REST, Routes} from 'discord.js'; +import type {Message} from 'discord.js'; +import {ChatEvent, ChatFromDiscordEvent} from './index.js'; -/* 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. -*/ +/* 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. + */ // 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. +const INBOUND_BUCKET_SIZE = 5; +const INBOUND_REFILL_PER_SEC = 0.5; + interface DiscordError { code?: number; status?: number; @@ -74,16 +78,29 @@ function splitMessage(text: string, limit: number): string[] { return chunks.filter(chunk => chunk.length > 0); } +interface Bucket { + tokens: number; + updated: number; + warned: boolean; +} + export default async function(context: ControllerPluginContext) { const {controller, logger, plugin} = context; let rest: REST | null = null; + let client: Client | null = null; let shuttingDown = false; - // 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. + // Log-once guards, cleared whenever the config field that feeds them changes. 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. + let channelToInstances = new Map(); + const buckets = new Map(); // Serialise reconnects so two quick config changes can't interleave and have // the older attempt null out the newer client. @@ -94,28 +111,9 @@ export default async function(context: ControllerPluginContext) { return run; }; - const connect = async () => { - rest = null; + const getToken = () => controller.config.get('chat_relay.discord_bot_token') as string | undefined; - 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; - } - - const client = new REST({version: '10'}).setToken(token); - - try { - // 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 token rejected:\n${stackOf(err)}`); - return; - } - - rest = client; - }; + // ---------------------------------------------------------------- outbound const getChannelId = (instanceName: string): string | null => { const mapping = controller.config.get('chat_relay.discord_channel_mapping') as Record | undefined; @@ -137,25 +135,21 @@ export default async function(context: ControllerPluginContext) { if (shuttingDown) return; const discord = rest; - // Not configured, or the token was rejected. connect() already logged. + // not configured, or the token was rejected; connect() 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: []}}, }); @@ -172,12 +166,218 @@ 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 + + const rebuildInboundMap = () => { + const mapping = controller.config.get('chat_relay.discord_to_factorio_channel_mapping') as + Record | undefined; + + const next = new Map(); + + for (const [instanceName, channelId] of Object.entries(mapping ?? {})) { + const names = next.get(channelId) ?? []; + names.push(instanceName); + next.set(channelId, names); + } + + for (const [channelId, names] of next) { + 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.`, + ); + } + } + + channelToInstances = next; + }; + + const hasInboundMapping = () => channelToInstances.size > 0; + + /** 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); + + if (!bucket) { + bucket = {tokens: INBOUND_BUCKET_SIZE, updated: now, warned: false}; + buckets.set(channelId, bucket); + } + + bucket.tokens = Math.min( + INBOUND_BUCKET_SIZE, + bucket.tokens + ((now - bucket.updated) / 1000) * INBOUND_REFILL_PER_SEC, + ); + bucket.updated = now; + + // Re-arm the warning once the channel has calmed down. + if (bucket.tokens >= INBOUND_BUCKET_SIZE) bucket.warned = false; + + if (bucket.tokens < 1) { + if (!bucket.warned) { + bucket.warned = true; + logger.warn(`[Chat Relay] Rate limiting inbound chat from Discord channel ${channelId}; dropping.`); + } + + return false; + } + + bucket.tokens -= 1; + 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; + + // 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 + + 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 (!takeToken(message.channelId)) return; + + const authorName = sanitiseInbound(message.member?.displayName ?? message.author.username, 40); + + 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, + })); + } catch (err) { + // A stopped instance plus 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)}`); + } + } + } + }; + + // ------------------------------------------------------------------ client + + const closeGateway = async () => { + const old = client; + client = null; + + if (old) { + try { + await old.destroy(); + } catch (err) { + logger.error(`[Chat Relay] Discord gateway shutdown error:\n${stackOf(err)}`); + } + } + }; + + /** Assumes `rest` is authenticated; the token it was built from is the live one. */ + const openGateway = async () => { + const token = getToken(); + if (!token || !rest || client) return; + + const newClient = new Client({intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ]}); + + newClient.on('messageCreate', message => { + onDiscordMessage(message).catch(err => { + 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.')); + + try { + await newClient.login(token); + } catch (err) { + // Outbound still works off `rest`; only inbound is down. + logger.error(`[Chat Relay] Discord gateway login failed (inbound disabled):\n${stackOf(err)}`); + await newClient.destroy().catch(() => {}); + return; + } + + if (shuttingDown) { + await newClient.destroy().catch(() => {}); + return; + } + + client = newClient; + logger.info('[Chat Relay] Discord gateway connected (inbound chat enabled).'); + }; + + const syncGateway = async () => { + if (!hasInboundMapping()) { + await closeGateway(); + return; + } + + await openGateway(); + }; + + const connect = async () => { + rest = null; + await closeGateway(); + + const token = getToken(); + if (!token) { + logger.error('[Chat Relay] Discord bot token not configured.'); + return; + } + + const newRest = new REST({version: '10'}).setToken(token); + + try { + 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) { + logger.error(`[Chat Relay] Discord token rejected:\n${stackOf(err)}`); + return; + } + + if (shuttingDown) return; + + rest = newRest; + await syncGateway(); + }; + + // ------------------------------------------------------------------ wiring + + rebuildInboundMap(); + controller.handle(ChatEvent, async (request: ChatEvent) => { await sendMessage(request.instance_name, request.datetime, request.message); }); @@ -190,13 +390,21 @@ 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(); + buckets.clear(); + rebuildInboundMap(); + enqueue(syncGateway).catch(err => { + logger.error(`[Chat Relay] Discord gateway sync failed:\n${stackOf(err)}`); + }); } }); controller.hooks.shutdown.attach(plugin.name, async () => { - // 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 closeGateway(); }); await connect(); diff --git a/index.ts b/index.ts index 23fd33d..ff550d4 100644 --- a/index.ts +++ b/index.ts @@ -2,6 +2,51 @@ import type * as lib from '@clusterio/lib'; import type {Instance, InstancePluginContext} from '@clusterio/host'; import {Type, Static} from '@sinclair/typebox'; +export const chatFromDiscord = Type.Object({ + author_name: Type.String(), + channel_id: Type.String(), + datetime: Type.String(), + message: Type.String(), +}); + +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 const MAX_INBOUND_LENGTH = 400; + +// Factorio renders these, so a Discord user could spoof a colour, an item link, +// or a fake speaker line. Whitelist the real tags rather than matching \[.*\], +// which would also eat innocent text like "array[0]" or "[lol]". +const RICH_TEXT_RE = new RegExp( + String.raw`\[/?(?:color|font|item|entity|technology|recipe|fluid|tile|virtual-signal` + + String.raw`|gps|img|specialisation|achievement|train|planet|space-location|quality)(?:=[^\]]*)?\]`, + 'gi', +); + +/** Make arbitrary text safe to print in-game. Shared by both processes. */ +export function sanitiseForFactorio(text: string, limit = MAX_INBOUND_LENGTH): string { + let clean = text + .replace(RICH_TEXT_RE, '') + .replace(/[\u0000-\u001f\u007f]/g, '') // control chars, including newlines + .replace(/\s+/g, ' ') + .trim(); + + // Code points, not UTF-16 units: emoji paste from Discord is real here, and a + // lone surrogate in a Lua string sent over stdin is not something I'd trust. + const chars = Array.from(clean); + if (chars.length > limit) { + clean = `${chars.slice(0, limit - 3).join('').trimEnd()}...`; + } + + return clean; +} + export class ChatEvent { declare ['constructor']: typeof ChatEvent; static type = 'event' as const; diff --git a/instance.ts b/instance.ts index b623f2f..80a3c13 100644 --- a/instance.ts +++ b/instance.ts @@ -1,5 +1,5 @@ import type {InstancePluginContext} from '@clusterio/host'; -import {ChatEvent} from './index.js'; +import {ChatEvent, ChatFromDiscordEvent, sanitiseForFactorio} from './index.js'; interface ChatMessage { datetime: string; @@ -25,6 +25,13 @@ 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, '\\"')}"`; +} + export default async function(context: InstancePluginContext) { const {instance, host, plugin, logger} = context; const queue: ChatMessage[] = []; @@ -92,4 +99,22 @@ export default async function(context: InstancePluginContext) { await flush(); } }); + + instance.handle(ChatFromDiscordEvent, async (event: ChatFromDiscordEvent) => { + // Re-sanitise here too: any producer can publish this event, and this is + // the process that actually calls game.print. + const author = sanitiseForFactorio(event.author_name, 40); + const message = sanitiseForFactorio(event.message); + if (!message) return; + + const line = `[Discord] ${author}: ${message}`; + + try { + // game.print reaches every player and does NOT fire on_console_chat, + // 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}`); + } + }); } diff --git a/package.json b/package.json index a17881f..92f2f88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@phidias0303/chat_relay", - "version": "1.0.5", + "version": "1.0.6", "description": "Chat relay of Discord and Factorio, seperate channel", "author": "PHIDIAS ", "license": "MIT",