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

@ -2,6 +2,8 @@ import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { t } from '../shared/i18n.mjs';
const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
const STORED_BOT_KEYS = new Set(['clientId', 'secretRef', 'approvedSenders']);
@ -86,8 +88,8 @@ export function deriveDingtalkSenderKey() {
*/
export function maskDingtalkSenderId(staffId) {
const value = cleanString(staffId);
if (!value) return '钉钉用户';
return '身份已隐藏';
if (!value) return t('钉钉用户');
return t('身份已隐藏');
}
/**
@ -97,7 +99,7 @@ export function maskDingtalkSenderId(staffId) {
*/
export function maskDingtalkClientId(clientId) {
const value = cleanString(clientId);
if (!value) return '钉钉机器人';
if (!value) return t('钉钉机器人');
if (value.length <= 8) return `${value.slice(0, 2)}••••`;
return `${value.slice(0, 4)}••••${value.slice(-4)}`;
}

View file

@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
@ -829,7 +830,7 @@ export function createDingtalkApi({
clientId: appKey,
clientSecret: appSecret,
cardInstanceId,
text: '消息处理失败,请稍后重试。',
text: t('消息处理失败,请稍后重试。'),
signal: cleanupSignal,
}).catch(() => undefined);
}

View file

@ -40,12 +40,13 @@ import {
createDeliveryReceipt,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import { t } from '../shared/i18n.mjs';
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const HELP_TEXT = [
const HELP_TEXT_LINES = [
'钉钉机器人已连接 DeepSeek Harness。',
'',
'直接发送文字、图片或文件即可继续当前会话。',
@ -66,7 +67,11 @@ const HELP_TEXT = [
'/steer 补充指令 纠偏当前任务',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
];
function helpText() {
return HELP_TEXT_LINES.map((line) => t(line)).join('\n');
}
function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -106,13 +111,13 @@ function dingtalkImageErrorUserMessage(error) {
while (current && typeof current === 'object' && !seen.has(current)) {
seen.add(current);
if (current.code === 'image-download-address-failed') {
return '钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。';
return t('钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。');
}
if (current.code === 'invalid-image-download') {
return '钉钉没有返回图片下载地址,请重新发送。';
return t('钉钉没有返回图片下载地址,请重新发送。');
}
if (current.code === 'image-content-download-failed') {
return '钉钉返回的图片临时地址无法读取,请重新发送。';
return t('钉钉返回的图片临时地址无法读取,请重新发送。');
}
current = current.cause;
}
@ -241,34 +246,34 @@ function fileTarget(message, sender, clientId) {
}
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}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。`;
return t('结果文件「{name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。', { 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 });
}
}
function progressText(update) {
if (update?.type === 'text' && nonEmptyString(update.text)) return update.text;
if (update?.type === 'tool') {
if (update.name === 'web_search') return '_正在搜索网络并整理信息…_';
return `_正在使用 ${nonEmptyString(update.name) ?? '工具'}…_`;
if (update.name === 'web_search') return t('_正在搜索网络并整理信息…_');
return t('_正在使用 {name}…_', { name: nonEmptyString(update.name) ?? t('工具') });
}
return `_${nonEmptyString(update?.text) ?? '正在处理…'}_`;
return t('_{text}_', { text: nonEmptyString(update?.text) ?? t('正在处理…') });
}
function canClaimInteractionReply(message, pending, sender) {
@ -426,9 +431,9 @@ export class DingtalkHarnessBridge {
commandRunner,
).catch((error) => {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = '钉钉命令处理失败。';
this.#status.lastError = t('钉钉命令处理失败。');
this.#logger.error?.('[dsh-dingtalk] failed to process a command', safeErrorDiagnostic(error));
return this.#send(sessionWebhook, CARD_ERROR_TEXT).catch(() => undefined);
return this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task);
@ -463,13 +468,13 @@ export class DingtalkHarnessBridge {
if (!sessionWebhook) {
increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString();
this.#status.lastError = '钉钉消息没有安全的回复地址。';
this.#status.lastError = t('钉钉消息没有安全的回复地址。');
}
return true;
})
.catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = '钉钉审批处理失败。';
this.#status.lastError = t('钉钉审批处理失败。');
this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
})
.finally(() => {
@ -611,7 +616,7 @@ export class DingtalkHarnessBridge {
} catch {
increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString();
this.#status.lastError = '钉钉消息没有安全的回复地址。';
this.#status.lastError = t('钉钉消息没有安全的回复地址。');
return;
}
@ -628,23 +633,23 @@ export class DingtalkHarnessBridge {
let cardStarted = false;
try {
if (!text && !hasImages && !hasFiles) {
await this.#send(sessionWebhook, '目前支持文字、图片和文件消息。');
await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'));
return;
}
const command = text.toLowerCase();
if (isPlainText && !hasImages && !hasFiles && command === '/help') {
await this.#send(sessionWebhook, HELP_TEXT);
await this.#send(sessionWebhook, helpText());
return;
}
if (isPlainText && !hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
await this.#send(sessionWebhook, t('钉钉机器人与 DeepSeek Harness 连接正常。'));
return;
}
if (isPlainText && !hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key);
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
await this.#send(sessionWebhook, t('已开启新会话。请发送你的问题。'));
return;
}
const workspaceCommand = isPlainText && !hasImages && !hasFiles
@ -684,7 +689,7 @@ export class DingtalkHarnessBridge {
signal: this.#signal,
logger: this.#logger,
});
cardStarted = await cardStream.start(CARD_INITIAL_TEXT);
cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
}
const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
@ -712,7 +717,7 @@ export class DingtalkHarnessBridge {
});
const answerText = typeof answer === 'string' && answer.trim()
? answer
: artifacts.length > 0 ? '结果文件已生成。' : answer;
: artifacts.length > 0 ? t('结果文件已生成。') : answer;
let textDeliveryError = null;
let textReceipt = null;
let streamed = false;
@ -747,11 +752,11 @@ export class DingtalkHarnessBridge {
return delivery.receipt;
} catch (error) {
if (error?.code === 'turn-stopped') {
if (cardStarted) await cardStream.finish('已停止。').catch(() => undefined);
if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
return;
}
if (this.#signal?.aborted) return;
this.#status.lastError = '钉钉消息处理失败。';
this.#status.lastError = t('钉钉消息处理失败。');
this.#logger.error?.(
'[dsh-dingtalk] failed to process an inbound message',
safeErrorDiagnostic(error),
@ -759,7 +764,7 @@ export class DingtalkHarnessBridge {
try {
const errorText = inboundFileUserMessage(error)
?? dingtalkImageErrorUserMessage(error)
?? CARD_ERROR_TEXT;
?? t(CARD_ERROR_TEXT);
const streamed = cardStarted && await cardStream.finish(errorText);
if (!streamed) await this.#send(sessionWebhook, errorText);
} catch {
@ -797,14 +802,14 @@ export class DingtalkHarnessBridge {
} catch {
increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString();
this.#status.lastError = '钉钉消息没有安全的回复地址。';
this.#status.lastError = t('钉钉消息没有安全的回复地址。');
return;
}
const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
if (!text) {
try {
await this.#send(sessionWebhook, '请用文字回答当前问题。');
await this.#send(sessionWebhook, t('请用文字回答当前问题。'));
} catch {
this.#logger.error?.('[dsh-dingtalk] failed to reject a non-text interaction reply');
}
@ -815,7 +820,7 @@ export class DingtalkHarnessBridge {
if (!pending || pending !== expected || pending.submitting) {
if (claimed && (!pending || pending !== expected)) {
try {
await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
} catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
}
@ -831,7 +836,7 @@ export class DingtalkHarnessBridge {
try {
await this.#presentInteraction(pending);
} catch {
this.#status.lastError = '钉钉交互问题发送失败。';
this.#status.lastError = t('钉钉交互问题发送失败。');
this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question');
pending.interaction.reconnect?.();
}
@ -850,7 +855,7 @@ export class DingtalkHarnessBridge {
try {
await this.#presentInteraction(pending);
} catch {
this.#status.lastError = '钉钉交互问题发送失败。';
this.#status.lastError = t('钉钉交互问题发送失败。');
this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question');
pending.interaction.reconnect?.();
}
@ -874,7 +879,7 @@ export class DingtalkHarnessBridge {
if (error?.code === 'interaction-not-pending') {
this.#clearPendingInteraction(key, pending.interactionId);
try {
await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
} catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
}
@ -883,10 +888,10 @@ export class DingtalkHarnessBridge {
pending.submitting = false;
pending.answers.pop();
pending.index -= 1;
this.#status.lastError = '回答提交失败。';
this.#status.lastError = t('回答提交失败。');
this.#logger.error?.('[dsh-dingtalk] failed to answer a Harness interaction');
try {
await this.#send(sessionWebhook, '回答提交失败,请重新发送当前问题的答案。');
await this.#send(sessionWebhook, t('回答提交失败,请重新发送当前问题的答案。'));
} catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an interaction retry notice');
}
@ -932,7 +937,7 @@ export class DingtalkHarnessBridge {
},
});
try {
await this.#send(sessionWebhook, '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。');
await this.#send(sessionWebhook, t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'));
} catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an interaction recovery notice');
}
@ -1018,7 +1023,7 @@ export class DingtalkHarnessBridge {
return;
}
try {
await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
} catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
}

View file

@ -1,3 +1,5 @@
import { t } from '../shared/i18n.mjs';
const DEFAULT_UPDATE_INTERVAL_MS = 500;
const FAILURE_TEXT = '消息处理失败,请稍后重试。';
@ -100,7 +102,7 @@ export function createDingTalkCardStream({
if (!cleanupPromise) {
cleanupPromise = api.failAiCard({
...cardRequest,
text: FAILURE_TEXT,
text: t(FAILURE_TEXT),
signal: AbortSignal.timeout(5_000),
}).then(
() => true,

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', 'pending', 'connecting']);
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
@ -84,7 +85,7 @@ function normalizePendingSender(value) {
return {
requestId: opaqueRequestId,
staffId,
displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? '钉钉用户',
displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? t('钉钉用户'),
requestedAt: cleanString(value.requestedAt),
};
}
@ -114,7 +115,7 @@ function publicPendingSender(sender) {
function publicApprovedSender(sender) {
return {
senderKey: sender.senderKey,
displayName: cleanString(sender.displayName) ?? '钉钉用户',
displayName: cleanString(sender.displayName) ?? t('钉钉用户'),
senderIdMasked: maskDingtalkSenderId(sender.staffId),
approvedAt: cleanString(sender.approvedAt),
};
@ -206,7 +207,7 @@ export class DingtalkController {
if (!clientSecret) {
this.#errors.set(
latest.botId,
safeError('missing-secret', '钉钉机器人凭据缺失,请移除后重新扫码。'),
safeError('missing-secret', t('钉钉机器人凭据缺失,请移除后重新扫码。')),
);
this.#touch();
return;
@ -217,7 +218,7 @@ export class DingtalkController {
} catch {
this.#errors.set(
latest.botId,
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
);
this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
}
@ -270,10 +271,10 @@ export class DingtalkController {
} catch (error) {
if (record.controller.signal.aborted || error?.name === 'AbortError') {
record.state = 'cancelled';
record.error = safeError('cancelled', '扫码接入已取消。');
record.error = safeError('cancelled', t('扫码接入已取消。'));
} else {
record.state = 'failed';
record.error = safeError('qr-start-failed', '无法生成钉钉二维码,请稍后重试。');
record.error = safeError('qr-start-failed', t('无法生成钉钉二维码,请稍后重试。'));
}
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch();
@ -319,7 +320,7 @@ export class DingtalkController {
} catch {
this.#errors.set(
identity.botId,
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
);
this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
}
@ -337,7 +338,7 @@ export class DingtalkController {
}
if (nowFrom(this.#clock) >= record.expiresAt) {
record.state = 'expired';
record.error = safeError('expired', '二维码已过期,请重新生成。');
record.error = safeError('expired', t('二维码已过期,请重新生成。'));
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch();
return publicAttempt(record);
@ -360,7 +361,7 @@ export class DingtalkController {
record.controller.abort();
await record.pollTask?.catch(() => undefined);
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
record.error ??= safeError('cancelled', '扫码接入已取消。');
record.error ??= safeError('cancelled', t('扫码接入已取消。'));
}
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch();
@ -380,7 +381,7 @@ export class DingtalkController {
} catch (error) {
this.#errors.set(
botId,
safeError('connection-failed', '钉钉连接仍未就绪,请稍后重试。'),
safeError('connection-failed', t('钉钉连接仍未就绪,请稍后重试。')),
);
throw error;
} finally {
@ -396,10 +397,10 @@ export class DingtalkController {
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(
`钉钉机器人(${maskDingtalkClientId(config.clientId)})`,
t('钉钉机器人({clientId})', { clientId: maskDingtalkClientId(config.clientId) }),
));
});
}
@ -501,14 +502,14 @@ export class DingtalkController {
connected,
configured: true,
bot: {
name: '钉钉机器人',
name: t('钉钉机器人'),
clientIdMasked: maskDingtalkClientId(config.clientId),
},
health: {
status: connected ? 'healthy' : accountError ? 'error' : 'offline',
summary: connected
? '钉钉 Stream 消息连接运行正常'
: accountError?.message ?? '钉钉消息连接当前离线',
? t('钉钉 Stream 消息连接运行正常')
: accountError?.message ?? t('钉钉消息连接当前离线'),
lastCheckedAt: currentStatus.lastCheckedAt ?? null,
},
stats: {
@ -580,32 +581,32 @@ export class DingtalkController {
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
} else if (state === 'EXPIRED') {
record.state = 'expired';
record.error = safeError('expired', '二维码已过期,请重新生成。');
record.error = safeError('expired', t('二维码已过期,请重新生成。'));
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
} else if (state === 'FAIL') {
record.state = 'failed';
record.error = safeError('authorization-failed', '钉钉未完成机器人授权,请重新扫码。');
record.error = safeError('authorization-failed', t('钉钉未完成机器人授权,请重新扫码。'));
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
} else {
record.state = 'pending';
record.error = safeError('poll-pending', '钉钉授权状态暂时不可用,正在重试。');
record.error = safeError('poll-pending', t('钉钉授权状态暂时不可用,正在重试。'));
}
} catch (error) {
if (record.controller.signal.aborted || error?.name === 'AbortError') {
record.state = 'cancelled';
record.error = safeError('cancelled', '扫码接入已取消。');
record.error = safeError('cancelled', t('扫码接入已取消。'));
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
} else if (record.state === 'connecting') {
record.state = 'failed';
record.error = safeError(
'activation-failed',
'钉钉已授权,但无法安全保存接入配置。',
t('钉钉已授权,但无法安全保存接入配置。'),
);
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#logger.error?.('[dsh-dingtalk] bot activation failed');
} else {
record.state = 'pending';
record.error = safeError('poll-failed', '钉钉授权查询暂时失败,正在重试。');
record.error = safeError('poll-failed', t('钉钉授权查询暂时失败,正在重试。'));
}
} finally {
this.#touch();
@ -660,7 +661,7 @@ export class DingtalkController {
}
this.#errors.set(
identity.botId,
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
);
this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
}
@ -681,7 +682,7 @@ export class DingtalkController {
await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
this.#errors.set(
previousConfig.botId,
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
);
throw error;
} finally {

View file

@ -4,6 +4,7 @@ import {
DingtalkHarnessBridge,
} from './dingtalk-bridge.mjs';
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -275,7 +276,7 @@ export class DingtalkRuntime {
? JSON.parse(response.data)
: response?.data;
} catch {
this.#status.lastError = '钉钉消息格式无效。';
this.#status.lastError = t('钉钉消息格式无效。');
this.#logger.warn?.('[dsh-dingtalk] ignored an invalid callback payload');
return;
}
@ -284,7 +285,7 @@ export class DingtalkRuntime {
await bridge.accept(message);
}).catch(() => {
if (signal.aborted || this.#bridge !== bridge) return;
this.#status.lastError = '钉钉消息处理失败。';
this.#status.lastError = t('钉钉消息处理失败。');
this.#logger.error?.('[dsh-dingtalk] callback processing failed');
}).finally(() => this.#callbackTasks.delete(task));
this.#callbackTasks.add(task);
@ -352,7 +353,7 @@ export class DingtalkRuntime {
return sendRememberedConnectionTest({
state: this.#state,
text,
channelLabel: '钉钉机器人',
channelLabel: t('钉钉机器人'),
send: async ({ sessionWebhook }, content) => {
if (!this.#status.ready || !this.#abortController) {
throw new Error('DingTalk runtime is not connected');

View file

@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { t } from '../shared/i18n.mjs';
const EMPTY_STATE = Object.freeze({
version: 1,
sessions: {},
@ -14,7 +16,7 @@ function nonEmptyString(value) {
}
function displayName(value) {
return (nonEmptyString(value) ?? '钉钉用户').slice(0, 100);
return (nonEmptyString(value) ?? t('钉钉用户')).slice(0, 100);
}
function normalizePendingSender(value, fallbackRequestId) {