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.
This commit is contained in:
Zaakir 2026-08-23 22:34:11 +05:00
parent 3ff8c2a519
commit dcd7cd64d8
78 changed files with 2283 additions and 992 deletions

View file

@ -3,6 +3,7 @@ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { normalizeWeixinApiBaseUrl } from './weixin-api.mjs';
import { t } from '../shared/i18n.mjs';
const EMPTY_DOCUMENT = Object.freeze({ version: 1, accounts: Object.freeze([]) });
@ -32,7 +33,7 @@ export function deriveWeixinBotIdentity(accountId) {
export function maskWeixinAccountId(accountId) {
const value = cleanString(accountId) ?? '';
if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : '微信机器人';
if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : t('微信机器人');
return `${value.slice(0, 6)}••••${value.slice(-4)}`;
}

View file

@ -43,31 +43,32 @@ import {
createDeliveryReceipt,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import { t } from '../shared/i18n.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
const GENERIC_PROCESSING_ERROR = () => t('消息处理失败,请稍后重试。');
const HELP_TEXT = [
'微信已连接 DeepSeek Harness。',
const HELP_TEXT = () => [
t('微信已连接 DeepSeek Harness。'),
'',
'直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
'/models 按序号列出所有可用模型',
'/model [序号或完整模型ID] 查看或切换当前会话模型',
'示例:先发 /models,再发 /model 2',
'/presetlist 按序号列出可用 Agent Preset',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
'纯数字 ID:/preset id:<ID>',
'/preset --default 跟随 Host 默认',
'/stop 停止当前任务',
'/steer 补充指令 纠偏当前任务',
'/status 检查连接状态',
'/help 显示本帮助',
t('直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。'),
t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'),
t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
t('/models 按序号列出所有可用模型'),
t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
t('示例:先发 /models,再发 /model 2'),
t('/presetlist 按序号列出可用 Agent Preset'),
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
t('纯数字 ID:/preset id:<ID>'),
t('/preset --default 跟随 Host 默认'),
t('/stop 停止当前任务'),
t('/steer 补充指令 纠偏当前任务'),
t('/status 检查连接状态'),
t('/help 显示本帮助'),
].join('\n');
function conversationKey(userId) {
@ -108,7 +109,7 @@ function canClaimInteractionReply(message, pending) {
&& nonEmptyString(extractWeixinText(message));
}
function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR) {
function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR()) {
const diagnostic = imagePromptDiagnostic(error);
return {
code: diagnostic?.code ?? 'message-processing-failed',
@ -119,24 +120,24 @@ function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR) {
}
function artifactFailureText(fileName, error) {
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
switch (error?.code) {
case 'artifact-delivery-uncertain':
return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
return t('结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
case 'artifact-permission-required':
return `结果文件「${name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。`;
return t('结果文件「{name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。', { name });
case 'artifact-too-large':
return `结果文件「${name}」超过当前微信会话可发送的文件大小,未发送。`;
return t('结果文件「{name}」超过当前微信会话可发送的文件大小,未发送。', { name });
case 'artifact-rate-limited':
return `结果文件「${name}」暂时被微信限流,未能发送,请稍后重试。`;
return t('结果文件「{name}」暂时被微信限流,未能发送,请稍后重试。', { name });
case 'artifact-provider-rejected':
return `结果文件「${name}」已生成,但微信拒绝了该文件消息。`;
return t('结果文件「{name}」已生成,但微信拒绝了该文件消息。', { name });
case 'artifact-invalid':
case 'artifact-changed':
case 'artifact-unavailable':
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
default:
return `结果文件「${name}」已生成,但暂时未能通过微信发送,请稍后重试。`;
return t('结果文件「{name}」已生成,但暂时未能通过微信发送,请稍后重试。', { name });
}
}
@ -244,7 +245,7 @@ export class WeixinHarnessBridge {
this.#status.lastError = error?.message ?? String(error);
this.#status.lastMessageError = safeMessageError(error);
this.#logger.error?.('[dsh-weixin] failed to process a command:', error);
return this.#send(sender, GENERIC_PROCESSING_ERROR, contextToken, runId)
return this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
.catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
@ -400,26 +401,26 @@ export class WeixinHarnessBridge {
const hasImages = hasInboundImages(promptMessage);
const hasFiles = hasInboundFiles(promptMessage);
if (!text && !hasImages && !hasFiles) {
await this.#send(sender, '目前支持文字、图片、文件,以及微信已转成文字的语音消息。', contextToken, runId);
await this.#send(sender, t('目前支持文字、图片、文件,以及微信已转成文字的语音消息。'), contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
const command = text.trim().toLowerCase();
if (!hasImages && !hasFiles && command === '/help') {
await this.#send(sender, HELP_TEXT, contextToken, runId);
await this.#send(sender, HELP_TEXT(), contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
await this.#send(sender, t('微信与 DeepSeek Harness 连接正常。'), contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key);
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
await this.#send(sender, t('已开启新会话。请发送你的问题。'), contextToken, runId);
await this.#state.markSeen(messageId);
return;
}
@ -483,7 +484,7 @@ export class WeixinHarnessBridge {
}
const answerText = typeof answer === 'string' && answer.trim()
? answer
: artifacts.length > 0 ? '结果文件已生成。' : answer;
: artifacts.length > 0 ? t('结果文件已生成。') : answer;
let textDeliveryError = null;
let textReceipt = null;
try {
@ -519,7 +520,7 @@ export class WeixinHarnessBridge {
this.#status.lastError = error?.message ?? String(error);
const userMessage = inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? GENERIC_PROCESSING_ERROR;
?? GENERIC_PROCESSING_ERROR();
this.#status.lastMessageError = safeMessageError(error, userMessage);
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
try {
@ -557,7 +558,7 @@ export class WeixinHarnessBridge {
if (!text || hasWeixinImageItems(message)) {
await this.#send(
expected.actor,
'请用文字回答当前问题。',
t('请用文字回答当前问题。'),
contextToken,
runId,
);
@ -569,7 +570,7 @@ export class WeixinHarnessBridge {
if (claimed && (!pending || pending !== expected)) {
await this.#send(
expected.actor,
INTERACTION_RESOLVED_TEXT,
INTERACTION_RESOLVED_TEXT(),
contextToken,
runId,
);
@ -586,7 +587,7 @@ export class WeixinHarnessBridge {
try {
await this.#presentInteraction(pending);
} catch {
this.#status.lastError = '微信交互问题发送失败。';
this.#status.lastError = t('微信交互问题发送失败。');
this.#logger.error?.('[dsh-weixin] failed to retry an interaction question');
pending.interaction.reconnect?.();
return;
@ -596,7 +597,7 @@ export class WeixinHarnessBridge {
if (claimed && (!presentedPending || presentedPending !== expected)) {
await this.#send(
expected.actor,
INTERACTION_RESOLVED_TEXT,
INTERACTION_RESOLVED_TEXT(),
contextToken,
runId,
).catch(() => undefined);
@ -621,7 +622,7 @@ export class WeixinHarnessBridge {
try {
await this.#presentInteraction(pending);
} catch {
this.#status.lastError = '微信交互问题发送失败。';
this.#status.lastError = t('微信交互问题发送失败。');
this.#logger.error?.('[dsh-weixin] failed to send the next interaction question');
pending.interaction.reconnect?.();
}
@ -646,7 +647,7 @@ export class WeixinHarnessBridge {
this.#clearPendingInteraction(key, pending.interactionId);
await this.#send(
pending.actor,
INTERACTION_RESOLVED_TEXT,
INTERACTION_RESOLVED_TEXT(),
pending.contextToken,
pending.runId,
).catch(() => undefined);
@ -656,11 +657,11 @@ export class WeixinHarnessBridge {
pending.submitting = false;
pending.answers.pop();
pending.index -= 1;
this.#status.lastError = '回答提交失败。';
this.#status.lastError = t('回答提交失败。');
this.#logger.error?.('[dsh-weixin] failed to answer a Harness interaction');
await this.#send(
pending.actor,
'回答提交失败,请重新发送当前问题的答案。',
t('回答提交失败,请重新发送当前问题的答案。'),
pending.contextToken,
pending.runId,
).catch(() => undefined);
@ -706,7 +707,7 @@ export class WeixinHarnessBridge {
});
await this.#send(
actor,
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
contextToken,
runId,
).catch(() => undefined);
@ -792,7 +793,7 @@ export class WeixinHarnessBridge {
this.#status.lastMessageAt = new Date().toISOString();
await this.#send(
nonEmptyString(message?.from_user_id),
INTERACTION_RESOLVED_TEXT,
INTERACTION_RESOLVED_TEXT(),
nonEmptyString(message?.context_token) ?? undefined,
nonEmptyString(message?.run_id) ?? undefined,
).catch(() => undefined);
@ -840,7 +841,7 @@ export class WeixinHarnessBridge {
}
await this.#send(
nonEmptyString(message?.from_user_id),
GENERIC_PROCESSING_ERROR,
GENERIC_PROCESSING_ERROR(),
nonEmptyString(message?.context_token) ?? undefined,
nonEmptyString(message?.run_id) ?? undefined,
).catch(() => undefined);

View file

@ -10,6 +10,7 @@ import {
connectionTestMessage,
connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
const ACTIVE_ATTEMPT_STATES = new Set([
'starting',
@ -98,8 +99,8 @@ function publicProvisioningError(error) {
if (error instanceof WeixinApiError) return safeAccountError(error.code, error.message);
const message = ACTIVATION_ERROR_MESSAGES[error?.code];
return message
? safeAccountError(error.code, message)
: safeAccountError('activation-unknown-failed', '微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。');
? safeAccountError(error.code, t(message))
: safeAccountError('activation-unknown-failed', t('微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。'));
}
function preserveActivationError(error, fallbackCode) {
@ -167,7 +168,7 @@ export class WeixinController {
if (!token) {
this.#errors.set(
latest.botId,
safeAccountError('missing-token', '登录凭据缺失,请移除账号后重新扫码。'),
safeAccountError('missing-token', t('登录凭据缺失,请移除账号后重新扫码。')),
);
return;
}
@ -176,7 +177,7 @@ export class WeixinController {
} catch (error) {
this.#errors.set(
latest.botId,
safeAccountError('connection-failed', '微信连接未就绪,插件会自动重试。'),
safeAccountError('connection-failed', t('微信连接未就绪,插件会自动重试。')),
);
this.#logger.warn?.(`[dsh-weixin] account ${latest.botId} failed to initialize:`, error);
} finally {
@ -227,12 +228,12 @@ export class WeixinController {
} catch (error) {
if (record.controller.signal.aborted) {
record.state = 'cancelled';
record.error = safeAccountError('cancelled', '扫码绑定已取消。');
record.error = safeAccountError('cancelled', t('扫码绑定已取消。'));
} else {
record.state = 'failed';
record.error = safeAccountError(
error instanceof WeixinApiError ? error.code : 'qr-start-failed',
error instanceof WeixinApiError ? error.message : '无法生成微信二维码,请稍后重试。',
error instanceof WeixinApiError ? error.message : t('无法生成微信二维码,请稍后重试。'),
);
}
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
@ -271,7 +272,7 @@ export class WeixinController {
record.verifyResolve = null;
await record.task?.catch(() => undefined);
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
record.error ??= safeAccountError('cancelled', '扫码绑定已取消。');
record.error ??= safeAccountError('cancelled', t('扫码绑定已取消。'));
}
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch();
@ -288,7 +289,7 @@ export class WeixinController {
await this.#startRuntime(config, token);
this.#errors.delete(botId);
} catch (error) {
this.#errors.set(botId, safeAccountError('connection-failed', '微信连接仍未就绪,请稍后重试。'));
this.#errors.set(botId, safeAccountError('connection-failed', t('微信连接仍未就绪,请稍后重试。')));
throw error;
} finally {
this.#touch();
@ -303,10 +304,10 @@ export class WeixinController {
return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
throw connectionTestTargetUnavailable('微信机器人');
throw connectionTestTargetUnavailable(t('微信机器人'));
}
return runtime.sendConnectionTest(connectionTestMessage(
`微信机器人(${maskWeixinAccountId(config.accountId)})`,
t('微信机器人({name})', { name: maskWeixinAccountId(config.accountId) }),
));
});
}
@ -352,7 +353,7 @@ export class WeixinController {
? 'error'
: 'offline';
const error = this.#errors.get(config.botId) ?? (state === 'error'
? safeAccountError('connection-failed', '微信连接未就绪,插件会自动重试。')
? safeAccountError('connection-failed', t('微信连接未就绪,插件会自动重试。'))
: null);
return {
botId: config.botId,
@ -360,16 +361,16 @@ export class WeixinController {
connected,
configured: true,
bot: {
name: '微信机器人',
name: t('微信机器人'),
accountIdMasked: maskWeixinAccountId(config.accountId),
},
health: {
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
summary: connected
? '微信消息长轮询运行正常'
? t('微信消息长轮询运行正常')
: state === 'error'
? '微信连接未就绪,插件会自动重试'
: '微信连接当前离线',
? t('微信连接未就绪,插件会自动重试')
: t('微信连接当前离线'),
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
},
stats: {
@ -438,11 +439,11 @@ export class WeixinController {
record.state = 'needs_verification';
} else if (response.status === 'verify_code_blocked') {
record.state = 'failed';
record.error = safeAccountError('verification-blocked', '配对码多次错误,请重新生成二维码。');
record.error = safeAccountError('verification-blocked', t('配对码多次错误,请重新生成二维码。'));
break;
} else if (response.status === 'expired') {
record.state = 'expired';
record.error = safeAccountError('expired', '二维码已过期,请重新生成。');
record.error = safeAccountError('expired', t('二维码已过期,请重新生成。'));
break;
} else if (response.status === 'scaned_but_redirect') {
record.currentBaseUrl = apiBaseFromServer(response.redirect_host, record.currentBaseUrl);
@ -453,7 +454,7 @@ export class WeixinController {
) ?? this.#configStore.list()[0];
if (!existing) {
record.state = 'failed';
record.error = safeAccountError('already-bound', '该微信账号已绑定,但本机没有可恢复的凭据。');
record.error = safeAccountError('already-bound', t('该微信账号已绑定,但本机没有可恢复的凭据。'));
} else {
record.state = 'connected';
record.botId = existing.botId;
@ -465,7 +466,7 @@ export class WeixinController {
const accountId = cleanString(response.ilink_bot_id);
const ownerUserId = cleanString(response.ilink_user_id);
if (!token || !accountId || !ownerUserId) {
throw new WeixinApiError('incomplete-login', '微信授权成功,但返回的账号凭据不完整。');
throw new WeixinApiError('incomplete-login', t('微信授权成功,但返回的账号凭据不完整。'));
}
record.state = 'connecting';
this.#touch();
@ -485,12 +486,12 @@ export class WeixinController {
if (!record.controller.signal.aborted && Date.now() >= record.expiresAt
&& !TERMINAL_ATTEMPT_STATES.has(record.state)) {
record.state = 'expired';
record.error = safeAccountError('expired', '二维码已过期,请重新生成。');
record.error = safeAccountError('expired', t('二维码已过期,请重新生成。'));
}
} catch (error) {
if (record.controller.signal.aborted || error?.name === 'AbortError') {
record.state = 'cancelled';
record.error = safeAccountError('cancelled', '扫码绑定已取消。');
record.error = safeAccountError('cancelled', t('扫码绑定已取消。'));
} else {
record.state = 'failed';
record.error = publicProvisioningError(error);

View file

@ -4,6 +4,7 @@ import {
connectionTestTarget,
connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
const HARNESS_HEALTH_ERROR_CODES = new Set([
@ -224,7 +225,7 @@ export class WeixinRuntime {
const code = response.errcode ?? response.ret;
throw new WeixinApiError(
code === -14 ? 'stale-token' : 'updates-rejected',
code === -14 ? '微信登录凭据已失效,请移除账号后重新扫码。' : '微信消息同步请求被拒绝。',
code === -14 ? t('微信登录凭据已失效,请移除账号后重新扫码。') : t('微信消息同步请求被拒绝。'),
);
}
consecutiveFailures = 0;
@ -293,7 +294,7 @@ export class WeixinRuntime {
: typeof this.#config.ownerUserId === 'string' && this.#config.ownerUserId.trim()
? this.#config.ownerUserId.trim()
: null;
if (!toUserId) throw connectionTestTargetUnavailable('微信机器人');
if (!toUserId) throw connectionTestTargetUnavailable(t('微信机器人'));
if (!this.#status.ready || !this.#abortController) {
throw new Error('Weixin runtime is not connected');
}