From 8446a467a3051c3af27ce3733cba8a0050cb4580 Mon Sep 17 00:00:00 2001 From: PHIDIAS Date: Fri, 15 Aug 2025 19:14:56 +0900 Subject: [PATCH] . --- controller.js | 193 ----------------------------- controller.ts | 280 ++++++++++++++++++++++++++++++++++++++++++ instance.js | 34 ----- instance.ts | 47 +++++++ message.js | 33 ----- message.ts | 25 ++++ tsconfig.browser.json | 4 + tsconfig.json | 6 + tsconfig.node.json | 5 + 9 files changed, 367 insertions(+), 260 deletions(-) delete mode 100644 controller.js create mode 100644 controller.ts delete mode 100644 instance.js create mode 100644 instance.ts delete mode 100644 message.js create mode 100644 message.ts create mode 100644 tsconfig.browser.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json 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