28 Commits
Author SHA1 Message Date
phidias c56f2f03de 1.0.7 RC4 2026-10-29 19:28:31 +09:00
phidias 984613de7d 1.0.7 RC3 2026-10-29 17:27:04 +09:00
phidias 5461422397 1.0.7 RC2 2026-10-29 15:51:25 +09:00
phidias 3b223191da 1.0.7 RC1 2026-10-19 01:09:14 +09:00
phidias 4f6c380859 1.0.6 RC9 2026-10-13 13:04:21 +09:00
phidias 43eb44a9f0 1.0.6 RC8 2026-10-13 02:25:19 +09:00
phidias fdf9c678f7 1.0.6 RC7 2026-10-13 02:23:53 +09:00
phidias 410371870f 1.0.6 RC6 2026-10-13 02:17:21 +09:00
phidias 763dd4628f 1.0.6 RC5 2026-10-13 02:14:20 +09:00
phidias 969a770663 1.0.6 RC4 2026-10-13 02:11:58 +09:00
phidias 6a51d36d14 1.0.6 RC3 2026-10-13 02:01:35 +09:00
phidias bd7d027702 1.0.6 RC2 2026-10-13 01:37:47 +09:00
phidias 5aa834c237 1.0.6 RC1 2026-10-13 01:28:26 +09:00
phidias a7158bfb62 1.0.5 RC11 2026-10-12 23:21:10 +09:00
phidias 249a0727a5 1.0.5 RC10 2026-10-12 23:20:20 +09:00
phidias 628b1aafe2 1.0.5 RC9 2026-10-12 23:19:41 +09:00
phidias b5090e1acf 1.0.5 RC8 2026-10-12 23:14:11 +09:00
phidias 9e89a60af7 1.0.5 RC7 2026-10-12 23:12:20 +09:00
phidias 1e49565691 1.0.5 RC6 2026-10-12 23:12:04 +09:00
phidias 2420a76256 1.0.5 RC5 2026-10-12 22:22:18 +09:00
phidias 7f5a9e5ee1 1.0.5 RC4 2026-10-12 22:13:43 +09:00
phidias 0e0cdac49f . 2026-10-12 22:03:44 +09:00
phidias b943e046f5 . 2026-10-12 22:00:53 +09:00
phidias 789fb9c162 1.0.5 RC3 2026-10-12 21:56:07 +09:00
phidias 08360ae71a 1.0.5 RC2 2026-10-12 20:36:07 +09:00
phidias faa6751c3f 1.0.5 RC1 2026-10-12 20:31:36 +09:00
phidias e99d52b0a3 1.0.5 D 2026-10-12 20:30:40 +09:00
phidias 5e1a5c12d4 1.0.5 D 2026-10-12 20:18:11 +09:00
11 changed files with 816 additions and 217 deletions

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# Changelog
## v1.0.6
- Bidirectional
## v1.0.5
- Action and player count
## v1.0.3
- Changed to TS
+188 -17
View File
@@ -1,21 +1,192 @@
MIT License
Copyright (c) 2025 PHIDIAS
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Definitions.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025 PHIDIAS
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+1 -1
View File
@@ -1,7 +1,7 @@
Chat Relay
========================
One way chat relay to Discord.
Chat relay of Discord and Factorio, seperate channel
Plugin of Clusterio
https://github.com/clusterio/clusterio
+401 -76
View File
@@ -1,117 +1,442 @@
import type {ControllerPluginContext} from '@clusterio/controller';
import {ChatEvent} from './index.js';
import * as Discord from 'discord.js';
import {Client, GatewayIntentBits, REST, Routes} from 'discord.js';
import type {Message} from 'discord.js';
import {ChatEvent, ChatFromDiscordEvent} from './index.js';
const MAX_DISCORD_MESSAGE_LENGTH = 1950;
/* 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 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.
const INBOUND_BUCKET_SIZE = 5;
const INBOUND_REFILL_PER_SEC = 0.5;
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 a 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);
}
interface Bucket {
tokens: number;
updated: number;
warned: boolean;
}
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 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.
const warnedNoMapping = new Set<string>();
const warnedBadChannel = new Set<string>();
const warnedAmbiguous = new Set<string>();
const warnedNoInstance = new Set<string>();
const warnedSendFail = new Set<string>();
// Inverted view of discord_channel_mapping, rebuilt rather than mutated in place.
let channelToInstances = new Map<string, string[]>();
const buckets = new Map<string, Bucket>();
// Serialise reconnects so two quick config changes can't interleave and have
// the older attempt null out the newer client.
let chain: Promise<unknown> = Promise.resolve();
const enqueue = <T>(fn: () => Promise<T>): Promise<T> => {
const run = chain.then(fn, fn);
chain = run.catch(() => {});
return run;
};
const getToken = () => controller.config.get('chat_relay.discord_bot_token') as string | undefined;
// --------------------------------------------------------------- outbound
const getChannelId = (instanceName: string): string | null => {
const mapping = controller.config.get('chat_relay.discord_channel_mapping') as
Record<string, string> | 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.',
);
}
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 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: []}},
});
} 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)}`);
}
// Don't keep hammering a channel that just rejected us.
return;
}
}
};
// ---------------------------------------------------------------- inbound
const rebuildInboundMap = () => {
const mapping = controller.config.get('chat_relay.discord_channel_mapping') as
Record<string, string> | undefined;
const next = new Map<string, string[]>();
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);
}
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 by ${names.length} instances`
+ ` (${names.join(', ')}); its messages are printed in 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 channel ${channelId}; dropping.`);
}
return false;
}
bucket.tokens -= 1;
return true;
};
/** Config is keyed by instance name; sendTo wants the controller's numeric ID. */
const findInstanceId = (instanceName: string): number | null => {
for (const instance of controller.instances.values()) {
if (instance.config.get('instance.name') === instanceName) return instance.id;
}
return null;
};
const onDiscordMessage = async (message: Message) => {
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;
// 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;
if (!message.content) return;
if (!takeToken(message.channelId)) return;
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 chat but does not exist;`
+ ' dropping Discord messages for it.',
);
}
continue;
}
try {
await controller.sendTo({instanceId: instanceId}, new ChatFromDiscordEvent(
authorName,
message.channelId,
datetime,
message.content,
));
} catch (err) {
// 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)}`,
);
}
}
}
};
// ----------------------------------------------------------------- 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)}`);
}
}
};
/** Requires an authenticated `rest`; only ever called from connect()/syncGateway(). */
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 () => {
await clientDestroy();
const token = controller.config.get('chat_relay.discord_bot_token') as string | undefined;
// 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();
const token = getToken();
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 newRest = new REST({version: '10'}).setToken(token);
try {
await client.login(token);
// 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) {
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.');
if (shuttingDown) return;
rest = newRest;
await syncGateway();
};
const sendMessage = async (instance_name: string, datetime: string, message: string) => {
if (!client) return;
// ----------------------------------------------------------------- wiring
const mapping = controller.config.get('chat_relay.discord_channel_mapping') as Record<string, string>;
const channelId = mapping?.[instance_name];
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;
if (controller.config.get('chat_relay.datetime_on_message')) {
msg = `${datetime} ${msg}`;
}
const send = (content: string) => channel.send({content, allowedMentions: {parse: []}});
if (msg.length <= MAX_DISCORD_MESSAGE_LENGTH) {
await send(msg);
return;
}
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();
}
await send(chunk);
}
};
rebuildInboundMap();
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();
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 () => {
await clientDestroy();
// 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();
});
await connect();
+121 -53
View File
@@ -1,70 +1,138 @@
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(),
channel_id: Type.String(),
datetime: Type.String(),
message: Type.String(),
});
export type ChatFromDiscord = Static<typeof chatFromDiscord>;
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<typeof ChatFromDiscordEvent.jsonSchema>) {
return new this(json.author_name, json.channel_id, json.datetime, json.message);
}
}
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;
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<typeof ChatEvent.jsonSchema>) {
return new this(json.instance_name, json.datetime, json.message);
}
static fromJSON(json: Static<typeof ChatEvent.jsonSchema>) {
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<string, string>;
}
'chat_relay.discord_channel_mapping': Record<string, string>;
}
}
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.',
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,
],
features: [
'ScriptCommands',
],
};
+83 -30
View File
@@ -1,28 +1,51 @@
import type {InstancePluginContext} from '@clusterio/host';
import {ChatEvent} from './index.js';
import {ChatEvent, ChatFromDiscordEvent, sanitiseForFactorio} from './index.js';
interface ChatMessage {
datetime: string;
message: string;
message: string;
}
const ACTION_VERBS: Record<string, string> = {
JOIN: 'joined the game',
LEAVE: 'left the game',
KICK: 'was kicked from the game',
BAN: 'was banned from the game',
};
function datetime(): string {
const now = new Date();
const p = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())} ${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
}
// Message body only. `.+?` (not `\S+`) so a manual save named 'my map' still parses.
const SAVE_LINE_RE = /Saving to (.+?) \((?:non-)?blocking\)\./;
// Factorio's autosaves are literally named '_autosave1'..'_autosaveN'.
const AUTOSAVE_NAME_RE = /^_autosave(\d+)$/;
function parse_save(line: string): number | null {
const m = SAVE_LINE_RE.exec(line);
if (!m) return null;
const slot = AUTOSAVE_NAME_RE.exec(m[1]);
return slot ? Number(slot[1]) : null;
}
/** 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[] = [];
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<string>();
const flush = async () => {
if (flushing || queue.length === 0 || !host.connector.connected) {
return;
}
flushing = true;
try {
while (queue.length > 0 && host.connector.connected) {
try {
@@ -40,37 +63,67 @@ export default async function(context: InstancePluginContext) {
instance.hooks.controllerConnectionEvent.attach(plugin.name, async (event) => {
if (event === 'connect') {
warnedPrintFail.clear();
await flush();
}
});
instance.hooks.output.attach(plugin.name, async (output) => {
if (output.type !== 'action') {
return;
if (output.type === 'action') {
const dt = datetime();
const action = output.action;
let message = output.message.trim();
if (action === 'CHAT' || action === 'SHOUT') {
message = message.replace(/\[special-item=.*?\]/g, '<blueprint>').replace(/<@/g, '<@\u200c>');
const message_index = message.indexOf(':');
const message_username = message_index === -1 ? message : message.slice(0, message_index);
const message_content = message_index === -1 ? '' : message.slice(message_index + 1).trim();
queue.push({datetime: dt, message: `**\`${message_username}\`**: ${message_content}`});
} else if (action === 'JOIN' || action === 'LEAVE' || action === 'KICK') {
const message_index = message.indexOf(' ');
const message_username = message_index === -1 ? message : message.slice(0, message_index);
const message_content = message_index === -1 ? '' : message.slice(message_index + 1).trim();
const player_count = instance.playersOnline.size + (action === 'LEAVE' ? -1 : 0);
queue.push({datetime: dt, message: `**\`${message_username}\`** ${message_content} (${player_count})`});
}
await flush();
} else if (output.type === 'log') {
const message = output.message.trim();
const save_slot = parse_save(message);
if (!save_slot) {
return;
}
const dt = datetime();
queue.push({datetime: dt, message: `**AUTO SAVE SLOT ${save_slot}**`});
await flush();
}
});
const now = new Date();
const p = (n: number) => String(n).padStart(2, '0');
const dt = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())} ${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
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;
if (output.action === 'CHAT' || output.action === 'SHOUT') {
const message = output.message.trim().replace(/\[special-item=.*?\]/g, '<blueprint>').replace(/<@/g, '<@\u200c>');
const message_index = message.indexOf(':');
const message_username = message_index === -1 ? message.trim() : message.slice(0, message_index);
const message_content = message_index === -1 ? '' : message.slice(message_index + 1).trim();
const line = `[DC] ${author}: ${message}`;
queue.push({datetime: dt, message: `**\`${message_username}\`**: ${message_content}`});
} else if (ACTION_VERBS[output.action]) {
const message = output.message.trim()
const name = message.toLowerCase().endsWith(ACTION_VERBS[output.action])
? message.slice(0, -ACTION_VERBS[output.action].length).trim()
: message;
queue.push({datetime: dt, message: `**\`${name}\`** ${ACTION_VERBS[output.action]} (${instance.playersOnline.size})`});
} else {
queue.push({datetime: dt, message: `unknown action ${output.action}`});
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)})`);
logger.info(`[Chat Relay] ${line}`);
} catch (err) {
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}`);
}
}
await flush();
});
}
+7 -7
View File
@@ -1,9 +1,9 @@
{
"name": "@phidias0303/chat_relay",
"version": "1.0.4",
"description": "One way chat relay to Discord",
"version": "1.0.7",
"description": "Chat relay of Discord and Factorio, seperate channel",
"author": "PHIDIAS <phidias@aperx.org>",
"license": "MIT",
"license": "Apache 2.0",
"homepage": "https://github.com/PHIDIAS0303/chat_relay#readme",
"repository": {
"type": "git",
@@ -22,18 +22,18 @@
],
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
"prepare": "tsc --build && vite build",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"peerDependencies": {
"@clusterio/controller": "workspace:^",
"@clusterio/lib": "workspace:^"
},
"devDependencies": {
"@clusterio/controller": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
"vite": "catalog:"
},
"dependencies": {
"discord.js": "^14.27.0",
+1 -1
View File
@@ -4,5 +4,5 @@
{"path": "../../packages/lib/tsconfig.browser.json"},
{"path": "../../packages/web_ui/tsconfig.browser.json"}
],
"include": ["web/**/*.tsx", "web/**/*.ts", "index.ts", "package.json"]
"include": ["index.ts", "package.json"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
-4
View File
@@ -1,4 +0,0 @@
import {type WebPluginContext} from '@clusterio/web_ui';
export default async function loadWebPlugin(context: WebPluginContext) {
}
-28
View File
@@ -1,28 +0,0 @@
'use strict';
const path = require('path');
const webpack = require('webpack');
const {merge} = require('webpack-merge');
const common = require('@clusterio/web_ui/webpack.common');
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: './web/index.tsx',
output: {
path: path.resolve(__dirname, 'dist', 'web'),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: 'chat_relay',
library: {type: 'var', name: 'plugin_chat_relay'},
exposes: {
'./': './index.ts',
'./package.json': './package.json',
'./web': './web/index.tsx',
},
shared: {
'@clusterio/lib': {import: false},
'@clusterio/web_ui': {import: false},
},
}),
],
});