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},