mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 05:20:45 +08:00
Wrap all user-facing chat text in src/channels with a host-side t() translator (Chinese is the identity default, so untranslated text is always sent verbatim in Chinese). English is selected via the host plugin config `language: en` or the DSH_IM_LANGUAGE environment variable, and the settings UI keeps following the DSH web locale. - add src/channels/shared/i18n.mjs with setImHostLanguage/t() and a Chinese-key -> English dictionary split by area (shared + channels) - complete the English dictionary for every t() key in src/channels, including the feishu channel (welcome/help, repair flow, interactive cards, watch flow) and the shared command/approval/stream text - wrap previously untranslated chat-facing strings in feishu bridge, feishu runtime, editable-message-stream, harness-client, connection-test and image-prompt - dedupe dictionary entries that existed in multiple channel files and canonicalize conflicting translations into the shared dictionaries - extend the i18n unit test to decode string-literal escapes so keys containing \n are checked correctly - document the language switch in README.md, README.en.md and CHANGELOG.md, and rebuild lib/index.js Added English support for bot chat messages across all nine IM channels and AI Office without changing any Chinese output.
558 lines
18 KiB
JavaScript
558 lines
18 KiB
JavaScript
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
import { fetchFileStream } from '../shared/file-download.mjs';
|
|
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
import { t } from '../shared/i18n.mjs';
|
|
import { DiscordApi } from './discord-api.mjs';
|
|
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
|
|
|
|
const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12);
|
|
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
|
|
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
const IMAGE_FILE_TYPES = new Map([
|
|
['.jpg', 'image/jpeg'],
|
|
['.jpeg', 'image/jpeg'],
|
|
['.png', 'image/png'],
|
|
['.webp', 'image/webp'],
|
|
['.gif', 'image/gif'],
|
|
]);
|
|
const DISCORD_IMAGE_HOSTS = Object.freeze(['cdn.discordapp.com']);
|
|
|
|
function socketUrl(value) {
|
|
const url = new URL(value);
|
|
if (url.protocol !== 'wss:') throw new Error('Discord returned an insecure Gateway URL');
|
|
url.searchParams.set('v', '10');
|
|
url.searchParams.set('encoding', 'json');
|
|
return url.href;
|
|
}
|
|
|
|
function addSocketListener(socket, event, listener) {
|
|
if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener);
|
|
else if (typeof socket.on === 'function') socket.on(event, listener);
|
|
else throw new TypeError('Discord WebSocket does not support events');
|
|
}
|
|
|
|
function eventData(event) {
|
|
if (typeof event === 'string') return event;
|
|
if (typeof event?.data === 'string') return event.data;
|
|
if (Buffer.isBuffer(event)) return event.toString('utf8');
|
|
if (Buffer.isBuffer(event?.data)) return event.data.toString('utf8');
|
|
return null;
|
|
}
|
|
|
|
function gatewayCloseError(code) {
|
|
if (code === 4004) {
|
|
const error = new Error(t('Discord Bot Token 无效,请重新填写。'));
|
|
error.code = 'discord-401';
|
|
return error;
|
|
}
|
|
if (code === 4013 || code === 4014) {
|
|
const error = new Error(t('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。'));
|
|
error.code = 'discord-intents';
|
|
return error;
|
|
}
|
|
const error = new Error(`Discord Gateway closed (${code || 'unknown'})`);
|
|
error.code = 'discord-gateway-closed';
|
|
return error;
|
|
}
|
|
|
|
function stripBotMention(text, botId) {
|
|
if (typeof text !== 'string') return '';
|
|
return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
|
|
}
|
|
|
|
function attachmentMediaType(attachment) {
|
|
const value = typeof attachment?.content_type === 'string'
|
|
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
|
|
if (IMAGE_MEDIA_TYPES.has(value)) return value;
|
|
const filename = typeof attachment?.filename === 'string' ? attachment.filename.toLowerCase() : '';
|
|
for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
|
|
if (filename.endsWith(extension)) return mediaType;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function attachmentSize(value) {
|
|
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
}
|
|
|
|
function discordImageSource(attachment, fetchImpl) {
|
|
const mediaType = attachmentMediaType(attachment);
|
|
if (!mediaType || typeof attachment?.url !== 'string') return null;
|
|
return {
|
|
name: typeof attachment.filename === 'string' ? attachment.filename : undefined,
|
|
mediaType,
|
|
size: attachmentSize(attachment.size),
|
|
load: (options) => fetchImageBuffer(attachment.url, {
|
|
...options,
|
|
fetchImpl,
|
|
allowedHosts: DISCORD_IMAGE_HOSTS,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function discordFileSource(attachment, fetchImpl) {
|
|
if (attachmentMediaType(attachment) || typeof attachment?.url !== 'string' || !attachment.url) {
|
|
return null;
|
|
}
|
|
const mediaType = typeof attachment.content_type === 'string' && attachment.content_type
|
|
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : undefined;
|
|
return {
|
|
name: typeof attachment.filename === 'string' && attachment.filename
|
|
? attachment.filename : String(attachment.id ?? 'discord-file'),
|
|
...(mediaType ? { mediaType } : {}),
|
|
size: attachmentSize(attachment.size),
|
|
load: ({ signal } = {}) => fetchFileStream(attachment.url, {
|
|
fetchImpl,
|
|
signal,
|
|
allowedHosts: DISCORD_IMAGE_HOSTS,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
|
|
if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
|
|
const direct = !message.guild_id;
|
|
const addressed = direct
|
|
|| message.mentions?.some((mention) => String(mention?.id) === String(botId));
|
|
return {
|
|
messageId: String(message.id),
|
|
senderId: String(message.author.id),
|
|
senderIsBot: message.author.bot === true,
|
|
kind: direct ? 'direct' : 'group',
|
|
conversationId: String(message.channel_id),
|
|
content: stripBotMention(message.content ?? '', botId),
|
|
images: Array.isArray(message.attachments)
|
|
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
|
|
: [],
|
|
files: Array.isArray(message.attachments)
|
|
? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
|
|
: [],
|
|
addressed,
|
|
replyTarget: {
|
|
channelId: String(message.channel_id),
|
|
replyToMessageId: String(message.id),
|
|
},
|
|
connectionTestTarget: { channelId: String(message.channel_id) },
|
|
};
|
|
}
|
|
|
|
export class DiscordBotClient {
|
|
#api;
|
|
#signal;
|
|
|
|
constructor({ api, signal }) {
|
|
this.#api = api;
|
|
this.#signal = signal;
|
|
}
|
|
|
|
async sendText(target, text) {
|
|
const chunks = splitMessageText(text, 1_900);
|
|
const providerMessageIds = [];
|
|
for (const [index, chunk] of chunks.entries()) {
|
|
const result = await this.#api.createMessage({
|
|
channelId: target.channelId,
|
|
content: chunk,
|
|
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
|
signal: this.#signal,
|
|
});
|
|
if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
|
|
}
|
|
return { providerMessageIds };
|
|
}
|
|
|
|
sendTyping(target) {
|
|
return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
|
|
}
|
|
|
|
sendFile(target, file) {
|
|
return this.#api.createFileMessage({
|
|
channelId: target.channelId,
|
|
file,
|
|
replyToMessageId: target.replyToMessageId,
|
|
signal: this.#signal,
|
|
});
|
|
}
|
|
|
|
async openStream(target) {
|
|
const stream = createEditableMessageStream({
|
|
limit: 1_900,
|
|
create: async (content) => {
|
|
const message = await this.#api.createMessage({
|
|
channelId: target.channelId,
|
|
content,
|
|
replyToMessageId: target.replyToMessageId,
|
|
signal: this.#signal,
|
|
});
|
|
return message.id;
|
|
},
|
|
edit: (messageId, content) => this.#api.editMessage({
|
|
channelId: target.channelId,
|
|
messageId,
|
|
content,
|
|
signal: this.#signal,
|
|
}),
|
|
sendRemainder: (content) => this.#api.createMessage({
|
|
channelId: target.channelId,
|
|
content,
|
|
signal: this.#signal,
|
|
}),
|
|
messageIdForResult: (message) => message?.id,
|
|
});
|
|
return stream.start();
|
|
}
|
|
}
|
|
|
|
export function createDiscordRuntimeStatus() {
|
|
return {
|
|
startedAt: null,
|
|
ready: false,
|
|
connectionState: 'idle',
|
|
harnessReachable: false,
|
|
lastCheckedAt: null,
|
|
lastConnectedAt: null,
|
|
lastError: null,
|
|
...createDiscordBridgeStatus(),
|
|
};
|
|
}
|
|
|
|
export class DiscordRuntime {
|
|
#config;
|
|
#token;
|
|
#harness;
|
|
#state;
|
|
#logger;
|
|
#replyTimeoutMs;
|
|
#connectTimeoutMs;
|
|
#createApi;
|
|
#createWebSocket;
|
|
#random;
|
|
#status = createDiscordRuntimeStatus();
|
|
#api = null;
|
|
#bridge = null;
|
|
#abortController = null;
|
|
#socket = null;
|
|
#gatewayUrl = null;
|
|
#resumeUrl = null;
|
|
#sessionId = null;
|
|
#sequence = null;
|
|
#heartbeatTimer = null;
|
|
#heartbeatAcked = true;
|
|
#reconnectTimer = null;
|
|
#reconnectAttempt = 0;
|
|
#generation = 0;
|
|
#stopped = true;
|
|
#starting = null;
|
|
|
|
constructor({
|
|
config,
|
|
token,
|
|
harness,
|
|
state,
|
|
logger = console,
|
|
replyTimeoutMs = 600_000,
|
|
connectTimeoutMs = 20_000,
|
|
createApi = (options) => new DiscordApi(options),
|
|
createWebSocket = (url) => new WebSocket(url),
|
|
random = Math.random,
|
|
}) {
|
|
if (!config || !token || !harness || !state) {
|
|
throw new TypeError('DiscordRuntime requires config, token, Harness, and state');
|
|
}
|
|
if (typeof createWebSocket !== 'function') throw new TypeError('DiscordRuntime requires WebSocket');
|
|
this.#config = config;
|
|
this.#token = token;
|
|
this.#harness = harness;
|
|
this.#state = state;
|
|
this.#logger = logger;
|
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
this.#connectTimeoutMs = connectTimeoutMs;
|
|
this.#createApi = createApi;
|
|
this.#createWebSocket = createWebSocket;
|
|
this.#random = random;
|
|
}
|
|
|
|
get status() {
|
|
return structuredClone(this.#status);
|
|
}
|
|
|
|
async sendConnectionTest(text) {
|
|
if (!this.#status.ready || !this.#bridge) {
|
|
const error = new Error('Discord bot is not connected');
|
|
error.code = 'test-target-unavailable';
|
|
throw error;
|
|
}
|
|
return this.#bridge.sendConnectionTest(text);
|
|
}
|
|
|
|
async start() {
|
|
if (this.#status.ready && this.#socket) return this.status;
|
|
if (this.#starting) return this.#starting;
|
|
this.#starting = this.#start().finally(() => {
|
|
this.#starting = null;
|
|
});
|
|
return this.#starting;
|
|
}
|
|
|
|
async #start() {
|
|
await this.stop();
|
|
this.#stopped = false;
|
|
this.#sessionId = null;
|
|
this.#resumeUrl = null;
|
|
this.#sequence = null;
|
|
this.#reconnectAttempt = 0;
|
|
this.#status.startedAt = new Date().toISOString();
|
|
this.#status.connectionState = 'connecting';
|
|
this.#status.lastError = null;
|
|
await this.#harness.ensureRunning();
|
|
this.#status.harnessReachable = true;
|
|
const controller = new AbortController();
|
|
this.#abortController = controller;
|
|
const api = this.#createApi({ token: this.#token });
|
|
this.#api = api;
|
|
try {
|
|
const [bot, gateway] = await Promise.all([
|
|
api.getCurrentUser({ signal: controller.signal }),
|
|
api.getGatewayBot({ signal: controller.signal }),
|
|
]);
|
|
if (String(bot?.id ?? '') !== this.#config.platformId || bot?.bot !== true) {
|
|
throw new Error('Discord token identity does not match the saved bot');
|
|
}
|
|
this.#gatewayUrl = gateway?.url;
|
|
const client = new DiscordBotClient({ api, signal: controller.signal });
|
|
this.#bridge = new DiscordHarnessBridge({
|
|
bot: client,
|
|
harness: this.#harness,
|
|
state: this.#state,
|
|
status: this.#status,
|
|
logger: this.#logger,
|
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
signal: controller.signal,
|
|
});
|
|
let timer;
|
|
try {
|
|
await Promise.race([
|
|
this.#openSocket(false),
|
|
new Promise((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error('Discord Gateway did not become ready in time')), this.#connectTimeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
return this.status;
|
|
} catch (error) {
|
|
this.#status.ready = false;
|
|
this.#status.connectionState = 'failed';
|
|
this.#status.lastError = error?.message ?? String(error);
|
|
await this.stop();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
#openSocket(resume) {
|
|
if (this.#stopped) return Promise.reject(new Error('Discord runtime is stopped'));
|
|
const generation = ++this.#generation;
|
|
const url = socketUrl(resume && this.#resumeUrl ? this.#resumeUrl : this.#gatewayUrl);
|
|
const socket = this.#createWebSocket(url);
|
|
this.#socket = socket;
|
|
let settled = false;
|
|
return new Promise((resolve, reject) => {
|
|
const markReady = () => {
|
|
if (settled || generation !== this.#generation) return;
|
|
settled = true;
|
|
this.#reconnectAttempt = 0;
|
|
const now = Date.now();
|
|
this.#status.ready = true;
|
|
this.#status.connectionState = 'connected';
|
|
this.#status.lastCheckedAt = now;
|
|
this.#status.lastConnectedAt = now;
|
|
this.#status.lastError = null;
|
|
resolve();
|
|
};
|
|
addSocketListener(socket, 'message', (event) => {
|
|
if (generation !== this.#generation || this.#stopped) return;
|
|
const raw = eventData(event);
|
|
if (!raw) return;
|
|
let packet;
|
|
try {
|
|
packet = JSON.parse(raw);
|
|
} catch {
|
|
this.#logger.warn?.('[dsh-im:discord] ignored malformed Gateway JSON');
|
|
return;
|
|
}
|
|
if (Number.isSafeInteger(packet.s)) this.#sequence = packet.s;
|
|
if (packet.op === 10) {
|
|
this.#startHeartbeat(packet.d?.heartbeat_interval, socket, generation);
|
|
if (resume && this.#sessionId) {
|
|
this.#sendGateway(socket, {
|
|
op: 6,
|
|
d: { token: this.#token, session_id: this.#sessionId, seq: this.#sequence },
|
|
});
|
|
} else {
|
|
this.#sendGateway(socket, {
|
|
op: 2,
|
|
d: {
|
|
token: this.#token,
|
|
intents: DISCORD_GATEWAY_INTENTS,
|
|
properties: {
|
|
os: process.platform,
|
|
browser: 'dsh-im',
|
|
device: 'dsh-im',
|
|
},
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (packet.op === 11) {
|
|
this.#heartbeatAcked = true;
|
|
this.#status.lastCheckedAt = Date.now();
|
|
return;
|
|
}
|
|
if (packet.op === 1) {
|
|
this.#heartbeat(socket);
|
|
return;
|
|
}
|
|
if (packet.op === 7) {
|
|
socket.close(4000, 'Reconnect requested');
|
|
return;
|
|
}
|
|
if (packet.op === 9) {
|
|
if (packet.d !== true) {
|
|
this.#sessionId = null;
|
|
this.#resumeUrl = null;
|
|
this.#sequence = null;
|
|
}
|
|
socket.close(4000, 'Invalid session');
|
|
return;
|
|
}
|
|
if (packet.op !== 0) return;
|
|
if (packet.t === 'READY') {
|
|
this.#sessionId = packet.d?.session_id ?? null;
|
|
this.#resumeUrl = packet.d?.resume_gateway_url ?? null;
|
|
markReady();
|
|
} else if (packet.t === 'RESUMED') {
|
|
markReady();
|
|
} else if (packet.t === 'MESSAGE_CREATE') {
|
|
const message = normalizeDiscordMessage(packet.d, this.#config.platformId);
|
|
const bridge = this.#bridge;
|
|
if (message && bridge) {
|
|
void bridge.accept(message).catch((error) => {
|
|
if (generation !== this.#generation || this.#stopped) return;
|
|
this.#logger.error?.(
|
|
`[dsh-im:discord] bot ${this.#config.botId} message handling failed:`,
|
|
error,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
addSocketListener(socket, 'close', (event = {}) => {
|
|
if (generation !== this.#generation) return;
|
|
this.#clearHeartbeat();
|
|
if (this.#socket === socket) this.#socket = null;
|
|
if (this.#stopped) {
|
|
if (!settled) reject(new DOMException('Stopped', 'AbortError'));
|
|
return;
|
|
}
|
|
const error = gatewayCloseError(Number(event.code) || 0);
|
|
this.#status.ready = false;
|
|
this.#status.connectionState = 'connecting';
|
|
this.#status.lastError = error.message;
|
|
if (!settled) {
|
|
settled = true;
|
|
reject(error);
|
|
}
|
|
if (error.code === 'discord-401' || error.code === 'discord-intents') {
|
|
this.#status.connectionState = 'failed';
|
|
return;
|
|
}
|
|
this.#scheduleReconnect();
|
|
});
|
|
addSocketListener(socket, 'error', () => {
|
|
if (generation !== this.#generation || this.#stopped) return;
|
|
this.#status.lastError = 'Discord Gateway WebSocket error';
|
|
});
|
|
});
|
|
}
|
|
|
|
#sendGateway(socket, payload) {
|
|
if (socket.readyState !== 1) return;
|
|
socket.send(JSON.stringify(payload));
|
|
}
|
|
|
|
#startHeartbeat(interval, socket, generation) {
|
|
this.#clearHeartbeat();
|
|
if (!Number.isFinite(interval) || interval < 1_000) {
|
|
socket.close(4000, 'Invalid heartbeat interval');
|
|
return;
|
|
}
|
|
this.#heartbeatAcked = true;
|
|
const schedule = (delay) => {
|
|
this.#heartbeatTimer = setTimeout(() => {
|
|
if (this.#stopped || generation !== this.#generation || this.#socket !== socket) return;
|
|
if (!this.#heartbeatAcked) {
|
|
socket.close(4000, 'Heartbeat was not acknowledged');
|
|
return;
|
|
}
|
|
this.#heartbeat(socket);
|
|
schedule(interval);
|
|
}, delay);
|
|
this.#heartbeatTimer?.unref?.();
|
|
};
|
|
schedule(Math.floor(interval * this.#random()));
|
|
}
|
|
|
|
#heartbeat(socket) {
|
|
this.#heartbeatAcked = false;
|
|
this.#sendGateway(socket, { op: 1, d: this.#sequence });
|
|
}
|
|
|
|
#clearHeartbeat() {
|
|
if (this.#heartbeatTimer !== null) clearTimeout(this.#heartbeatTimer);
|
|
this.#heartbeatTimer = null;
|
|
this.#heartbeatAcked = true;
|
|
}
|
|
|
|
#scheduleReconnect() {
|
|
if (this.#stopped || this.#reconnectTimer !== null) return;
|
|
const delay = RECONNECT_DELAYS_MS[Math.min(this.#reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
this.#reconnectAttempt += 1;
|
|
this.#reconnectTimer = setTimeout(() => {
|
|
this.#reconnectTimer = null;
|
|
void this.#openSocket(Boolean(this.#sessionId)).catch((error) => {
|
|
if (this.#stopped) return;
|
|
this.#logger.warn?.('[dsh-im:discord] Gateway reconnect failed:', error);
|
|
this.#scheduleReconnect();
|
|
});
|
|
}, delay);
|
|
this.#reconnectTimer?.unref?.();
|
|
}
|
|
|
|
async stop() {
|
|
this.#stopped = true;
|
|
this.#generation += 1;
|
|
this.#abortController?.abort();
|
|
this.#abortController = null;
|
|
if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
|
|
this.#reconnectTimer = null;
|
|
this.#clearHeartbeat();
|
|
const socket = this.#socket;
|
|
const bridge = this.#bridge;
|
|
this.#socket = null;
|
|
this.#bridge = null;
|
|
this.#api = null;
|
|
try {
|
|
if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
|
|
} catch (error) {
|
|
this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, error);
|
|
}
|
|
await Promise.race([
|
|
bridge?.waitForIdle() ?? Promise.resolve(),
|
|
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
|
]);
|
|
this.#status.ready = false;
|
|
this.#status.connectionState = 'idle';
|
|
return this.status;
|
|
}
|
|
}
|