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

@ -51,13 +51,15 @@ import {
watchListCard,
workspaceListCard,
} from './feishu-cards.mjs';
import { t } from '../shared/i18n.mjs';
import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
import {
FEISHU_GROUP_RESPONSE_MODES,
normalizeFeishuGroupResponseMode,
} from './group-response-mode.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
// Lazily evaluated: t() must run after setImHostLanguage, not at import time.
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
const MENU_COMMAND = /^\/m(?:enu)?$/i;
@ -87,34 +89,37 @@ const REPAIR_URL_HOSTS = new Set([
'open.larksuite.com',
]);
const HELP_TEXT = [
'北汇星河 AIOS 已连接 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 检查连接状态',
'/repair 修复卡片按钮回调',
'/m(或 /menu) 打开交互卡片菜单',
'/watch [Session ID 或序号] 关注会话,任务完成自动推送',
'/unwatch [Session ID 或序号] 取消关注',
'/watchlist 查看关注列表',
'/archived on|off 会话列表是否包含归档会话',
'/help 显示本帮助',
].join('\n');
// Built lazily: t() must run after setImHostLanguage, not at import time.
function helpText() {
return [
t('北汇星河 AIOS 已连接 DeepSeek Harness。'),
'',
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('/repair 修复卡片按钮回调'),
t('/m(或 /menu) 打开交互卡片菜单'),
t('/watch [Session ID 或序号] 关注会话,任务完成自动推送'),
t('/unwatch [Session ID 或序号] 取消关注'),
t('/watchlist 查看关注列表'),
t('/archived on|off 会话列表是否包含归档会话'),
t('/help 显示本帮助'),
].join('\n');
}
const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
@ -122,43 +127,43 @@ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
function safeErrorText(error) {
switch (error?.code) {
case 'workspace-not-absolute':
return '工作区必须是绝对路径。';
return t('工作区必须是绝对路径。');
case 'workspace-not-found':
return '工作区路径不存在。';
return t('工作区路径不存在。');
case 'workspace-not-directory':
return '工作区路径必须指向一个目录。';
return t('工作区路径必须指向一个目录。');
case 'workspace-bot-not-found':
return '机器人正在移除或已重新接入,无法操作原会话的工作区。';
return t('机器人正在移除或已重新接入,无法操作原会话的工作区。');
default:
return '操作失败,请稍后重试。';
return t('操作失败,请稍后重试。');
}
}
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-permission-required':
return `结果文件「${name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。`;
return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。', { name });
case 'artifact-too-large':
return `结果文件「${name}」超过飞书 30 MB 上限,未发送。`;
return t('结果文件「{name}」超过飞书 30 MB 上限,未发送。', { name });
case 'artifact-empty':
return `结果文件「${name}」为空,飞书不允许发送空文件。`;
return t('结果文件「{name}」为空,飞书不允许发送空文件。', { name });
case 'artifact-changed':
case 'artifact-invalid':
case 'artifact-unavailable':
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
case 'artifact-rate-limited':
return `结果文件「${name}」暂时被飞书限流,未能发送,请稍后重试。`;
return t('结果文件「{name}」暂时被飞书限流,未能发送,请稍后重试。', { name });
case 'artifact-delivery-uncertain':
return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
return t('结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
default:
return `结果文件「${name}」已生成,但暂时未能发送,请稍后重试。`;
return t('结果文件「{name}」已生成,但暂时未能发送,请稍后重试。', { name });
}
}
function answerTextForDelivery(answer, artifacts) {
if (typeof answer === 'string' && answer.trim()) return answer;
return artifacts.length > 0 ? '结果文件已生成。' : answer;
return artifacts.length > 0 ? t('结果文件已生成。') : answer;
}
function nonEmptyString(value) {
@ -611,7 +616,7 @@ export class FeishuHarnessBridge {
event.message.chat_id,
inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
?? t('处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。'),
).catch(() => undefined);
}
@ -675,7 +680,7 @@ export class FeishuHarnessBridge {
const hasFiles = hasInboundFiles(message);
const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
if (!text && !hasImages && !hasFiles) {
await this.#send(event.message.chat_id, '目前支持文字、图片和文件消息。');
await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'));
return;
}
@ -684,7 +689,7 @@ export class FeishuHarnessBridge {
return;
}
if (commandText === '/help') {
await this.#send(event.message.chat_id, HELP_TEXT);
await this.#send(event.message.chat_id, helpText());
return;
}
if (MENU_COMMAND.test(commandText)) {
@ -694,12 +699,12 @@ export class FeishuHarnessBridge {
}
if (commandText === '/new') {
await this.#state.clearSession(key);
await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'));
return;
}
if (commandText === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
await this.#send(event.message.chat_id, t('飞书机器人与 DeepSeek Harness 连接正常。'));
return;
}
if (SESSION_LIST_PREFIX.test(commandText)) {
@ -729,7 +734,7 @@ export class FeishuHarnessBridge {
const match = ARCHIVED_COMMAND.exec(commandText);
const value = match[1]?.toLowerCase();
if (value !== 'on' && value !== 'off') {
await this.#send(event.message.chat_id, '用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)');
await this.#send(event.message.chat_id, t('用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)'));
return;
}
if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
@ -737,7 +742,7 @@ export class FeishuHarnessBridge {
}
await this.#send(
event.message.chat_id,
value === 'on' ? '已开启:会话列表包含归档会话。' : '已关闭:会话列表隐藏归档会话。',
value === 'on' ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
);
return;
}
@ -794,7 +799,7 @@ export class FeishuHarnessBridge {
// and number replies because callback buttons are the capability being fixed.
async #handleRepairCommand(event, commandText) {
if (event?.message?.chat_type !== 'p2p') {
await this.#send(event.message.chat_id, '为避免授权链接暴露,请私聊机器人发送 /repair。');
await this.#send(event.message.chat_id, t('为避免授权链接暴露,请私聊机器人发送 /repair。'));
return;
}
const actorOpenId = strictSenderOpenId(event);
@ -802,19 +807,19 @@ export class FeishuHarnessBridge {
await this.#send(
event.message.chat_id,
this.#repairOwnerOpenIds.size === 0
? '当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。'
: '此操作只能由机器人接入者在私聊中发起,未进行任何修改。',
? t('当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。')
: t('此操作只能由机器人接入者在私聊中发起,未进行任何修改。'),
);
return;
}
if (!this.#repair) {
await this.#send(event.message.chat_id, '当前 Host 版本暂不支持聊天内修复,请先更新插件。');
await this.#send(event.message.chat_id, t('当前 Host 版本暂不支持聊天内修复,请先更新插件。'));
return;
}
const parsed = REPAIR_COMMAND.exec(commandText);
if (!parsed) {
await this.#send(event.message.chat_id, '用法:/repair、/repair qr、/repair status、/repair cancel 或 /repair verify');
await this.#send(event.message.chat_id, t('用法:/repair、/repair qr、/repair status、/repair cancel 或 /repair verify'));
return;
}
const operation = parsed[1]?.toLowerCase() ?? 'start';
@ -828,12 +833,12 @@ export class FeishuHarnessBridge {
if (!attempt) {
await this.#send(
chatId,
'当前 Runtime 没有可恢复的修复任务记录(机器人可能刚完成密钥更新并重启)。本命令不会启动新的授权;请查看机器人发送的验证结果,确认上一次任务已结束后再发送 /repair。',
t('当前 Runtime 没有可恢复的修复任务记录(机器人可能刚完成密钥更新并重启)。本命令不会启动新的授权;请查看机器人发送的验证结果,确认上一次任务已结束后再发送 /repair。'),
);
return;
}
if (attempt.actorOpenId !== actorOpenId) {
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。');
await this.#send(chatId, t('另一位管理员正在修复该机器人,本次不会显示其授权信息。'));
return;
}
if (operation === 'cancel') {
@ -843,7 +848,7 @@ export class FeishuHarnessBridge {
snapshot = repairSnapshot(result, { botId: this.#botId });
attempt.snapshot = snapshot;
} catch {
await this.#send(chatId, '暂时无法取消修复任务,请稍后重试。');
await this.#send(chatId, t('暂时无法取消修复任务,请稍后重试。'));
return;
}
if (snapshot.state === 'cancelled') {
@ -858,7 +863,7 @@ export class FeishuHarnessBridge {
try {
snapshot = await this.#refreshRepairAttempt(attempt);
} catch {
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
return;
}
if (operation === 'qr') {
@ -887,7 +892,7 @@ export class FeishuHarnessBridge {
const previous = this.#repairAttempt;
if (previous && REPAIR_ACTIVE_STATES.has(previous.snapshot.state)) {
if (previous.actorOpenId !== actorOpenId) {
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。');
await this.#send(chatId, t('另一位管理员正在修复该机器人,本次不会显示其授权信息。'));
return;
}
try {
@ -897,7 +902,7 @@ export class FeishuHarnessBridge {
return;
}
} catch {
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
return;
}
}
@ -911,7 +916,7 @@ export class FeishuHarnessBridge {
}), { botId: this.#botId });
snapshot = await this.#waitForRepairLink(snapshot, { actorOpenId, chatId });
} catch {
await this.#send(chatId, '修复流程暂时失败,现有机器人连接不受影响;请稍后发送 /repair 重试。');
await this.#send(chatId, t('修复流程暂时失败,现有机器人连接不受影响;请稍后发送 /repair 重试。'));
return;
}
const attempt = {
@ -932,7 +937,7 @@ export class FeishuHarnessBridge {
} catch {
attempt.stopped = true;
await this.#repair.cancel(this.#repairArgs(attempt)).catch(() => undefined);
await this.#send(chatId, '飞书返回了无法安全验证的授权链接,已中止本次修复。');
await this.#send(chatId, t('飞书返回了无法安全验证的授权链接,已中止本次修复。'));
return;
}
}
@ -945,7 +950,7 @@ export class FeishuHarnessBridge {
}
if (!attempt.verificationUrl) {
attempt.stopped = true;
await this.#send(chatId, '飞书未返回授权链接,已中止本次修复。');
await this.#send(chatId, t('飞书未返回授权链接,已中止本次修复。'));
return;
}
await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot);
@ -1000,7 +1005,7 @@ export class FeishuHarnessBridge {
attempt.announcedSaving = true;
await this.#send(
attempt.chatId,
'授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。',
t('授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。'),
);
}
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
@ -1021,7 +1026,7 @@ export class FeishuHarnessBridge {
this.#logger.warn?.('[dsh-feishu] callback repair status monitoring failed');
await this.#send(
attempt.chatId,
'修复状态查询中断,现有机器人连接不受影响;发送 /repair status 重试查询。',
t('修复状态查询中断,现有机器人连接不受影响;发送 /repair status 重试查询。'),
).catch(() => undefined);
});
}
@ -1030,16 +1035,16 @@ export class FeishuHarnessBridge {
const remaining = snapshot.remainingSeconds
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
const expiry = remaining === null
? '链接为短期有效'
: `链接约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟后过期`;
? t('链接为短期有效')
: t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
await this.#send(chatId, [
existing ? '已有一个修复任务在等待授权。' : '🔧 准备修复卡片按钮。',
'本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。',
existing ? t('已有一个修复任务在等待授权。') : t('🔧 准备修复卡片按钮。'),
t('本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。'),
'',
'当前设备直接打开:',
t('当前设备直接打开:'),
url,
'',
`若要用另一台设备扫码,发送 /repair qr。${expiry}。`,
t('若要用另一台设备扫码,发送 /repair qr。{expiry}。', { expiry }),
].join('\n'));
}
@ -1055,9 +1060,12 @@ export class FeishuHarnessBridge {
if (!imageKey) throw new Error('Feishu QR upload returned no image key');
const remaining = snapshot.remainingSeconds
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
const remainingText = remaining === null
? ''
: t('(剩余约 {minutes} 分钟)', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
await this.#send(
chatId,
`请用另一台设备扫码完成授权${remaining === null ? '' : `(剩余约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟)`}。`,
t('请用另一台设备扫码完成授权{remaining}。', { remaining: remainingText }),
);
const response = await this.#client.im.v1.message.create({
params: { receive_id_type: 'chat_id' },
@ -1069,40 +1077,40 @@ export class FeishuHarnessBridge {
});
if (response?.code && response.code !== 0) throw new Error('Feishu QR message send failed');
} catch {
await this.#send(chatId, `二维码暂时无法发送,请直接打开授权链接:\n${url}`);
await this.#send(chatId, t('二维码暂时无法发送,请直接打开授权链接:\n{url}', { url }));
}
}
#repairStatusText(snapshot, { verificationFocused = false } = {}) {
if (snapshot.state === 'succeeded') {
return '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
return t('✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。');
}
if (snapshot.state === 'expired' || snapshot.error?.code === 'expired_token') {
return '授权链接已过期;平台未返回成功结果,无法确认已修复。发送 /repair 生成新链接。';
return t('授权链接已过期;平台未返回成功结果,无法确认已修复。发送 /repair 生成新链接。');
}
if (snapshot.state === 'cancelled' || snapshot.error?.code === 'abort') {
return '已取消本次修复授权,未确认完成修复。';
return t('已取消本次修复授权,未确认完成修复。');
}
if (snapshot.error?.code === 'access_denied') {
return '你已取消或拒绝授权,没有确认修复;发送 /repair 可重试。';
return t('你已取消或拒绝授权,没有确认修复;发送 /repair 可重试。');
}
if (snapshot.error?.code === 'card_action_probe_timeout'
|| snapshot.error?.code === 'card-action-probe-timeout') {
return '授权已提交,但未收到测试按钮回调。可能尚未点击或配置仍在传播;稍后发送 /repair verify 查询,不要盲目重复授权。';
return t('授权已提交,但未收到测试按钮回调。可能尚未点击或配置仍在传播;稍后发送 /repair verify 查询,不要盲目重复授权。');
}
if (snapshot.state === 'error') {
return '修复流程暂时失败,现有机器人连接不受影响;发送 /repair 可重试。';
return t('修复流程暂时失败,现有机器人连接不受影响;发送 /repair 可重试。');
}
if (snapshot.state === 'saving') {
return '授权已确认,正在等待专用测试按钮的真实回调;回调到达前不会宣告成功。';
return t('授权已确认,正在等待专用测试按钮的真实回调;回调到达前不会宣告成功。');
}
if (verificationFocused) {
return '授权尚未完成,暂时不能验证卡片按钮。请先打开授权链接并确认。';
return t('授权尚未完成,暂时不能验证卡片按钮。请先打开授权链接并确认。');
}
const remaining = snapshot.remainingSeconds === null
? ''
: `,剩余约 ${Math.max(1, Math.ceil(snapshot.remainingSeconds / 60))} 分钟`;
return `修复任务正在等待授权${remaining}。发送 /repair qr 可获取二维码,/repair cancel 可取消。`;
: t(',剩余约 {minutes} 分钟', { minutes: Math.max(1, Math.ceil(snapshot.remainingSeconds / 60)) });
return t('修复任务正在等待授权{remaining}。发送 /repair qr 可获取二维码,/repair cancel 可取消。', { remaining });
}
/**
@ -1134,7 +1142,7 @@ export class FeishuHarnessBridge {
// restart) or never came from us: nudge instead of staying silent.
const chatId = nonEmptyString(event?.context?.open_chat_id);
if (chatId) {
this.#send(chatId, '这个菜单已过期,请回复 /m 重新打开。').catch(() => undefined);
this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。')).catch(() => undefined);
}
return Promise.resolve();
}
@ -1161,12 +1169,12 @@ export class FeishuHarnessBridge {
}
if (action === 'new') {
await this.#state.clearSession(key);
await this.#send(chatId, '已开启全新 Harness 会话。');
await this.#send(chatId, t('已开启全新 Harness 会话。'));
return;
}
if (action === 'status') {
await this.#harness.ensureRunning({ signal: this.#signal });
await this.#send(chatId, '飞书机器人与 DeepSeek Harness 连接正常。');
await this.#send(chatId, t('飞书机器人与 DeepSeek Harness 连接正常。'));
return;
}
if (action === 'help') {
@ -1213,7 +1221,7 @@ export class FeishuHarnessBridge {
if (menu.kind === 'menu') {
const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair', 'watchlist'][number - 1];
if (!action) {
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。'));
return;
}
if (action === 'repair') {
@ -1226,7 +1234,7 @@ export class FeishuHarnessBridge {
if (menu.kind === 'sessions') {
const session = menu.sessions[number - 1];
if (!session?.sessionId) {
await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`);
await this.#send(chatId, t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
return;
}
// The number label sits on the session (bind) button of the row.
@ -1236,7 +1244,7 @@ export class FeishuHarnessBridge {
if (menu.kind === 'workspaces') {
const workspace = menu.paths[number - 1];
if (!workspace) {
await this.#send(chatId, `只有 ${menu.paths.length} 个工作区,回复 /workspacelist 重新查看。`);
await this.#send(chatId, t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
return;
}
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
@ -1245,7 +1253,7 @@ export class FeishuHarnessBridge {
if (menu.kind === 'watches') {
const entry = menu.entries[number - 1];
if (!entry?.sessionId) {
await this.#send(chatId, `关注列表只有 ${menu.entries.length} 个会话。`);
await this.#send(chatId, t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
return;
}
await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
@ -1271,7 +1279,7 @@ export class FeishuHarnessBridge {
const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
const workspace = listed?.workspace ?? resolved.workspace;
if (sessions.length === 0) {
await this.#send(chatId, `工作区:${workspace}\n该工作区暂无会话。`);
await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }));
return;
}
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
@ -1296,7 +1304,7 @@ export class FeishuHarnessBridge {
);
} catch (error) {
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。');
await this.#send(chatId, t('暂时无法获取会话列表,请稍后重试。'));
}
}
@ -1307,26 +1315,26 @@ export class FeishuHarnessBridge {
await this.#sendCard(chatId, workspaceListCard(paths, current), { key });
} catch (error) {
this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message);
await this.#send(chatId, '暂时无法获取工作区列表,请稍后重试。');
await this.#send(chatId, t('暂时无法获取工作区列表,请稍后重试。'));
}
}
async #bindSession(key, chatId, sessionId) {
try {
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || '暂无标题';
await this.#send(chatId, `已绑定会话「${title}」\nID:${bound?.sessionId ?? sessionId}`);
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }));
} catch (error) {
await this.#send(chatId, `绑定失败:${safeErrorText(error)}`);
await this.#send(chatId, t('绑定失败:{message}', { message: safeErrorText(error) }));
}
}
async #switchWorkspace(key, chatId, workspace) {
try {
const current = await this.#harness.switchWorkspace(workspace);
await this.#send(chatId, `工作区已切换为:${current}`);
await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
} catch (error) {
await this.#send(chatId, `切换失败:${safeErrorText(error)}`);
await this.#send(chatId, t('切换失败:{message}', { message: safeErrorText(error) }));
}
}
@ -1398,7 +1406,7 @@ export class FeishuHarnessBridge {
*/
async #resolveWatchTarget(target) {
if (typeof target !== 'string' || target === '') {
return { error: '用法:/watch <Session ID 或当前工作区序号>' };
return { error: t('用法:/watch <Session ID 或当前工作区序号>') };
}
const numeric = /^\d{1,4}$/.test(target) ? Number(target) : null;
const currentPath = typeof this.#harness?.currentWorkspace === 'function'
@ -1409,13 +1417,13 @@ export class FeishuHarnessBridge {
return Array.isArray(listed?.sessions) ? listed.sessions : [];
};
if (numeric !== null) {
if (!currentPath) return { error: '当前机器人没有可用的工作区,无法按序号解析会话。' };
if (!currentPath) return { error: t('当前机器人没有可用的工作区,无法按序号解析会话。') };
const sessions = this.#visibleSessions(await listSessions(currentPath));
const session = sessions[numeric - 1];
if (!session?.sessionId) {
return { error: `当前工作区只有 ${sessions.length} 个会话。` };
return { error: t('当前工作区只有 {count} 个会话。', { count: sessions.length }) };
}
return { sessionId: session.sessionId, title: session.title ?? '暂无标题' };
return { sessionId: session.sessionId, title: session.title ?? t('暂无标题') };
}
const extraPaths = typeof this.#harness?.listWorkspaces === 'function'
? (await this.#harness.listWorkspaces()).filter((path) => path !== currentPath)
@ -1424,9 +1432,9 @@ export class FeishuHarnessBridge {
for (const workspace of paths) {
const sessions = await listSessions(workspace);
const session = sessions.find((candidate) => candidate.sessionId === target);
if (session) return { sessionId: target, title: session.title ?? '暂无标题' };
if (session) return { sessionId: target, title: session.title ?? t('暂无标题') };
}
return { error: '没有找到这个会话,请用 /sessionlist 查看可用会话。' };
return { error: t('没有找到这个会话,请用 /sessionlist 查看可用会话。') };
}
async #latestSessionSeq(sessionId) {
@ -1443,14 +1451,14 @@ export class FeishuHarnessBridge {
async #runWatch(key, chatId, target) {
this.#ensureEventWatcher();
if (typeof this.#state?.setWatch !== 'function') {
await this.#send(chatId, '当前状态存储不支持关注。');
await this.#send(chatId, t('当前状态存储不支持关注。'));
return;
}
let resolved;
try {
resolved = await this.#resolveWatchTarget(target);
} catch (error) {
await this.#send(chatId, `无法解析会话:${safeErrorText(error)}`);
await this.#send(chatId, t('无法解析会话:{message}', { message: safeErrorText(error) }));
return;
}
if (resolved.error) {
@ -1460,7 +1468,7 @@ export class FeishuHarnessBridge {
const existing = this.#state.watchEntries?.(key) ?? [];
const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
await this.#send(chatId, `每个聊天最多关注 ${MAX_WATCHES_PER_KEY} 个会话。`);
await this.#send(chatId, t('每个聊天最多关注 {count} 个会话。', { count: MAX_WATCHES_PER_KEY }));
return;
}
try {
@ -1473,10 +1481,10 @@ export class FeishuHarnessBridge {
chatId,
lastSeq,
});
await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`);
await this.#send(chatId, t('已关注会话「{title}」,任务完成会推送结果。', { title: String(resolved.title).replace(/\s+/gu, ' ') }));
await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
} catch (error) {
await this.#send(chatId, `关注失败:${safeErrorText(error)}`);
await this.#send(chatId, t('关注失败:{message}', { message: safeErrorText(error) }));
}
}
@ -1487,15 +1495,15 @@ export class FeishuHarnessBridge {
? entries[Number(target) - 1]
: entries.find((candidate) => candidate.sessionId === target);
if (!entry) {
await this.#send(chatId, '关注列表里没有这个会话,回复 /watchlist 查看。');
await this.#send(chatId, t('关注列表里没有这个会话,回复 /watchlist 查看。'));
return;
}
try {
await this.#state.removeWatch(key, entry.sessionId);
this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`);
await this.#send(chatId, t('已取消关注「{title}」。', { title: String(entry.title ?? '').replace(/\s+/gu, ' ') }));
} catch (error) {
await this.#send(chatId, `取消失败:${safeErrorText(error)}`);
await this.#send(chatId, t('取消失败:{message}', { message: safeErrorText(error) }));
}
}
@ -1877,18 +1885,18 @@ export class FeishuHarnessBridge {
const text = extractText(event);
if (!text) {
await this.#send(event.message.chat_id, '请用文字回答当前问题。');
await this.#send(event.message.chat_id, t('请用文字回答当前问题。'));
return;
}
const pending = this.#pendingInteractions.get(key);
if (!pending || pending !== expected || pending.submitting) {
if (this.#isResolvedQuestionReply(event, key)) {
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
return;
}
if (claimed && (!pending || pending !== expected)) {
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT());
return;
}
return this.#enqueueMessage(event, messageId, key, processingReaction, {
@ -1946,7 +1954,7 @@ export class FeishuHarnessBridge {
if (error?.code === 'interaction-not-pending') {
this.#rememberResolvedInteraction(key, pending);
this.#clearPendingInteraction(key, pending.interactionId);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
return;
}
pending.submitting = false;
@ -1954,7 +1962,7 @@ export class FeishuHarnessBridge {
pending.index -= 1;
this.#status.lastError = '回答提交失败。';
this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
await this.#send(event.message.chat_id, '回答提交失败,请重新发送当前问题的答案。')
await this.#send(event.message.chat_id, t('回答提交失败,请重新发送当前问题的答案。'))
.catch(() => undefined);
}
}
@ -1999,7 +2007,7 @@ export class FeishuHarnessBridge {
});
await this.#send(
chatId,
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
).catch(() => undefined);
return;
}
@ -2101,7 +2109,7 @@ export class FeishuHarnessBridge {
await this.#state.markSeen(messageId);
this.#status.lastMessageAt = new Date().toISOString();
this.#status.messagesReceived += 1;
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
}
#takePendingInteraction(key, interactionId) {
@ -2141,10 +2149,10 @@ export class FeishuHarnessBridge {
#progressText(update) {
if (update.type === 'text' && update.text) return update.text;
if (update.type === 'tool') {
if (update.name === 'web_search') return '_正在搜索网络并整理信息…_';
return `_正在使用 ${update.name || '工具'}…_`;
if (update.name === 'web_search') return t('_正在搜索网络并整理信息…_');
return t('_正在使用 {name}…_', { name: update.name || t('工具') });
}
return `_${update.text || '正在处理…'}_`;
return t('_{text}_', { text: update.text || t('正在处理…') });
}
async #addReaction(messageId, emojiType) {

View file

@ -14,6 +14,8 @@
* without button callbacks.
*/
import { t } from '../shared/i18n.mjs';
export const MENU_PAGE_SIZE = 10;
function plainText(content) {
@ -56,7 +58,7 @@ function buttonElement(content, actionValue) {
function safeTitle(value) {
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
return title || '暂无标题';
return title || t('暂无标题');
}
function cardWith(headerText, elements) {
@ -69,18 +71,18 @@ function cardWith(headerText, elements) {
/** The main command menu (buttons + number-reply fallback). */
export function menuCard() {
return cardWith('🤖 助手菜单', [
{ tag: 'div', text: markdown('**点击按钮或直接回复数字**') },
button('1 · 会话列表', 'sessions'),
button('2 · 工作区', 'workspaces'),
button('3 · 新会话', 'new'),
button('4 · 状态', 'status'),
button('5 · 帮助', 'help'),
return cardWith(t('🤖 助手菜单'), [
{ tag: 'div', text: markdown(t('**点击按钮或直接回复数字**')) },
button(t('1 · 会话列表'), 'sessions'),
button(t('2 · 工作区'), 'workspaces'),
button(t('3 · 新会话'), 'new'),
button(t('4 · 状态'), 'status'),
button(t('5 · 帮助'), 'help'),
// Repair must remain number-driven. Apps that need this command do not
// have card.action.trigger yet, so rendering it as a callback button would
// send the user straight back to Feishu's broken callback setup popup.
{ tag: 'div', text: markdown('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
button('7 · 关注列表', 'watchlist'),
{ tag: 'div', text: markdown(t('**6 · 修复卡片按钮**(请直接回复数字 **6**)')) },
button(t('7 · 关注列表'), 'watchlist'),
]);
}
@ -89,10 +91,10 @@ export function cardActionProbeCard(nonce) {
if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
throw new TypeError('A safe card-action probe nonce is required');
}
return cardWith('🧪 验证卡片按钮', [
return cardWith(t('🧪 验证卡片按钮'), [
{
tag: 'div',
text: markdown('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。'),
text: markdown(t('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。')),
},
{
tag: 'column_set',
@ -103,7 +105,7 @@ export function cardActionProbeCard(nonce) {
weight: 1,
elements: [{
tag: 'button',
text: plainText('完成验证'),
text: plainText(t('完成验证')),
type: 'primary',
width: 'fill',
behaviors: [{
@ -140,74 +142,80 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
],
});
const elements = [
{ tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
{ tag: 'div', text: markdown(t('**工作区**:{workspace}\n共 **{total}** 个会话{paging}', {
workspace: `\`${workspace}\``,
total,
paging: total > MENU_PAGE_SIZE
? t('(第 {page}/{pageCount} 页)', { page: page + 1, pageCount })
: '',
})) },
...slice.map((session, offset) => {
// Page-local numbering: number replies resolve against this page.
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? t('(已归档)') : ''}`;
const watching = watched(session.sessionId);
return row(
buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
buttonElement(watching ? t('⭐取关') : t('⭐关注'), watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
buttonElement(label, `use:${session.sessionId}`),
);
}),
];
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
return cardWith('📂 会话列表', elements);
if (page > 0) elements.push(button(t('◀ 上一页'), `sessions:${page - 1}`));
if (page + 1 < pageCount) elements.push(button(t('下一页 ▶'), `sessions:${page + 1}`));
elements.push({ tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) });
return cardWith(t('📂 会话列表'), elements);
}
/** The workspace list card (switch-workspace buttons + reply fallback). */
export function workspaceListCard(paths, current) {
const elements = paths.length === 0
? [{ tag: 'div', text: markdown('当前 Host 上没有已登记的工作区。') }]
? [{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) }]
: [
{ tag: 'div', text: markdown(`回复数字切换工作区,或点击按钮:`) },
{ tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
...paths.map((path, index) => button(
`${index + 1}. ${path}${path === current ? '(当前)' : ''}`,
`${index + 1}. ${path}${path === current ? t('(当前)') : ''}`,
`workspace:${path}`,
)),
];
return cardWith('🗂 工作区', elements);
return cardWith(t('🗂 工作区'), elements);
}
/** The card-menu help text (number-driven, no command memorization). */
export function menuHelpText() {
return [
'🤖 助手菜单(回复数字即可,无需记命令)',
t('🤖 助手菜单(回复数字即可,无需记命令)'),
'',
'1 · /sessionlist 列出会话(回复数字绑定)',
'2 · /workspacelist 列出工作区(回复数字切换)',
'3 · /new 开启新会话',
'4 · /status 连接状态',
'5 · /help 本帮助',
'6 · /repair 修复卡片按钮(请回复数字 6)',
'7 · /watchlist 关注列表',
t('1 · /sessionlist 列出会话(回复数字绑定)'),
t('2 · /workspacelist 列出工作区(回复数字切换)'),
t('3 · /new 开启新会话'),
t('4 · /status 连接状态'),
t('5 · /help 本帮助'),
t('6 · /repair 修复卡片按钮(请回复数字 6)'),
t('7 · /watchlist 关注列表'),
'',
'直接发送文字/图片即继续当前会话。',
'/session ID 或序号 绑定已有会话',
'/watch ID 或序号 关注会话(完成后推送)',
'/compact 压缩上下文',
'/workspace 绝对路径 切换工作区',
'/presetlist 列出可用 Agent Preset',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
'纯数字 ID:/preset id:<ID>',
'/preset --default 跟随 Host 默认',
t('直接发送文字/图片即继续当前会话。'),
t('/session ID 或序号 绑定已有会话'),
t('/watch ID 或序号 关注会话(完成后推送)'),
t('/compact 压缩上下文'),
t('/workspace 绝对路径 切换工作区'),
t('/presetlist 列出可用 Agent Preset'),
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
t('纯数字 ID:/preset id:<ID>'),
t('/preset --default 跟随 Host 默认'),
].join('\n');
}
/** The watch list for one conversation (unwatch buttons + reply fallback). */
export function watchListCard(entries) {
const elements = entries.length === 0
? [{ tag: 'div', text: markdown('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。') }]
? [{ tag: 'div', text: markdown(t('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。')) }]
: [
{ tag: 'div', text: markdown('任务完成会自动推送,回复数字或点按钮取消关注:') },
{ tag: 'div', text: markdown(t('任务完成会自动推送,回复数字或点按钮取消关注:')) },
...entries.map((entry, index) => button(
`${index + 1}. ${safeTitle(entry.title)}`,
`unwatch:${entry.sessionId}`,
)),
];
return cardWith('👁 关注列表', elements);
return cardWith(t('👁 关注列表'), elements);
}
/**
@ -216,19 +224,19 @@ export function watchListCard(entries) {
*/
export function completionCard(sessionId, title, reason) {
const reasonText = reason === 'completed'
? '已完成'
? t('已完成')
: reason === 'stopped'
? '已停止'
? t('已停止')
: reason === 'aborted'
? '已中止'
? t('已中止')
: reason === 'cancelled'
? '已取消'
: '已结束';
return cardWith('✅ 任务完成', [
? t('已取消')
: t('已结束');
return cardWith(t('✅ 任务完成'), [
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
{ tag: 'div', text: markdown(`**状态**:${reasonText}`) },
button('打开会话列表', 'sessions'),
button('工作区', 'workspaces'),
{ tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') },
{ tag: 'div', text: markdown(t('**状态**:{reason}', { reason: reasonText })) },
button(t('打开会话列表'), 'sessions'),
button(t('工作区'), 'workspaces'),
{ tag: 'div', text: markdown(t('绑定该会话后可继续追问,输入文字即可。')) },
]);
}

View file

@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { createDeliveryReceipt } from '../shared/semantic/delivery.mjs';
import { t } from '../shared/i18n.mjs';
const STREAM_ELEMENT_ID = 'stream_md';
const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
@ -133,7 +134,7 @@ function streamingCard(initialText) {
schema: '2.0',
config: {
streaming_mode: true,
summary: { content: '正在生成…' },
summary: { content: t('正在生成…') },
streaming_config: {
print_frequency_ms: { default: 70 },
print_step: { default: 1 },
@ -158,12 +159,12 @@ export class VerifiedFeishuChannel {
constructor({
client,
initialText = DEFAULT_INITIAL_TEXT,
initialText,
fileUploadTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
}) {
this.#client = client;
this.#initialText = initialText;
this.#initialText = initialText ?? t(DEFAULT_INITIAL_TEXT);
this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
}
@ -215,7 +216,7 @@ export class VerifiedFeishuChannel {
settings: JSON.stringify({
config: {
streaming_mode: false,
summary: { content: summaryOf(lastContent) || '回答完成' },
summary: { content: summaryOf(lastContent) || t('回答完成') },
},
}),
sequence: ++sequence,

View file

@ -7,6 +7,7 @@ import {
connectionTestTargetUnavailable,
sendRememberedConnectionTest,
} from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
@ -206,7 +207,7 @@ export class FeishuRuntime {
this.#client = new this.#lark.Client(larkConfig);
const channel = new VerifiedFeishuChannel({
client: this.#client,
initialText: '已连接 DeepSeek Harness,正在思考…',
initialText: t('已连接 DeepSeek Harness,正在思考…'),
});
this.#bridge = new FeishuHarnessBridge({
client: this.#client,
@ -340,7 +341,7 @@ export class FeishuRuntime {
} catch {
void this.#sendCardActionProbeNotice(
operatorOpenId,
CALLBACK_PROBE_SEND_FAILURE_NOTICE,
t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
'failure',
);
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
@ -348,7 +349,7 @@ export class FeishuRuntime {
if (response?.code && response.code !== 0) {
void this.#sendCardActionProbeNotice(
operatorOpenId,
CALLBACK_PROBE_SEND_FAILURE_NOTICE,
t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
'failure',
);
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
@ -358,7 +359,7 @@ export class FeishuRuntime {
if (!messageId) {
void this.#sendCardActionProbeNotice(
operatorOpenId,
CALLBACK_PROBE_SEND_FAILURE_NOTICE,
t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
'failure',
);
throw probeError('card_action_probe_send_failed', '飞书未返回测试卡片的消息 ID');
@ -371,7 +372,7 @@ export class FeishuRuntime {
this.#pendingCardActionProbes.delete(messageId);
void this.#sendCardActionProbeNotice(
operatorOpenId,
CALLBACK_PROBE_TIMEOUT_NOTICE,
t(CALLBACK_PROBE_TIMEOUT_NOTICE),
'timeout',
);
reject(probeError(
@ -413,7 +414,7 @@ export class FeishuRuntime {
// the callback proof itself.
void this.#sendCardActionProbeNotice(
operatorOpenId,
CALLBACK_PROBE_SUCCESS_NOTICE,
t(CALLBACK_PROBE_SUCCESS_NOTICE),
'success',
).finally(() => {
probe.resolve({
@ -482,10 +483,10 @@ export class FeishuRuntime {
return sendRememberedConnectionTest({
state: this.#state,
text,
channelLabel: '飞书机器人',
channelLabel: t('飞书机器人'),
send: async (target, content) => {
const chatId = typeof target?.chatId === 'string' ? target.chatId.trim() : '';
if (!chatId) throw connectionTestTargetUnavailable('飞书机器人');
if (!chatId) throw connectionTestTargetUnavailable(t('飞书机器人'));
await send('chat_id', chatId, content);
},
});
@ -500,7 +501,7 @@ export class FeishuRuntime {
clearTimeout(probe.timeout);
void this.#sendCardActionProbeNotice(
probe.expectedOperatorOpenId,
CALLBACK_PROBE_ABORT_NOTICE,
t(CALLBACK_PROBE_ABORT_NOTICE),
'abort',
);
probe.reject(probeError('abort', '飞书运行时已停止'));

View file

@ -1,4 +1,5 @@
import { ImagePromptError } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
@ -113,7 +114,7 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
throw new ImagePromptError(
'image-too-large',
`Feishu image exceeds ${maxBytes} bytes`,
'图片超过 5 MB,请压缩后重试。',
t('图片超过 5 MB,请压缩后重试。'),
);
}
chunks.push(data);
@ -221,7 +222,7 @@ async function feishuImageDownloadError(error, signal) {
return new ImagePromptError(
'feishu-image-permission-required',
'Feishu image download requires the im:message:readonly tenant scope',
FEISHU_IMAGE_PERMISSION_MESSAGE,
t(FEISHU_IMAGE_PERMISSION_MESSAGE),
{ cause: error },
);
}
@ -249,7 +250,7 @@ function feishuImageSource(event, client, key) {
throw new ImagePromptError(
'image-too-large',
`Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
'图片超过 5 MB,请压缩后重试。',
t('图片超过 5 MB,请压缩后重试。'),
);
}
return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });