diff --git a/controller.js b/controller.js deleted file mode 100644 index 9d94d06..0000000 --- a/controller.js +++ /dev/null @@ -1,193 +0,0 @@ -'use strict'; -const Discord = require('discord.js'); -const fetch = require('node-fetch'); -const {BaseControllerPlugin} = require('@clusterio/controller'); -const {InstanceActionEvent} = require('./info.js'); -const {ChatEvent} = require("./message.js"); - -const MAX_DISCORD_MESSAGE_LENGTH = 1950; -const MIN_CONFIDENCE_SCORE = 10.0; - -class LibreTranslateAPI { - constructor(url, apiKey, logger = console) { - if (!url || !apiKey) this.logger.error('[Chat Sync] LibreTranslate API configuration is incomplete.'); - try {new URL(url);} catch {this.logger.error('[Chat Sync] LibreTranslate url is invalid');} - this.url = url.endsWith('/') ? url : url + '/'; - this.apiKey = apiKey; - this.logger = logger; - } - - async handleResponse(response) { - if (!response.ok) this.logger.error(`[Chat Sync] API Request got HTTP ${response.status}`); - return response.json(); - } - - async init() { - try { - this.allowedLanguages = (await this.handleResponse(await fetch(`${this.url}languages?api_key=${this.apiKey}`, {method: 'GET'})))?.[0]?.targets || []; - } catch (err) { - this.logger.error(`[Chat Sync] failed to initialize languages:\n${err.stack}`); - } - } - - async translateRequest(q, source, target) { - try { - return (await this.handleResponse(await fetch(`${this.url}translate`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({q: q, api_key: this.apiKey, source: source, target: target})})))?.translatedText; - } catch (err) { - this.logger.error(`[Chat Sync] Translation failed:\n${err.stack}`); - } - } - - async detectLanguage(q) { - try { - return (await this.handleResponse(await fetch(`${this.url}detect`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({q: q, api_key: this.apiKey})})))?.[0]; - } catch (err) { - this.logger.error(`[Chat Sync] Detection failed:\n${err.stack}`); - } - } - - async translate(query, targetLanguages) { - console.log(query); - const result = {action: false, passage: []}; - - try { - const detection = await this.detectLanguage(query); - - if (!detection || typeof detection !== 'object' || !detection.confidence || !detection.language) { - this.logger.warn('[Chat Sync] Invalid language detection result:', detection); - return result; - } - - if (detection.confidence > MIN_CONFIDENCE_SCORE) { - for (const targetLang of targetLanguages) { - if (!((detection.language === 'zh-Hans' || detection.language === 'zh-Hant') && (targetLang === 'zh-Hans' || targetLang === 'zh-Hant')) && detection.language !== targetLang && this.allowedLanguages.includes(detection.language) && this.allowedLanguages.includes(targetLang)) { - result.action = true; - const translated = await this.translateRequest(query, detection.language, targetLang); - result.passage.push(`[${detection.language} -> ${targetLang}] ${translated}`); - } - } - } - - return result; - } catch (err) { - this.logger.error(`[Chat Sync] translation failed:\n${err.stack}`); - } - } -} - -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.'); - - if (this.controller.config.get('ClusterChatSync.use_libretranslate')) { - this.translator = new LibreTranslateAPI(this.controller.config.get('ClusterChatSync.libretranslate_url'), this.controller.config.get('ClusterChatSync.libretranslate_key'), this.logger); - await this.translator.init(); - this.translator_language = this.controller.config.get('ClusterChatSync.libretranslate_language').trim().split(/\s+/) || ['zh-Hant', 'en']; - } - } - - 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}`); - - if (this.controller.config.get('ClusterChatSync.use_libretranslate')) { - const result = await this.translator.translate(nrc_message, this.translator_language); - - if (result && result.action) { - await this.sendMessage(request, `**\`${nrc_username}\`**: ${result.passage}`); - this.instance.sendTo({ instanceId: this.instance.id }, new ChatEvent(this.instance.name, `[color=255,255,255]\`${nrc_username}\`: ${result}[/color]`)); - } - } - } - } -} - -module.exports = {ControllerPlugin}; diff --git a/controller.ts b/controller.ts new file mode 100644 index 0000000..2a2fdde --- /dev/null +++ b/controller.ts @@ -0,0 +1,280 @@ +import { Client, GatewayIntentBits } from 'discord.js'; +import fetch from 'node-fetch'; +import { BaseControllerPlugin } from '@clusterio/controller'; +import { InstanceActionEvent } from './info'; +import { ChatEvent } from './message'; + +const MAX_DISCORD_MESSAGE_LENGTH = 1950; +const MIN_CONFIDENCE_SCORE = 10.0; + +interface TranslationResult { + action: boolean; + passage: string[]; +} + +interface LanguageDetection { + confidence: number; + language: string; + [key: string]: unknown; +} + +class LibreTranslateAPI { + private url: string; + private apiKey: string; + private logger: Console; + private allowedLanguages: string[] = []; + + constructor(url: string, apiKey: string, logger: Console = console) { + if (!url || !apiKey) { + logger.error('[Chat Sync] LibreTranslate API configuration is incomplete.'); + } + try { + new URL(url); + } catch { + logger.error('[Chat Sync] LibreTranslate url is invalid'); + } + this.url = url.endsWith('/') ? url : url + '/'; + this.apiKey = apiKey; + this.logger = logger; + } + + private async handleResponse(response: fetch.Response): Promise { + if (!response.ok) { + this.logger.error(`[Chat Sync] API Request got HTTP ${response.status}`); + } + return response.json(); + } + + async init(): Promise { + try { + const languages = await this.handleResponse( + await fetch(`${this.url}languages?api_key=${this.apiKey}`, { method: 'GET' }) + ); + this.allowedLanguages = languages?.[0]?.targets || []; + } catch (err) { + this.logger.error(`[Chat Sync] failed to initialize languages:\n${err.stack}`); + } + } + + async translateRequest(q: string, source: string, target: string): Promise { + try { + const response = await this.handleResponse( + await fetch(`${this.url}translate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ q, api_key: this.apiKey, source, target }) + }) + ); + return response?.translatedText; + } catch (err) { + this.logger.error(`[Chat Sync] Translation failed:\n${err.stack}`); + } + } + + async detectLanguage(q: string): Promise { + try { + const response = await this.handleResponse( + await fetch(`${this.url}detect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ q, api_key: this.apiKey }) + }) + ); + return response?.[0]; + } catch (err) { + this.logger.error(`[Chat Sync] Detection failed:\n${err.stack}`); + } + } + + async translate(query: string, targetLanguages: string[]): Promise { + const result: TranslationResult = { action: false, passage: [] }; + try { + const detection = await this.detectLanguage(query); + if (!detection || typeof detection !== 'object' || !detection.confidence || !detection.language) { + this.logger.warn('[Chat Sync] Invalid language detection result:', detection); + return result; + } + + if (detection.confidence > MIN_CONFIDENCE_SCORE) { + for (const targetLang of targetLanguages) { + if ( + !((detection.language === 'zh-Hans' || detection.language === 'zh-Hant') && + (targetLang === 'zh-Hans' || targetLang === 'zh-Hant')) && + detection.language !== targetLang && + this.allowedLanguages.includes(detection.language) && + this.allowedLanguages.includes(targetLang) + ) { + result.action = true; + const translated = await this.translateRequest(query, detection.language, targetLang); + if (translated) { + result.passage.push(`[${detection.language} -> ${targetLang}] ${translated}`); + } + } + } + } + } catch (err) { + this.logger.error(`[Chat Sync] translation failed:\n${err.stack}`); + } + return result; + } +} + +interface ControllerConfig { + get(key: string): any; + on(event: string, callback: (field: string, curr: any, prev: any) => void); +} + +interface InstanceInfo { + name: string; + id: number; + sendTo(target: string | { instanceId: number }, message: any): void; +} + +export class ControllerPlugin extends BaseControllerPlugin { + private client: Client | null = null; + private translator?: LibreTranslateAPI; + private translator_language: string[] = []; + + constructor( + private controller: { + config: ControllerConfig; + handle(event: any, handler: Function): void; + logger: Console; + }, + private instance: InstanceInfo + ) { + super(); + } + + async init(): Promise { + this.controller.config.on('fieldChanged', (field: string, curr: any, prev: any) => { + if (field === 'ClusterChatSync.discord_bot_token') { + this.connect().catch(err => { + this.controller.logger.error(`[Chat Sync] Discord bot token:\n${err.stack}`); + }); + } + }); + this.controller.handle(InstanceActionEvent, this.handleInstanceAction.bind(this)); + await this.connect(); + } + + private async clientDestroy(): Promise { + if (this.client) { + this.client.destroy(); + this.client = null; + } + } + + private async connect(): Promise { + await this.clientDestroy(); + const token = this.controller.config.get('ClusterChatSync.discord_bot_token'); + if (!token) { + this.controller.logger.error('[Chat Sync] Discord bot token not configured.'); + return; + } + + this.client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent + ] + }); + + this.controller.logger.info('[Chat Sync] Logging into Discord.'); + try { + await this.client.login(token); + } catch (err) { + this.controller.logger.error(`[Chat Sync] Discord login error:\n${err.stack}`); + await this.clientDestroy(); + return; + } + + this.controller.logger.info('[Chat Sync] Logged in Discord successfully.'); + + if (this.controller.config.get('ClusterChatSync.use_libretranslate')) { + this.translator = new LibreTranslateAPI( + this.controller.config.get('ClusterChatSync.libretranslate_url'), + this.controller.config.get('ClusterChatSync.libretranslate_key'), + this.controller.logger + ); + await this.translator.init(); + this.translator_language = this.controller.config.get('ClusterChatSync.libretranslate_language') + .trim() + .split(/\s+/) || ['zh-Hant', 'en']; + } + } + + async onShutdown(): Promise { + await this.clientDestroy(); + } + + private async sendMessage(request: { instanceName: string }, message: string): Promise { + if (!this.client) return; + + const channelMapping = this.controller.config.get('ClusterChatSync.discord_channel_mapping'); + const channel_id = channelMapping[request.instanceName]; + if (!channel_id) return; + + try { + const channel = await this.client.channels.fetch(channel_id); + if (!channel || !channel.isTextBased()) { + this.controller.logger.error(`[Chat Sync] Discord Channel ID ${channel_id} not found or not text channel.`); + return; + } + + if (this.controller.config.get('ClusterChatSync.datetime_on_message')) { + const now = new Date(); + const timestamp = `${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')}`; + message = `${timestamp} ${message}`; + } + + while (message.length > 0) { + let chunk = message.slice(0, MAX_DISCORD_MESSAGE_LENGTH); + const lastSpace = chunk.lastIndexOf(' '); + + if (lastSpace !== -1 && chunk.length === MAX_DISCORD_MESSAGE_LENGTH) { + chunk = chunk.slice(0, lastSpace); + message = message.slice(lastSpace).trim(); + } else { + message = message.slice(chunk.length).trim(); + } + + await channel.send(chunk, { allowedMentions: { parse: [] } }); + } + } catch (err: any) { + if (err.code !== 10003) { // Unknown Channel error + this.controller.logger.error(`[Chat Sync] Discord channel error:\n${err.stack}`); + } + } + } + + private async handleInstanceAction(request: { + action: string; + content: string; + instanceName: string + }): Promise { + if (request.action !== 'CHAT' && request.action !== 'SHOUT') return; + + const sanitizedContent = request.content + .replace(/\[special-item=.*?\]/g, '') + .replace(/<@/g, '<@\u200c>'); + const colonIndex = sanitizedContent.indexOf(':'); + const username = sanitizedContent.substring(0, colonIndex); + const message = sanitizedContent.substring(colonIndex + 1).trim(); + + await this.sendMessage(request, `**\`${username}\`**: ${message}`); + + if (this.translator && this.controller.config.get('ClusterChatSync.use_libretranslate')) { + const result = await this.translator.translate(message, this.translator_language); + if (result?.action) { + await this.sendMessage(request, `**\`${username}\`**: ${result.passage.join('\n')}`); + this.instance.sendTo( + { instanceId: this.instance.id }, + new ChatEvent(this.instance.name, `[color=255,255,255]\`${username}\`: ${result.passage.join('\n')}[/color]`) + ); + } + } + } +} diff --git a/instance.js b/instance.js deleted file mode 100644 index 5e2e83d..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("./info.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..934c5de --- /dev/null +++ b/instance.ts @@ -0,0 +1,47 @@ +import * as lib from "@clusterio/lib"; +import { BaseInstancePlugin } from "@clusterio/host"; +import { InstanceActionEvent } from "./info.ts"; + +type MessageQueueItem = [string, unknown]; // [action, content] +type ControllerEvent = 'connect' | 'disconnect' | string; +type OutputMessage = { + type: string; + action: string; + message: unknown; +}; + +export class InstancePlugin extends BaseInstancePlugin { + private messageQueue: MessageQueueItem[] = []; + + async init(): Promise { + this.messageQueue = []; + } + + onControllerConnectionEvent(event: ControllerEvent): void { + 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([action, content]); + + // Optional: Log the error + console.error('Failed to send queued message:', err); + } + } + this.messageQueue = []; + } + } + + async onOutput(output: OutputMessage): Promise { + 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]); + } + } +} diff --git a/message.js b/message.js deleted file mode 100644 index 4408e2e..0000000 --- a/message.js +++ /dev/null @@ -1,33 +0,0 @@ -// import { Type, Static } from "@sinclair/typebox"; -const {Type, Static} = require("@sinclair/typebox"); - -export class ChatEvent { - // declare ["constructor"]: typeof ChatEvent; - // as const - static type = "event"; - // as const - static src = ["control", "instance"]; - // as const - static dst = "instance"; - // as const - static plugin = "global_chat"; - static permission = null; - - /* - constructor( - public instanceName: string, - public content: string, - ) { - } - */ - - static jsonSchema = Type.Object({ - "instanceName": Type.String(), - "content": Type.String(), - }); - - // json: Static - static fromJSON(json) { - return new this(json.instanceName, json.content); - } -} \ No newline at end of file diff --git a/message.ts b/message.ts new file mode 100644 index 0000000..a6c8b5b --- /dev/null +++ b/message.ts @@ -0,0 +1,25 @@ +import { Type, Static } from "@sinclair/typebox"; + +export class ChatEvent { + declare ["constructor"]: typeof ChatEvent; + static type = "event" as const; + static src = ["control", "instance"] as const; + static dst = "instance" as const; + static plugin = "global_chat" as const; + static permission = null; + + constructor( + public instanceName: string, + public content: string, + ) { + } + + static jsonSchema = Type.Object({ + "instanceName": Type.String(), + "content": Type.String(), + }); + + static fromJSON(json: Static) { + return new this(json.instanceName, json.content); + } +} \ No newline at end of file diff --git a/tsconfig.browser.json b/tsconfig.browser.json new file mode 100644 index 0000000..cc77ead --- /dev/null +++ b/tsconfig.browser.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.browser.json", + "include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ], +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fe5912a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" } + ] +} \ No newline at end of file diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..7087bbf --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} \ No newline at end of file