dsh-im-ops/src/channels/whatsapp/whatsapp-controller.mjs
Zaakir dcd7cd64d8 feat(i18n): add English support for host-side chat messages
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.
2026-08-24 00:32:30 +05:00

437 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from 'node:crypto';
import { connectionTestMessage } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import {
deriveWhatsappBotId,
maskWhatsappAccount,
normalizeWhatsappAccessPolicy,
} from './config-store.mjs';
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
const QR_TTL_MS = 60_000;
function safeError(code, message) {
return Object.freeze({ code, message });
}
function publicAttempt(record) {
if (!record) return null;
return {
attemptId: record.id,
status: record.state,
qrRevision: record.qrRevision,
pollIntervalMs: 1_000,
...(record.qrValue ? { qrValue: record.qrValue } : {}),
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
...(record.botId ? { botId: record.botId } : {}),
...(record.error ? { error: structuredClone(record.error) } : {}),
};
}
export class WhatsappController {
#configStore;
#authPath;
#createSession;
#createRuntime;
#deleteAuth;
#deleteState;
#logger;
#runtimes = new Map();
#errors = new Map();
#attempts = new Map();
#transitions = new Map();
#activeAttemptId = null;
#revision = 0;
#closed = false;
constructor({
configStore,
authPath,
createSession,
createRuntime,
deleteAuth = async () => {},
deleteState = async () => {},
logger = console,
}) {
if (!configStore || typeof configStore.list !== 'function'
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
throw new TypeError('WhatsappController requires a config store');
}
if (typeof authPath !== 'function' || typeof createSession !== 'function'
|| typeof createRuntime !== 'function') {
throw new TypeError('WhatsappController dependencies are incomplete');
}
this.#configStore = configStore;
this.#authPath = authPath;
this.#createSession = createSession;
this.#createRuntime = createRuntime;
this.#deleteAuth = deleteAuth;
this.#deleteState = deleteState;
this.#logger = logger;
}
async initialize() {
if (this.#closed) return this.status();
for (const config of this.#configStore.list()) {
await this.#withBotTransition(config.botId, async () => {
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
try {
await this.#startRuntime(config);
this.#errors.delete(config.botId);
} catch (error) {
this.#errors.set(config.botId, safeError(
error?.code === 'relink-required' ? 'relink-required' : 'connection-failed',
error?.code === 'relink-required'
? t('WhatsApp 关联设备已失效,请移除后重新扫码。')
: t('WhatsApp 连接未就绪,插件会自动重试。'),
));
this.#logger.warn?.(`[dsh-im:whatsapp] bot ${config.botId} failed to initialize`);
} finally {
this.#touch();
}
});
}
return this.status();
}
async startProvisioning() {
if (this.#closed) throw new Error('WhatsApp controller is closed');
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
let resolveFirstQr;
let rejectFirstQr;
let firstQrSettled = false;
const firstQr = new Promise((resolve, reject) => {
resolveFirstQr = () => {
if (firstQrSettled) return;
firstQrSettled = true;
resolve();
};
rejectFirstQr = (error) => {
if (firstQrSettled) return;
firstQrSettled = true;
reject(error);
};
});
const id = randomUUID();
const record = {
id,
state: 'starting',
authDirectory: id,
createdAt: Date.now(),
expiresAt: null,
qrRevision: 0,
qrValue: null,
controller: new AbortController(),
session: null,
task: null,
error: null,
botId: null,
};
this.#attempts.set(id, record);
this.#activeAttemptId = id;
this.#touch();
try {
const session = await this.#createSession({
authDir: this.#authPath(record.authDirectory),
signal: record.controller.signal,
logger: this.#logger,
onQr: (value) => {
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)
|| typeof value !== 'string' || !value) return;
record.qrValue = value;
record.qrRevision += 1;
record.expiresAt = Date.now() + QR_TTL_MS;
record.state = 'pending';
this.#touch();
resolveFirstQr();
},
});
record.session = session;
record.task = session.ready.then((identity) => this.#completeProvisioning(record, identity))
.catch((error) => this.#failProvisioning(record, error, rejectFirstQr));
await firstQr;
return publicAttempt(record);
} catch (error) {
await this.#failProvisioning(record, error, rejectFirstQr);
throw error;
}
}
registrationStatus(attemptId) {
return publicAttempt(this.#attempts.get(attemptId));
}
async cancelProvisioning(attemptId) {
const record = this.#attempts.get(attemptId);
if (!record) return null;
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
record.controller.abort();
await record.session?.close().catch(() => undefined);
await record.task?.catch(() => undefined);
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
record.state = 'cancelled';
record.error = safeError('cancelled', t('扫码接入已取消。'));
}
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
}
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch();
return publicAttempt(record);
}
async reconnectBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown WhatsApp bot');
await this.#withBotTransition(botId, async () => {
try {
await this.#startRuntime(config);
this.#errors.delete(botId);
} catch (error) {
this.#errors.set(botId, safeError(
error?.code === 'relink-required' ? 'relink-required' : 'connection-failed',
error?.code === 'relink-required'
? t('WhatsApp 关联设备已失效,请移除后重新扫码。')
: t('WhatsApp 连接仍未就绪,请稍后重试。'),
));
throw error;
} finally {
this.#touch();
}
});
return this.status();
}
async sendConnectionTest(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown WhatsApp bot');
return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
const error = new Error(t('WhatsApp机器人尚未连接'));
error.code = 'test-target-unavailable';
throw error;
}
return runtime.sendConnectionTest(
connectionTestMessage(
`${config.name}(${maskWhatsappAccount(config.accountJid)})`,
t('WhatsApp机器人'),
),
);
});
}
async setAccessPolicy(botId, value) {
if (this.#closed) throw new Error('WhatsApp controller is closed');
const accessPolicy = normalizeWhatsappAccessPolicy(value);
await this.#withBotTransition(botId, async () => {
if (this.#closed) throw new Error('WhatsApp controller is closed');
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown WhatsApp bot');
const saved = await this.#configStore.save({ ...config, ...accessPolicy });
this.#runtimes.get(botId)?.setAccessPolicy?.(saved);
this.#touch();
});
return this.status();
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown WhatsApp bot');
await this.#withBotTransition(botId, async () => {
await this.#stopRuntime(botId);
try {
await this.#configStore.remove(botId);
} catch (error) {
await this.#startRuntime(config).catch(() => undefined);
throw error;
}
await Promise.allSettled([
this.#deleteAuth(config.authDirectory),
this.#deleteState({ botId, config }),
]);
this.#errors.delete(botId);
this.#touch();
});
return this.status();
}
status() {
const bots = this.#configStore.list().map((config) => {
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
const connected = runtimeStatus?.ready === true
&& runtimeStatus.connectionState === 'connected'
&& runtimeStatus.harnessReachable === true;
const state = connected ? 'connected'
: runtimeStatus?.connectionState === 'connecting' ? 'connecting'
: this.#errors.has(config.botId) || runtimeStatus?.connectionState === 'failed'
? 'error' : 'offline';
return {
botId: config.botId,
state,
connected,
configured: true,
bot: { name: config.name, idMasked: maskWhatsappAccount(config.accountJid) },
health: {
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
summary: connected ? t('WhatsApp Web 关联设备运行正常')
: state === 'error' ? t('WhatsApp 连接未就绪') : t('WhatsApp 连接当前离线'),
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
},
stats: {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
accessPolicy: normalizeWhatsappAccessPolicy(config),
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});
const connectedCount = bots.filter((bot) => bot.connected).length;
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
return {
schemaVersion: 1,
revision: this.#revision,
state: active && ACTIVE_ATTEMPT_STATES.has(active.state) ? 'provisioning'
: bots.length === 0 ? 'disconnected'
: connectedCount === bots.length ? 'connected'
: connectedCount > 0 ? 'degraded' : 'offline',
bots,
totals: { configured: bots.length, connected: connectedCount },
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
? { provisioning: publicAttempt(active) } : {}),
};
}
async close() {
if (this.#closed) return;
this.#closed = true;
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
await Promise.allSettled([...this.#transitions.values()]);
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
}
async #completeProvisioning(record, identity) {
if (record.controller.signal.aborted || this.#closed) return;
record.state = 'connecting';
record.qrValue = null;
record.expiresAt = null;
this.#touch();
const botId = deriveWhatsappBotId(identity.accountJid);
record.botId = botId;
await record.session?.close();
const previous = this.#configStore.get(botId);
const config = {
botId,
accountJid: identity.accountJid,
authDirectory: record.authDirectory,
name: identity.name,
createdAt: previous?.createdAt ?? new Date().toISOString(),
connectedAt: new Date().toISOString(),
accessMode: previous?.accessMode,
allowedNumbers: previous?.allowedNumbers,
};
try {
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });
await this.#configStore.save(config);
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });
try {
await this.#startRuntime(config);
this.#errors.delete(botId);
} catch (error) {
this.#errors.set(botId, safeError('connection-failed', t('WhatsApp 已绑定,消息连接暂未就绪。')));
this.#logger.warn?.(`[dsh-im:whatsapp] bot ${botId} did not reconnect after QR binding`);
}
if (previous?.authDirectory && previous.authDirectory !== config.authDirectory) {
await this.#deleteAuth(previous.authDirectory).catch(() => undefined);
}
record.state = 'connected';
record.error = null;
} catch (error) {
if (record.controller.signal.aborted || this.#closed || error?.name === 'AbortError') {
await this.#stopRuntime(botId);
if (previous) await this.#configStore.save(previous).catch(() => undefined);
else {
const removed = await this.#configStore.remove(botId).catch(() => null);
if (removed) {
await this.#deleteState({ botId, config }).catch((cleanupError) => {
this.#logger.warn?.('[dsh-im:whatsapp] cancelled bot state cleanup failed:', cleanupError);
});
}
}
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
if (previous) await this.#startRuntime(previous).catch(() => undefined);
record.state = 'cancelled';
record.error = safeError('cancelled', t('扫码接入已取消。'));
} else {
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
record.state = 'failed';
record.error = safeError('activation-failed', t('WhatsApp 已扫码,但无法保存关联设备。'));
this.#logger.error?.('[dsh-im:whatsapp] unable to persist linked-device session');
}
} finally {
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch();
}
}
async #failProvisioning(record, error, rejectFirstQr = () => {}) {
if (TERMINAL_ATTEMPT_STATES.has(record.state)) return;
if (record.controller.signal.aborted || error?.name === 'AbortError') {
record.state = 'cancelled';
record.error = safeError('cancelled', t('扫码接入已取消。'));
} else {
record.state = 'failed';
record.error = safeError('qr-connect-failed', t('无法连接 WhatsApp,请重新生成二维码。'));
}
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
await record.session?.close().catch(() => undefined);
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
this.#touch();
rejectFirstQr(error);
}
async #startRuntime(config) {
if (this.#closed) throw new Error('WhatsApp controller is closed');
await this.#stopRuntime(config.botId);
if (this.#closed) throw new Error('WhatsApp controller is closed');
const runtime = await this.#createRuntime({
botId: config.botId,
config,
authDir: this.#authPath(config.authDirectory),
});
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
throw new TypeError('createRuntime returned an invalid WhatsApp runtime');
}
this.#runtimes.set(config.botId, runtime);
try {
await runtime.start();
} catch (error) {
await runtime.stop().catch(() => undefined);
this.#runtimes.delete(config.botId);
throw error;
}
}
async #stopRuntime(botId) {
const runtime = this.#runtimes.get(botId);
this.#runtimes.delete(botId);
await runtime?.stop().catch(() => undefined);
}
#withBotTransition(botId, operation) {
const previous = this.#transitions.get(botId) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(operation);
const settled = current.finally(() => {
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
});
this.#transitions.set(botId, settled);
return settled;
}
#touch() {
this.#revision += 1;
}
}