This commit is contained in:
phidias authored and GitHub committed 2026-10-09 18:22:56 +09:00
1 parent ef5d18a171
commit b56904e8ae
13 files changed
+296 -222

No files matched your search

+4
View File
@@ -1,5 +1,9 @@
# Changelog
## v1.0.3
- Changed to TS
## v1.0.0
- Initial
-117
View File
@@ -1,117 +0,0 @@
'use strict';
const Discord = require('discord.js');
const lib = require('@clusterio/lib');
const {BaseControllerPlugin} = require('@clusterio/controller');
const {InstanceActionEvent} = require('./index.js');
const MAX_DISCORD_MESSAGE_LENGTH = 1950;
class ControllerPlugin extends BaseControllerPlugin {
async init() {
this.controller.config.on('fieldChanged', (field, curr, prev) => {
if (field === 'ClusterChatSync.discord_bot_token') {
this.connect().catch(err => {
this.logger.error(`[Chat Sync] Discord bot token:\n${err.stack}`);
});
}
});
this.controller.handle(InstanceActionEvent, this.handleInstanceAction.bind(this));
this.client = null;
await this.connect();
}
async clientDestroy() {
if (this.client) {
this.client.destroy();
this.client = null;
}
}
async connect() {
await this.clientDestroy();
if (!this.controller.config.get('ClusterChatSync.discord_bot_token')) {
this.logger.error('[Chat Sync] Discord bot token not configured.');
return;
}
this.client = new Discord.Client({intents: [Discord.GatewayIntentBits.Guilds, Discord.GatewayIntentBits.GuildMessages, Discord.GatewayIntentBits.MessageContent]});
this.logger.info('[Chat Sync] Logging into Discord.');
try {
await this.client.login(this.controller.config.get('ClusterChatSync.discord_bot_token'));
} catch (err) {
this.logger.error(`[Chat Sync] Discord login error:\n${err.stack}`);
await this.clientDestroy();
return;
}
this.logger.info('[Chat Sync] Logged in Discord successfully.');
}
async onShutdown() {
await this.clientDestroy();
}
async sendMessage(request, nrc_msg) {
const channel_id = this.controller.config.get('ClusterChatSync.discord_channel_mapping')[request.instanceName];
if (!channel_id) return;
let channel;
try {
channel = await this.client.channels.fetch(channel_id);
if (channel === null) {
this.logger.error(`[Chat Sync] Discord Channel ID ${channel_id} not found.`);
return;
}
} catch (err) {
if (err.code !== 10003) {
this.logger.error(`[Chat Sync] Discord channel fetch error:\n${err.stack}`);
}
}
if (this.controller.config.get('ClusterChatSync.datetime_on_message')) {
let now = new Date();
nrc_msg = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')} ${nrc_msg}`
}
if (nrc_msg.length <= MAX_DISCORD_MESSAGE_LENGTH) {
await channel.send(nrc_msg, {allowedMentions: {parse: []}});
} else {
while (nrc_msg.length > 0) {
let nrc_cmsg = nrc_msg.slice(0, MAX_DISCORD_MESSAGE_LENGTH);
let nrc_lindex = nrc_cmsg.lastIndexOf(' ');
if (nrc_lindex !== -1) {
nrc_cmsg = nrc_cmsg.slice(0, nrc_lindex);
nrc_msg = nrc_msg.slice(nrc_lindex).trim();
} else {
nrc_msg = nrc_msg.slice(MAX_DISCORD_MESSAGE_LENGTH).trim();
}
await channel.send(nrc_cmsg, {allowedMentions: {parse: []}});
}
}
}
async handleInstanceAction(request, src) {
if (request.action === 'CHAT' || request.action === 'SHOUT') {
const nrc = request.content.replace(/\[special-item=.*?\]/g, '<blueprint>').replace(/<@/g, '<@\u200c>');
const nrc_index = nrc.indexOf(':');
const nrc_username = nrc.substring(0, nrc_index);
const nrc_message = nrc.substring(nrc_index + 1).trim();
await this.sendMessage(request, `**\`${nrc_username}\`**: ${nrc_message}`);
} else if (request.action === 'JOIN') {
await this.sendMessage(request, `**\`${nrc_username}\`** joined the game`);
} else if (request.action === 'LEAVE') {
await this.sendMessage(request, `**\`${nrc_username}\`** left the game`);
} else if (request.action === 'KICK') {
await this.sendMessage(request, `**\`${nrc_username}\`** was kicked from the game`);
} else if (request.action === 'BAN') {
await this.sendMessage(request, `**\`${nrc_username}\`** was banned from the game`);
}
}
}
module.exports = {ControllerPlugin};
+148
View File
@@ -0,0 +1,148 @@
import type {ControllerPluginContext} from "@clusterio/controller";
import {ChatEvent} from './index.js';
import * as Discord from 'discord.js';
const MAX_DISCORD_MESSAGE_LENGTH = 1950;
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',
};
const escapeMentions = (content: string) => content
.replace(/\[special-item=.*?\]/g, '<blueprint>')
.replace(/<@/g, '<@\u200c>');
export default async function(context: ControllerPluginContext) {
const {controller, plugin, logger} = context;
let client: Discord.Client | null = null;
const clientDestroy = async () => {
const old = client;
client = null;
if (old) await old.destroy();
};
const connect = async () => {
await clientDestroy();
const token = controller.config.get('ClusterChatSync.discord_bot_token') as string | undefined;
if (!token) {
logger.error('[Chat Sync] Discord bot token not configured.');
return;
}
const discordClient = new Discord.Client({intents: [
Discord.GatewayIntentBits.Guilds,
Discord.GatewayIntentBits.GuildMessages,
Discord.GatewayIntentBits.MessageContent,
]});
client = discordClient;
logger.info('[Chat Sync] Logging into Discord.');
try {
await discordClient.login(token);
} catch (err) {
logger.error(`[Chat Sync] Discord login error:\n${(err as Error).stack}`);
await clientDestroy();
return;
}
logger.info('[Chat Sync] Logged in Discord successfully.');
};
const sendMessage = async (instanceName: string, message: string) => {
const discordClient = client;
if (!discordClient) return;
const mapping = controller.config.get('ClusterChatSync.discord_channel_mapping') as Record<string, string>;
const channelId = mapping?.[instanceName];
if (!channelId) return;
let channel: Discord.Channel | null;
try {
channel = await discordClient.channels.fetch(channelId);
} catch (err) {
if ((err as {code?: number}).code !== 10003) {
logger.error(`[Chat Sync] Discord channel fetch error:\n${(err as Error).stack}`);
}
return;
}
if (channel === null || !channel.isTextBased()) {
logger.error(`[Chat Sync] Discord Channel ID ${channelId} is not a usable text channel.`);
return;
}
const textChannel = channel;
let nrcMsg = message;
if (controller.config.get('ClusterChatSync.datetime_on_message')) {
const now = new Date();
const p = (n: number) => String(n).padStart(2, '0');
const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}` + ` ${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
nrcMsg = `${stamp} ${nrcMsg}`;
}
const send = (content: string) => textChannel.send({content, allowedMentions: {parse: []}});
if (nrcMsg.length <= MAX_DISCORD_MESSAGE_LENGTH) {
await send(nrcMsg);
return;
}
let remaining = nrcMsg;
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);
}
};
controller.handle(ChatEvent, async (request: ChatEvent) => {
const {instanceName, action, content} = request;
if (action === 'CHAT' || action === 'SHOUT') {
const nrc = escapeMentions(content);
const index = nrc.indexOf(':');
const username = index === -1 ? nrc.trim() : nrc.slice(0, index);
const msg = index === -1 ? '' : nrc.slice(index + 1).trim();
await sendMessage(instanceName, `**\`${username}\`**: ${msg}`);
return;
}
const verb = ACTION_VERBS[action];
if (verb) {
await sendMessage(instanceName, `**\`${content.trim()}\`** ${verb}`);
}
});
controller.config.on('fieldChanged', (field) => {
if (field === 'ClusterChatSync.discord_bot_token') {
connect().catch(err => {
logger.error(`[Chat Sync] Discord bot token:\n${(err as Error).stack}`);
});
}
});
controller.hooks.shutdown.attach(plugin.name, async () => {
await clientDestroy();
});
await connect();
}
-59
View File
@@ -1,59 +0,0 @@
'use strict';
const lib = require('@clusterio/lib');
class InstanceActionEvent {
static type = 'event';
static src = 'instance';
static dst = 'controller';
static plugin = 'ClusterChatSync';
constructor(instanceName, action, content) {
this.instanceName = instanceName;
this.action = action;
this.content = content;
}
static jsonSchema = {
type: 'object',
required: ['instanceName', 'action', 'content'],
properties: {'instanceName': {type: 'string'}, 'action': {type: 'string'}, 'content': {type: 'string'}}
}
static fromJSON(json) {
return new this(json.instanceName, json.action, json.content);
}
static Response = lib.JsonString;
}
const plugin = {
name: 'ClusterChatSync',
title: 'Cluster Chat Sync',
description: 'One way chat sync.',
instanceEntrypoint: './instance.js',
controllerEntrypoint: './controller.js',
controllerConfigFields: {
'ClusterChatSync.discord_bot_token': {
title: 'Discord Bot Token',
description: 'API Token',
type: 'string'
},
'ClusterChatSync.datetime_on_message': {
title: 'Message Datetime',
description: 'Append datetime in front',
type: 'boolean',
initialValue: true
},
'ClusterChatSync.discord_channel_mapping': {
title: 'Channels',
description: 'Putting the discord channel id and instance relations here',
type: 'object',
initialValue: {
'S1': '123'
},
},
},
messages: [InstanceActionEvent],
};
module.exports = {plugin, InstanceActionEvent};
+61
View File
@@ -0,0 +1,61 @@
import type * as lib from '@clusterio/lib';
import {Type, Static} from '@sinclair/typebox';
export class ChatEvent {
declare ['constructor']: typeof ChatEvent;
static type = 'event' as const;
static src = 'instance' as const;
static dst = 'controller' as const;
static plugin = 'ClusterChatSync' as const;
static permission = null;
constructor(
public instanceName: string,
public action: string,
public content: string,
) {
}
static jsonSchema = Type.Object({
'instanceName': Type.String(),
'action': Type.String(),
'content': Type.String(),
});
static fromJSON(json: Static<typeof ChatEvent.jsonSchema>) {
return new this(json.instanceName, json.action, json.content);
}
}
export const plugin: lib.PluginDeclaration = {
name: 'ClusterChatSync',
title: 'Cluster Chat Sync',
description: 'One way chat forward to Discord.',
webEntrypoint: './web',
instanceEntrypoint: 'dist/node/instance.js',
controllerEntrypoint: 'dist/node/controller.js',
controllerConfigFields: {
'ClusterChatSync.discord_bot_token': {
title: 'Discord Bot Token',
description: 'API Token',
type: 'string'
},
'ClusterChatSync.datetime_on_message': {
title: 'Message Datetime',
description: 'Append datetime in front',
type: 'boolean',
initialValue: true
},
'ClusterChatSync.discord_channel_mapping': {
title: 'Channels',
description: 'Putting the discord channel id and instance relations here',
type: 'object',
initialValue: {
'S1': '123'
},
},
},
messages: [
ChatEvent,
],
};
-34
View File
@@ -1,34 +0,0 @@
'use strict';
const lib = require('@clusterio/lib');
const {BaseInstancePlugin} = require('@clusterio/host');
const {InstanceActionEvent} = require('./index.js');
class InstancePlugin extends BaseInstancePlugin {
async init() {
this.messageQueue = [];
}
onControllerConnectionEvent(event) {
if (event === 'connect') {
for (const [action, content] of this.messageQueue) {
try {
this.instance.sendTo('controller', new InstanceActionEvent(this.instance.name, action, content));
} catch (err) {
this.messageQueue.push([output.action, output.message]);
}
}
this.messageQueue = [];
}
}
async onOutput(output) {
if (output.type !== 'action') return;
if (this.host.connector.connected) {
this.instance.sendTo('controller', new InstanceActionEvent(this.instance.name, output.action, output.message));
} else {
this.messageQueue.push([output.action, output.message]);
}
}
}
module.exports = {InstancePlugin};
+47
View File
@@ -0,0 +1,47 @@
import type {InstancePluginContext} from '@clusterio/host';
import {ChatEvent} from './index.js';
export default async function(context: InstancePluginContext) {
const {instance, host, plugin} = context;
let queue: [string, string][] = [];
instance.hooks.controllerConnectionEvent.attach(plugin.name, async (event) => {
if (event !== 'connect' || queue.length === 0) {
return;
}
const pending = queue;
queue = [];
const failed: [string, string][] = [];
for (const [action, content] of pending) {
try {
await instance.sendTo('controller', new ChatEvent(instance.name, action, content));
} catch (err) {
failed.push([action, content]);
}
}
queue = failed;
});
instance.hooks.output.attach(plugin.name, async (output) => {
if (output.type !== 'action') {
return;
}
if (host.connector.connected) {
try {
await instance.sendTo('controller', new ChatEvent(instance.name, output.action, output.message));
} catch (err) {
queue.push([output.action, output.message]);
}
} else {
queue.push([output.action, output.message]);
}
});
instance.hooks.controllerConnectionEvent.attach(plugin.name, async (event) => {
});
}
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@phidias0303/clusterio_chat_sync",
"version": "1.0.2",
"description": "One way chat sync. Created with the help of Cooldude2606",
"version": "1.0.3",
"description": "One way chat forward to Discord",
"author": "PHIDIAS <phidias@aperx.org>",
"license": "MIT",
"homepage": "https://github.com/PHIDIAS0303/ClusterChatSync#readme",
@@ -12,7 +12,7 @@
"bugs": {
"url": "https://github.com/PHIDIAS0303/ClusterChatSync/issues"
},
"type": "commonjs",
"type": "module",
"keywords": [
"aperx",
"clusterio",
@@ -20,10 +20,10 @@
"discord",
"clusterio-plugin"
],
"main": "index.js",
"main": "dist/node/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"prepare": "webpack-cli --env production"
"prepare": "tsc --build && webpack-cli --mode production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"peerDependencies": {
"@clusterio/lib": "workspace:^"
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.browser.json",
"references": [
{"path": "../../packages/lib/tsconfig.browser.json"},
{"path": "../../packages/web_ui/tsconfig.browser.json"}
],
"include": ["web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{"path": "./tsconfig.node.json"},
{"path": "./tsconfig.browser.json"}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{"path": "../../packages/lib/tsconfig.node.json"},
{"path": "../../packages/host/tsconfig.node.json"},
{"path": "../../packages/ctl/tsconfig.node.json"}
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"]
}
View File
File renamed without changes.
+5 -6
View File
@@ -5,20 +5,19 @@ const { merge } = require('webpack-merge');
const common = require('@clusterio/web_ui/webpack.common');
module.exports = (env = {}) => merge(common(env), {
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: './web/index.jsx',
entry: './web/index.tsx',
output: {
path: path.resolve(__dirname, 'dist', 'web'),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: 'ClusterChatSync',
library: {type: 'window', name: 'plugin_ClusterChatSync' },
library: {type: 'var', name: 'plugin_ClusterChatSync'},
exposes: {
'./': './index.js',
'./package.json': './package.json',
'./web': './web/index.jsx',
'./': './index.ts',
'./package.json': './package.json'
},
shared: {
'@clusterio/lib': {import: false},