From b56904e8ae9488a65999b19ed73d7610459fabca Mon Sep 17 00:00:00 2001 From: PHIDIAS Date: Fri, 9 Oct 2026 18:22:56 +0900 Subject: [PATCH] . --- CHANGELOG.md | 4 + controller.js | 117 ------------------- controller.ts | 148 ++++++++++++++++++++++++ index.js | 59 ---------- index.ts | 61 ++++++++++ instance.js | 34 ------ instance.ts | 47 ++++++++ package.json | 12 +- tsconfig.browser.json | 8 ++ tsconfig.json | 7 ++ tsconfig.node.json | 10 ++ web/{index.jsx => index.tsx} | 0 webpack.config.js => webpack.config.cjs | 11 +- 13 files changed, 296 insertions(+), 222 deletions(-) delete mode 100644 controller.js create mode 100644 controller.ts delete mode 100644 index.js create mode 100644 index.ts delete mode 100644 instance.js create mode 100644 instance.ts create mode 100644 tsconfig.browser.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json rename web/{index.jsx => index.tsx} (100%) rename webpack.config.js => webpack.config.cjs (67%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c30e44c..52b159d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v1.0.3 + +- Changed to TS + ## v1.0.0 - Initial diff --git a/controller.js b/controller.js deleted file mode 100644 index 86a377b..0000000 --- a/controller.js +++ /dev/null @@ -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, '').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}; diff --git a/controller.ts b/controller.ts new file mode 100644 index 0000000..65358e7 --- /dev/null +++ b/controller.ts @@ -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 = { + 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, '') + .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; + 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(); +} diff --git a/index.js b/index.js deleted file mode 100644 index c861d00..0000000 --- a/index.js +++ /dev/null @@ -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}; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..8ec8d3c --- /dev/null +++ b/index.ts @@ -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) { + 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, + ], +}; diff --git a/instance.js b/instance.js deleted file mode 100644 index 9334cde..0000000 --- a/instance.js +++ /dev/null @@ -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}; diff --git a/instance.ts b/instance.ts new file mode 100644 index 0000000..eb03311 --- /dev/null +++ b/instance.ts @@ -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) => { + + }); +} diff --git a/package.json b/package.json index 23fc988..7e0689a 100644 --- a/package.json +++ b/package.json @@ -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 ", "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:^" diff --git a/tsconfig.browser.json b/tsconfig.browser.json new file mode 100644 index 0000000..fe62bfd --- /dev/null +++ b/tsconfig.browser.json @@ -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"] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..46fcf91 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + {"path": "./tsconfig.node.json"}, + {"path": "./tsconfig.browser.json"} + ] +} \ No newline at end of file diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..76d1ea2 --- /dev/null +++ b/tsconfig.node.json @@ -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/*"] +} \ No newline at end of file diff --git a/web/index.jsx b/web/index.tsx similarity index 100% rename from web/index.jsx rename to web/index.tsx diff --git a/webpack.config.js b/webpack.config.cjs similarity index 67% rename from webpack.config.js rename to webpack.config.cjs index 6098287..70ab9b6 100644 --- a/webpack.config.js +++ b/webpack.config.cjs @@ -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},