This commit is contained in:
2025-08-15 19:14:56 +09:00
parent f5f3de125a
commit 8446a467a3
9 changed files with 367 additions and 260 deletions

View File

@@ -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, '<blueprint>').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};

280
controller.ts Normal file
View File

@@ -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<any> {
if (!response.ok) {
this.logger.error(`[Chat Sync] API Request got HTTP ${response.status}`);
}
return response.json();
}
async init(): Promise<void> {
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<string | undefined> {
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<LanguageDetection | undefined> {
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<TranslationResult> {
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<void> {
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<void> {
if (this.client) {
this.client.destroy();
this.client = null;
}
}
private async connect(): Promise<void> {
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<void> {
await this.clientDestroy();
}
private async sendMessage(request: { instanceName: string }, message: string): Promise<void> {
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<void> {
if (request.action !== 'CHAT' && request.action !== 'SHOUT') return;
const sanitizedContent = request.content
.replace(/\[special-item=.*?\]/g, '<blueprint>')
.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]`)
);
}
}
}
}

View File

@@ -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};

47
instance.ts Normal file
View File

@@ -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<void> {
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<void> {
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]);
}
}
}

View File

@@ -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<typeof ChatEvent.jsonSchema>
static fromJSON(json) {
return new this(json.instanceName, json.content);
}
}

25
message.ts Normal file
View File

@@ -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<typeof ChatEvent.jsonSchema>) {
return new this(json.instanceName, json.content);
}
}

4
tsconfig.browser.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.browser.json",
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
}

6
tsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" }
]
}

5
tsconfig.node.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.node.json",
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}