This commit is contained in:
2025-06-15 01:12:41 +09:00
parent 1f2d63a131
commit d85e7177de
4 changed files with 105 additions and 124 deletions

View File

@@ -1,171 +1,156 @@
"use strict"; "use strict";
const Discord = require("discord.js"); const Discord = require("discord.js");
const fetch = require("node-fetch");
const {BaseControllerPlugin} = require("@clusterio/controller"); const {BaseControllerPlugin} = require("@clusterio/controller");
const {InstanceActionEvent} = require("./info.js"); const {InstanceActionEvent} = require("./info.js");
const MAX_DISCORD_MESSAGE_LENGTH = 1950;
const MIN_CONFIDENCE_SCORE = 10.0;
class LibreTranslateAPI { class LibreTranslateAPI {
constructor(url, apiKey) { constructor(url, apiKey, logger = console) {
if (!url) throw new Error('url is required for LibreTranslate API'); if (!url || !apiKey) this.logger.error('[Chat Sync] LibreTranslate API configuration is incomplete.');
if (!apiKey) throw new Error('API key is required for LibreTranslate API'); try {new URL(url);} catch {this.logger.error('[Chat Sync] LibreTranslate url is invalid');}
this.url = url.endsWith('/') ? url : url + '/'; this.url = url.endsWith('/') ? url : url + '/';
this.apiKey = apiKey; this.apiKey = apiKey;
this.allowedLanguages = []; 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() { async init() {
try { try {
const response = await fetch(`${this.url}languages?api_key=${this.apiKey}`, {method: 'GET'}); this.allowedLanguages = (await this.handleResponse(await fetch(`${this.url}languages?api_key=${this.apiKey}`, {method: 'GET'})))[0].targets || [];
const data = await response.json();
this.allowedLanguages = data[0].targets;
} catch (error) { } catch (error) {
console.error('Failed to initialize languages:', error); this.logger.error('[Chat Sync] failed to initialize languages:', error);
throw error;
} }
} }
async translateRequest(q, source, target) { async translateRequest(q, source, target) {
const params = new URLSearchParams(); try {
params.append('q', q); 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;
params.append('api_key', this.apiKey); } catch (error) {
params.append('source', source); this.logger.error('[Chat Sync] Translation failed:', error);
params.append('target', target); }
const response = await fetch(`${this.url}translate`, {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params});
const data = await response.json();
return data.translatedText;
} }
async detectLanguage(q) { async detectLanguage(q) {
const params = new URLSearchParams(); try {
params.append('q', q); 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];
params.append('api_key', this.apiKey); } catch (error) {
this.logger.error('[Chat Sync] Detection failed:', error);
const response = await fetch(`${this.url}detect`, {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params}); }
const data = await response.json();
return data[0];
} }
async translate(query, targetLanguages = ['zh-Hant', 'en']) { async translate(query, targetLanguages = ['zh-Hant', 'en']) {
console.log(query); console.log(query);
const result = {action: false, passage: []}; const result = {action: false, passage: []};
try { try {
const detection = await this.detectLanguage(query); const detection = await this.detectLanguage(query);
if (detection.confidence > 10.0) { 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) { 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)) { 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; result.action = true;
const translated = await this.translateRequest(query, detection.language, targetLang); const translated = await this.translateRequest(query, detection.language, targetLang);
result.passage.push(translated); result.passage.push(`[${detection.language} -> ${targetLang}] ${translated}`);
} }
} }
} }
return result; return result;
} catch (error) { } catch (error) {
console.error('Translation failed:', error); this.logger.error('[Chat Sync] translation failed:', error);
throw error;
} }
} }
} }
class ControllerPlugin extends BaseControllerPlugin { class ControllerPlugin extends BaseControllerPlugin {
async init() { async init() {
this.controller.config.on('fieldChanged', (field, curr, prev) => { 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}`);});});
if (field === 'chat_sync.discord_bot_token') this.connect().catch(err => {this.logger.error(`Unexpected error:\n${err.stack}`);});
});
this.controller.handle(InstanceActionEvent, this.handleInstanceAction.bind(this)); this.controller.handle(InstanceActionEvent, this.handleInstanceAction.bind(this));
this.client = null; this.client = null;
if (this.controller.config.get('chat_sync.use_libretranslate')) {
this.translator = new LibreTranslateAPI(this.controller.config.get('chat_sync.libretranslate_url'), this.controller.config.get('chat_sync.libretranslate_key'));
await this.translator.init();
this.translator_language = this.controller.config.get('chat_sync.libretranslate_language').trim().split(/\s+/);
}
await this.connect(); await this.connect();
} }
async connect() { async clientDestroy() {
if (this.client) { if (this.client) {
this.client.destroy(); this.client.destroy();
this.client = null; this.client = null;
} }
}
let token = this.controller.config.get('chat_sync.discord_bot_token'); async connect() {
await this.clientDestroy()
if (!token) { if (!this.controller.config.get('ClusterChatSync.discord_bot_token')) {
this.logger.warn('chat sync bot token not configured, so chat is offline'); this.logger.error('[Chat Sync] Discord bot token not configured');
return; return;
} }
this.client = new Discord.Client({ this.client = new Discord.Client({intents: [Discord.GatewayIntentBits.Guilds, Discord.GatewayIntentBits.GuildMessages, Discord.GatewayIntentBits.MessageContent]});
intents: [ this.logger.info('[Chat Sync] Logging into Discord');
Discord.GatewayIntentBits.Guilds,
Discord.GatewayIntentBits.GuildMessages,
Discord.GatewayIntentBits.MessageContent,
],
});
this.logger.info('chat sync is logging in to Discord');
try { try {
await this.client.login(this.controller.config.get('chat_sync.discord_bot_token')); await this.client.login(this.controller.config.get('ClusterChatSync.discord_bot_token'));
} catch (err) { } catch (err) {
this.logger.error(`chat sync have error logging in to discord, chat is offline:\n${err.stack}`); this.logger.error(`[Chat Sync] Discord login error:\n${err.stack}`);
this.client.destroy(); await this.clientDestroy()
this.client = null;
return; return;
} }
this.logger.info('chat sync have successfully logged in'); this.logger.info('[Chat Sync] have successfully logged in');
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+/);
}
} }
async onShutdown() { async onShutdown() {
if (this.client) { await this.clientDestroy()
this.client.destroy();
this.client = null;
}
} }
async sendMessage(nrc_msg) { async sendMessage(request, nrc_msg) {
const channel_id = this.controller.config.get('chat_sync.discord_channel_mapping')[request.instanceName]; const channel_id = this.controller.config.get('ClusterChatSync.discord_channel_mapping')[request.instanceName];
let channel = null;
if (!channel_id) return; if (!channel_id) return;
try { try {
channel = await this.client.channels.fetch(channel_id); let channel = await this.client.channels.fetch(channel_id);
} catch (err) {
if (err.code !== 10003) throw err;
}
if (channel === null) { if (channel === null) {
this.logger.error(`chat sync discord hannel ID ${channel_id} was not found`); this.logger.error(`[Chat Sync] Discord Channel ID ${channel_id} not found.`);
return; return;
} }
} catch (err) {if (err.code !== 10003) this.logger.error(`[Chat Sync] Discord channel fetch error:\n${err.stack}`);}
if (this.controller.config.get("chat_sync.datetime_on_message")) { if (this.controller.config.get("ClusterChatSync.datetime_on_message")) {
let now = new Date(); let now = new Date();
let dt = `${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 = `${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}`
nrc_msg = `${dt} ${nrc_msg}`
} }
if (nrc_msg.length <= 1950) { if (nrc_msg.length <= MAX_DISCORD_MESSAGE_LENGTH) {
await channel.send(nrc_msg, {allowedMentions: {parse: []}}); await channel.send(nrc_msg, {allowedMentions: {parse: []}});
} else { } else {
while (nrc_msg.length > 0) { while (nrc_msg.length > 0) {
let nrc_cmsg = nrc_msg.slice(0, 1950); let nrc_cmsg = nrc_msg.slice(0, MAX_DISCORD_MESSAGE_LENGTH);
let nrc_lindex = nrc_cmsg.lastIndexOf(' '); let nrc_lindex = nrc_cmsg.lastIndexOf(' ');
if (nrc_lindex !== -1) { if (nrc_lindex !== -1) {
nrc_cmsg = nrc_cmsg.slice(0, nrc_lindex); nrc_cmsg = nrc_cmsg.slice(0, nrc_lindex);
nrc_msg = nrc_msg.slice(nrc_lindex).trim(); nrc_msg = nrc_msg.slice(nrc_lindex).trim();
} else { } else {
nrc_msg = nrc_msg.slice(1950).trim(); nrc_msg = nrc_msg.slice(MAX_DISCORD_MESSAGE_LENGTH).trim();
} }
await channel.send(nrc_cmsg, {allowedMentions: {parse: []}}); await channel.send(nrc_cmsg, {allowedMentions: {parse: []}});
@@ -179,15 +164,16 @@ class ControllerPlugin extends BaseControllerPlugin {
const nrc_index = nrc.indexOf(":"); const nrc_index = nrc.indexOf(":");
const nrc_username = nrc.substring(0, nrc_index); const nrc_username = nrc.substring(0, nrc_index);
const nrc_message = nrc.substring(nrc_index + 1).trim(); const nrc_message = nrc.substring(nrc_index + 1).trim();
await this.sendMessage(request, `**\`${nrc_username}\`**: ${nrc_message}`)
if (this.controller.config.get('chat_sync.use_libretranslate')) { if (this.controller.config.get('ClusterChatSync.use_libretranslate')) {
const result = await this.translator.translate(nrc_message, this.translator_language); const result = await this.translator.translate(nrc_message, this.translator_language);
this.sendChat(`[color=255,255,255]\`${nrc_username}\`: ${result}[/color]`);
// await sendMessage(`**\`${nrc_username}\`**: ${result}`) if (result && result.action) {
await this.sendMessage(request, `**\`${nrc_username}\`**: ${result.passage}`)
return `[color=255,255,255]\`${nrc_username}\`: ${result}[/color]`;
}
} }
await sendMessage(`**\`${nrc_username}\`**: ${nrc_message}`)
} }
} }
} }

38
info.js
View File

@@ -5,7 +5,7 @@ class InstanceActionEvent {
static type = "event"; static type = "event";
static src = "instance"; static src = "instance";
static dst = "controller"; static dst = "controller";
static plugin = "chat_sync"; static plugin = "ClusterChatSync";
constructor(instanceName, action, content) { constructor(instanceName, action, content) {
this.instanceName = instanceName; this.instanceName = instanceName;
@@ -29,26 +29,24 @@ class InstanceActionEvent {
} }
const plugin = { const plugin = {
name: "chat_sync", name: "ClusterChatSync",
title: "Chat Sync", title: "Cluster Chat Sync",
description: "One way chat sync.", description: "One way chat sync.",
instanceEntrypoint: "instance", instanceEntrypoint: "instance",
controllerEntrypoint: "controller", controllerEntrypoint: "controller",
controllerConfigFields: { controllerConfigFields: {
"chat_sync.discord_bot_token": { "ClusterChatSync.discord_bot_token": {
title: "Discord Bot Token", title: "Discord Bot Token",
description: "API Token", description: "API Token",
type: "string", type: "string"
optional: true,
}, },
"chat_sync.datetime_on_message": { "ClusterChatSync.datetime_on_message": {
title: "Message Datetime", title: "Message Datetime",
description: "Append datetime in front", description: "Append datetime in front",
type: "boolean", type: "boolean",
initialValue: true, initialValue: true
optional: true,
}, },
"chat_sync.discord_channel_mapping": { "ClusterChatSync.discord_channel_mapping": {
title: "Channels", title: "Channels",
description: "Putting the discord channel id and instance relations here", description: "Putting the discord channel id and instance relations here",
type: "object", type: "object",
@@ -56,33 +54,29 @@ const plugin = {
"S1": "123" "S1": "123"
}, },
}, },
"chat_sync.use_libretranslate": { "ClusterChatSync.use_libretranslate": {
title: "Translate Message", title: "Translate Message",
description: "Using self host or paid service of libretranslate", description: "Using self host or paid service of libretranslate",
type: "boolean", type: "boolean",
initialValue: false, initialValue: false
optional: true,
}, },
"chat_sync.libretranslate_url": { "ClusterChatSync.libretranslate_url": {
title: "Translate Server URL", title: "Translate Server URL",
description: "Including http protocol, and the port if needed", description: "Including http protocol, and the port if needed",
type: "string", type: "string",
initialValue: "http://localhost:5000", initialValue: "http://localhost:5000"
optional: true,
}, },
"chat_sync.libretranslate_key": { "ClusterChatSync.libretranslate_key": {
title: "Translate Server API Key", title: "Translate Server API Key",
description: "The API key for the translate server", description: "The API key for the translate server",
type: "string", type: "string",
initialValue: "123456", initialValue: "123456"
optional: true,
}, },
"chat_sync.libretranslate_language": { "ClusterChatSync.libretranslate_language": {
title: "Translate Server Target Language", title: "Translate Server Target Language",
description: "Put a space between each language, using ISO 639-1 codes", description: "Put a space between each language, using ISO 639-1 codes",
type: "string", type: "string",
initialValue: "zh-Hants en", initialValue: "zh-Hants en"
optional: true,
}, },
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@phidias0303/clusterio_chat_sync", "name": "@phidias0303/clusterio_chat_sync",
"version": "1.0.1", "version": "1.0.2",
"description": "One way chat sync", "description": "One way chat sync",
"main": "info.js", "main": "info.js",
"scripts": { "scripts": {
@@ -30,12 +30,13 @@
"devDependencies": { "devDependencies": {
"@clusterio/lib": "^2.0.0-alpha.21", "@clusterio/lib": "^2.0.0-alpha.21",
"@clusterio/web_ui": "^2.0.0-alpha.21", "@clusterio/web_ui": "^2.0.0-alpha.21",
"webpack": "^5.88.2", "webpack": "^5.99.9",
"webpack-cli": "^5.1.4", "webpack-cli": "^6.0.1",
"webpack-merge": "^5.9.0" "webpack-merge": "^6.0.1"
}, },
"dependencies": { "dependencies": {
"discord.js": "^14.14.1" "discord.js": "^14.19.3",
"node-fetch": "^3.3.2"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"

View File

@@ -13,8 +13,8 @@ module.exports = (env = {}) => merge(common(env), {
}, },
plugins: [ plugins: [
new webpack.container.ModuleFederationPlugin({ new webpack.container.ModuleFederationPlugin({
name: "chat_sync", name: "ClusterChatSync",
library: { type: "var", name: "plugin_chat_sync" }, library: { type: "var", name: "plugin_ClusterChatSync" },
exposes: { exposes: {
"./": "./info.js", "./": "./info.js",
"./package.json": "./package.json", "./package.json": "./package.json",