merge: integrate upstream v2.0.0 (files, images, i18n) into feishu-card-system

- Resolve conflicts in feishu-cards.mjs / bridge.mjs / control-command.mjs / lib
- Keep card-system enhancements: menuCard/settingsCard/presetCard/modelCard, /m newCard, card form submit, model select
- Adopt upstream i18n t() wrapping + file/image delivery + inbound files
- Add missing English dictionary entries for new feishu card strings
- Rebuild lib (package now 2.0.0)
This commit is contained in:
C3H3-AI 2026-08-24 07:22:50 +08:00
commit 044ab74c28
167 changed files with 22497 additions and 1793 deletions

View file

@ -12,6 +12,10 @@ import {
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
} from '../shared/inbound-file.mjs';
import {
harnessAnswerForQuestion,
harnessQuestionText,
@ -34,6 +38,10 @@ import {
} from '../shared/preset-command.mjs';
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
createDeliveryReceipt,
} from '../shared/semantic/delivery.mjs';
import {
MENU_PAGE_SIZE,
PRESET_FOLLOW_DEFAULT_SENTINEL,
@ -52,13 +60,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;
@ -103,18 +113,45 @@ const WORKSPACE_HELP_LINES = [
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 ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
switch (error?.code) {
case 'artifact-permission-required':
return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。', { name });
case 'artifact-too-large':
return t('结果文件「{name}」超过飞书 30 MB 上限,未发送。', { name });
case 'artifact-empty':
return t('结果文件「{name}」为空,飞书不允许发送空文件。', { name });
case 'artifact-changed':
case 'artifact-invalid':
case 'artifact-unavailable':
return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
case 'artifact-rate-limited':
return t('结果文件「{name}」暂时被飞书限流,未能发送,请稍后重试。', { name });
case 'artifact-delivery-uncertain':
return t('结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
default:
return t('结果文件「{name}」已生成,但暂时未能发送,请稍后重试。', { name });
}
}
function answerTextForDelivery(answer, artifacts) {
if (typeof answer === 'string' && answer.trim()) return answer;
return artifacts.length > 0 ? t('结果文件已生成。') : answer;
}
function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
@ -403,7 +440,7 @@ export class FeishuHarnessBridge {
.finally(() => this.#acceptedMessageIds.delete(messageId));
return processing;
}
const commandRunner = isControlCommand(commandText)
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -548,7 +585,10 @@ export class FeishuHarnessBridge {
.then(() => this.#handle(event, key, { alreadyRecorded }));
const settled = finalize
? work
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
.then(async (receipt) => {
await this.#finishReaction(messageId, processingReaction, 'DONE');
return receipt;
})
.catch((error) => this.#handleMessageFailure(
event,
messageId,
@ -579,8 +619,9 @@ export class FeishuHarnessBridge {
await this.#finishReaction(messageId, processingReaction, 'ERROR');
await this.#send(
event.message.chat_id,
imagePromptUserMessage(error)
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? t('处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。'),
).catch(() => undefined);
}
@ -610,6 +651,7 @@ export class FeishuHarnessBridge {
{
signal: this.#signal,
hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
@ -640,9 +682,10 @@ export class FeishuHarnessBridge {
const message = extractInboundMessage(event, this.#client);
const text = message.content;
const hasImages = hasInboundImages(message);
const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
if (!text && !hasImages) {
await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
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, t('目前支持文字、图片和文件消息。'));
return;
}
@ -660,7 +703,7 @@ 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 会话。'));
await this.#sendMenuCard(key, event.message.chat_id);
return;
}
@ -702,7 +745,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') {
@ -710,7 +753,7 @@ export class FeishuHarnessBridge {
}
await this.#send(
event.message.chat_id,
value === 'on' ? '已开启:会话列表包含归档会话。' : '已关闭:会话列表隐藏归档会话。',
value === 'on' ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
);
return;
}
@ -750,10 +793,11 @@ export class FeishuHarnessBridge {
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
try {
await this.#answerWithStream(event, key, message);
const receipt = await this.#answerWithStream(event, key, message);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
return receipt;
} finally {
await this.#cancelPendingInteraction(key);
await this.#approvals.closeRoute(key);
@ -766,7 +810,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);
@ -774,19 +818,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';
@ -800,12 +844,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') {
@ -815,7 +859,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') {
@ -830,7 +874,7 @@ export class FeishuHarnessBridge {
try {
snapshot = await this.#refreshRepairAttempt(attempt);
} catch {
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
return;
}
if (operation === 'qr') {
@ -859,7 +903,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 {
@ -869,7 +913,7 @@ export class FeishuHarnessBridge {
return;
}
} catch {
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
return;
}
}
@ -883,7 +927,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 = {
@ -904,7 +948,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;
}
}
@ -917,7 +961,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);
@ -972,7 +1016,7 @@ export class FeishuHarnessBridge {
attempt.announcedSaving = true;
await this.#send(
attempt.chatId,
'授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。',
t('授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。'),
);
}
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
@ -993,7 +1037,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);
});
}
@ -1002,16 +1046,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'));
}
@ -1027,9 +1071,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' },
@ -1041,40 +1088,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 });
}
/**
@ -1131,7 +1178,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();
}
@ -1211,7 +1258,7 @@ 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 === 'use:current') {
@ -1342,7 +1389,7 @@ export class FeishuHarnessBridge {
const actions = ['use:current', 'new', 'sessions', 'status', 'repair', 'settings', 'help'];
const action = actions[number - 1];
if (!action) {
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。'));
return;
}
if (action === 'repair') {
@ -1355,7 +1402,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.
@ -1365,7 +1412,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 });
@ -1374,7 +1421,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 });
@ -1400,7 +1447,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);
@ -1425,7 +1472,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('暂时无法获取会话列表,请稍后重试。'));
}
}
@ -1436,28 +1483,28 @@ 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 }));
await this.#sendMenuCard(key, chatId);
} 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 }));
await this.#sendMenuCard(key, chatId);
} catch (error) {
await this.#send(chatId, `切换失败:${safeErrorText(error)}`);
await this.#send(chatId, t('切换失败:{message}', { message: safeErrorText(error) }));
}
}
@ -1930,7 +1977,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'
@ -1941,13 +1988,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)
@ -1956,9 +2003,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) {
@ -1975,14 +2022,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) {
@ -1992,7 +2039,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 {
@ -2005,10 +2052,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) }));
}
}
@ -2019,15 +2066,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) }));
}
}
@ -2149,7 +2196,7 @@ export class FeishuHarnessBridge {
}
}
#interactionAskOptions(event, key) {
#interactionAskOptions(event, key, files) {
return {
timeoutMs: this.#replyTimeoutMs,
signal: this.#signal,
@ -2161,6 +2208,68 @@ export class FeishuHarnessBridge {
requiresMention: event.message.chat_type !== 'p2p',
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files,
};
}
async #sendAnswerText(chatId, answer, { deliveryId, presentation }) {
const providerMessageIds = [];
for (const chunk of splitText(answer)) {
this.#signal?.throwIfAborted();
const messageId = await this.#send(chatId, chunk);
if (messageId) providerMessageIds.push(messageId);
}
return createDeliveryReceipt({
deliveryId,
presentation,
providerMessageIds,
});
}
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt) {
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
aggregatePresentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
channelKey: 'feishu',
signal: this.#signal,
sendImage: typeof this.#channel?.sendImage === 'function'
? (file) => this.#channel.sendImage(chatId, file, {
replyTo,
signal: this.#signal,
})
: undefined,
sendFile: typeof this.#channel?.sendFile === 'function'
? (file) => this.#channel.sendFile(chatId, file, {
replyTo,
signal: this.#signal,
})
: undefined,
sendFailureNotice: async (artifact, error) => ({
messageId: await this.#send(
chatId,
artifactFailureText(artifact?.fileName, error),
),
}),
logger: this.#logger,
});
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
if (!delivery.receipt) {
return {
receipt: createDeliveryReceipt({
deliveryId: replyTo,
presentation: 'feishu-files',
}),
failureNoticeVisible: delivery.failureNoticeVisible,
};
}
return {
receipt: delivery.receipt,
failureNoticeVisible: delivery.failureNoticeVisible,
};
}
@ -2172,7 +2281,7 @@ export class FeishuHarnessBridge {
? await promptContentForMessage(message, { signal: this.#signal })
: undefined;
if (!this.#channel?.stream) {
const { answer } = await askInWorkspaceSession({
const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
@ -2180,27 +2289,53 @@ export class FeishuHarnessBridge {
content,
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: this.#interactionAskOptions(event, key),
askOptions: this.#interactionAskOptions(event, key, message.files),
});
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
let textReceipt;
let textSendError = null;
try {
textReceipt = await this.#sendAnswerText(
chatId,
answerTextForDelivery(answer, artifacts),
{
deliveryId: messageId,
presentation: 'feishu-text',
},
);
} catch (error) {
textSendError = error;
this.#logger.warn?.(
'[dsh-feishu] final text delivery failed; continuing with result files:',
error,
);
}
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
const artifactDispatched = delivery.receipt.artifacts.some(
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
);
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return;
return delivery.receipt;
}
let promptStarted = false;
let completedAnswer = '';
let completedArtifacts = [];
let stream;
try {
await this.#channel.stream(chatId, {
stream = await this.#channel.stream(chatId, {
markdown: async (controller) => {
promptStarted = true;
const askOptions = {
...this.#interactionAskOptions(event, key),
...this.#interactionAskOptions(event, key, message.files),
onUpdate: async (update) => {
await controller.setContent(this.#progressText(update));
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
},
};
({ answer: completedAnswer } = await askInWorkspaceSession({
const completed = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
@ -2209,26 +2344,56 @@ export class FeishuHarnessBridge {
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions,
}));
await controller.setContent(completedAnswer);
});
completedAnswer = completed.answer;
completedArtifacts = completed.artifacts ?? [];
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
},
}, { replyTo: messageId });
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
} catch (error) {
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
if (completedAnswer) {
if (completedAnswer || completedArtifacts.length > 0) {
this.#logger.warn?.(
'[dsh-feishu] native stream failed after generation; sending final text:',
error.message,
);
for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk);
let textReceipt;
let textSendError = null;
try {
textReceipt = await this.#sendAnswerText(
chatId,
answerTextForDelivery(completedAnswer, completedArtifacts),
{
deliveryId: messageId,
presentation: 'feishu-text-fallback',
},
);
} catch (fallbackError) {
textSendError = fallbackError;
this.#logger.warn?.(
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
fallbackError,
);
}
const delivery = await this.#deliverArtifacts(
chatId,
messageId,
completedArtifacts,
textReceipt,
);
const artifactDispatched = delivery.receipt.artifacts.some(
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
);
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return;
return delivery.receipt;
}
if (promptStarted) throw error;
this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
const { answer } = await askInWorkspaceSession({
const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,
key,
@ -2236,11 +2401,48 @@ export class FeishuHarnessBridge {
content,
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: this.#interactionAskOptions(event, key),
askOptions: this.#interactionAskOptions(event, key, message.files),
});
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
let textReceipt;
let textSendError = null;
try {
textReceipt = await this.#sendAnswerText(
chatId,
answerTextForDelivery(answer, artifacts),
{
deliveryId: messageId,
presentation: 'feishu-text-fallback',
},
);
} catch (fallbackError) {
textSendError = fallbackError;
this.#logger.warn?.(
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
fallbackError,
);
}
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
const artifactDispatched = delivery.receipt.artifacts.some(
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
);
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return delivery.receipt;
}
const delivery = await this.#deliverArtifacts(
chatId,
messageId,
completedArtifacts,
createDeliveryReceipt({
deliveryId: messageId,
presentation: 'feishu-cardkit',
providerMessageIds: stream?.messageId ? [stream.messageId] : [],
}),
);
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
return delivery.receipt;
}
async #processInteractionReply(event, messageId, key, expected, processingReaction) {
@ -2266,18 +2468,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, {
@ -2335,7 +2537,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;
@ -2343,7 +2545,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);
}
}
@ -2388,7 +2590,7 @@ export class FeishuHarnessBridge {
});
await this.#send(
chatId,
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
).catch(() => undefined);
return;
}
@ -2490,7 +2692,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) {
@ -2530,10 +2732,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;
/** 预设下拉中「跟随默认」(null) 的哨兵值,供 initial_index 命中与回调识别。 */
@ -87,7 +89,7 @@ function initialIndex(options, currentValue) {
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) {
@ -595,10 +597,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',
@ -609,7 +611,7 @@ export function cardActionProbeCard(nonce) {
weight: 1,
elements: [{
tag: 'button',
text: plainText('完成验证'),
text: plainText(t('完成验证')),
type: 'primary',
width: 'fill',
behaviors: [{
@ -648,23 +650,29 @@ 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}`));
if (page + 1 < pageCount) elements.push(button(t('下一页 ▶'), `sessions:${page + 1}`));
elements.push(
{ tag: 'hr' },
buttonPair('🔙 返回菜单', 'back_to_menu', '🔍 关注列表', 'watchlist'),
{ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') },
buttonPair('🔙 返回菜单', 'back_to_menu', t('🔍 关注列表'), 'watchlist'),
{ tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) },
);
return cardWith('📂 会话列表', elements);
}
@ -675,19 +683,19 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
export function workspaceListCard(paths, current) {
const elements = paths.length === 0
? [
{ tag: 'div', text: markdown('当前 Host 上没有已登记的工作区。') },
{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) },
backButton(),
]
: [
{ 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}`,
)),
{ tag: 'hr' },
backButton(),
];
return cardWith('🗂 工作区', elements);
return cardWith(t('🗂 工作区'), elements);
}
// ── Watch list card (multi-select add/remove + back button) ──────────────
@ -706,7 +714,7 @@ function watchAddSelect(entries, availableSessions) {
return {
tag: 'multi_select_static',
name: 'watch_add',
placeholder: { tag: 'plain_text', content: '勾选要关注的会话' },
placeholder: { tag: 'plain_text', content: t('勾选要关注的会话') },
options,
behaviors: [{ type: 'callback', value: { action: 'watch_add', kind: 'multi' } }],
};
@ -722,7 +730,7 @@ function watchRemoveSelect(entries) {
return {
tag: 'multi_select_static',
name: 'watch_remove',
placeholder: { tag: 'plain_text', content: '勾选要取消关注的会话' },
placeholder: { tag: 'plain_text', content: t('勾选要取消关注的会话') },
options,
behaviors: [{ type: 'callback', value: { action: 'watch_remove', kind: 'multi' } }],
};
@ -736,19 +744,19 @@ export function watchListCard(entries, availableSessions) {
const elements = [];
if (watching.length === 0) {
elements.push({ tag: 'div', text: markdown('当前没有关注的会话。任务完成会自动推送结果。') });
elements.push({ tag: 'div', text: markdown(t('当前没有关注的会话。任务完成会自动推送结果。')) });
} else {
elements.push({ tag: 'div', text: markdown(`当前关注 **${watching.length}** 个会话:`) });
elements.push({ tag: 'div', text: markdown(t('当前关注 **{count}** 个会话:', { count: watching.length })) });
}
if (addSelect) {
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown('**➕ 添加关注**(多选下拉勾选)') }, addSelect);
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➕ 添加关注**(多选下拉勾选)')) }, addSelect);
}
if (removeSelect) {
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown('**➖ 取消关注**(多选下拉勾选)') }, removeSelect);
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➖ 取消关注**(多选下拉勾选)')) }, removeSelect);
}
elements.push({ tag: 'hr' }, buttonPair('📋 会话列表', 'sessions', '🔙 返回菜单', 'back_to_menu'));
return cardWith('👁 关注列表', elements);
elements.push({ tag: 'hr' }, buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'));
return cardWith(t('👁 关注列表'), elements);
}
// ── Completion push card ──────────────────────────────────────────────────
@ -759,20 +767,20 @@ export function watchListCard(entries, availableSessions) {
*/
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}`) },
{ tag: 'div', text: markdown(t('**状态**:{reason}', { reason: reasonText })) },
{ tag: 'hr' },
buttonPair('📋 会话列表', 'sessions', '🔙 返回菜单', 'back_to_menu'),
{ tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') },
buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'),
{ tag: 'div', text: markdown(t('绑定该会话后可继续追问,输入文字即可。')) },
]);
}

View file

@ -1,6 +1,23 @@
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,正在思考…';
const MAX_STREAM_CHARS = 28000;
const MAX_FILE_OPERATION_TIMEOUT_MS = 120_000;
const FILE_DELIVERY_ERRORS = new Map([
[99991672, ['artifact-permission-required', 'Feishu file delivery requires the im:resource permission.']],
[234006, ['artifact-too-large', 'The result file exceeds Feishu\'s size limit.']],
[234010, ['artifact-empty', 'Feishu does not accept empty files.']],
[230017, ['artifact-provider-rejected', 'Feishu rejected the uploaded file ownership.']],
[230020, ['artifact-rate-limited', 'Feishu temporarily rate-limited file delivery.']],
[230049, ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']],
[230055, ['artifact-provider-rejected', 'Feishu rejected the file message type.']],
]);
function assertApiSuccess(operation, response) {
if (response?.code && response.code !== 0) {
@ -9,6 +26,104 @@ function assertApiSuccess(operation, response) {
return response;
}
function providerErrorCode(cause) {
const pending = [cause];
const seen = new Set();
let fallback;
while (pending.length > 0) {
const value = pending.shift();
if (!value || seen.has(value)) continue;
if (typeof value === 'object') seen.add(value);
if (Array.isArray(value)) {
pending.push(...value);
continue;
}
const code = Number(value?.code);
if (Number.isFinite(code) && code !== 0) {
if (FILE_DELIVERY_ERRORS.has(code)) return code;
fallback ??= code;
}
pending.push(value?.response?.data, value?.data, value?.error, value?.cause);
}
return fallback;
}
function fileDeliveryError(stage, cause, providerCode, { uncertain = false } = {}) {
const explicitCode = providerCode === undefined || providerCode === null
? undefined
: Number(providerCode);
const code = Number.isFinite(explicitCode) && explicitCode !== 0
? explicitCode
: providerErrorCode(cause);
const fallback = Number.isFinite(code)
? ['artifact-provider-rejected', `Feishu rejected file ${stage}.`]
: uncertain
? ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']
: ['artifact-provider-failed', `Feishu file ${stage} failed.`];
const [errorCode, message] = FILE_DELIVERY_ERRORS.get(code) ?? fallback;
const error = new Error(message, { cause });
error.code = errorCode;
if (Number.isFinite(code)) error.providerCode = code;
return error;
}
function boundedFileTimeout(value, name) {
if (!Number.isInteger(value) || value < 1 || value > MAX_FILE_OPERATION_TIMEOUT_MS) {
throw new TypeError(`${name} must be an integer between 1 and ${MAX_FILE_OPERATION_TIMEOUT_MS}`);
}
return value;
}
function abortReason(signal) {
return signal?.reason ?? new DOMException('The operation was aborted', 'AbortError');
}
function operationTimeout(stage) {
const error = new Error(`Feishu file ${stage} timed out.`);
error.code = 'provider-timeout';
return error;
}
function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
signal?.throwIfAborted();
const deadline = new AbortController();
const operationSignal = signal
? AbortSignal.any([signal, deadline.signal])
: deadline.signal;
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
operationSignal.removeEventListener('abort', onAbort);
callback(value);
};
const onAbort = () => finish(
reject,
signal?.aborted ? abortReason(signal) : operationTimeout(stage),
);
const timer = setTimeout(() => deadline.abort(), timeoutMs);
operationSignal.addEventListener('abort', onAbort, { once: true });
Promise.resolve().then(() => operation(operationSignal)).then(
(value) => finish(resolve, value),
(error) => finish(reject, error),
);
if (operationSignal.aborted) onAbort();
});
}
function deliveryUuid(file, chatId, messageType) {
const seed = `${file.deliveryKey}\u0000${chatId}`;
const digest = createHash('sha256')
.update(messageType === 'file' ? seed : `${seed}\u0000${messageType}`)
.digest('hex')
.slice(0, 40);
return `dshim_${digest}`;
}
function summaryOf(text) {
const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
@ -19,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 },
@ -39,10 +154,19 @@ function streamingCard(initialText) {
export class VerifiedFeishuChannel {
#client;
#initialText;
#fileUploadTimeoutMs;
#fileMessageTimeoutMs;
constructor({ client, initialText = DEFAULT_INITIAL_TEXT }) {
constructor({
client,
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');
}
async stream(chatId, input, options = {}) {
@ -92,7 +216,7 @@ export class VerifiedFeishuChannel {
settings: JSON.stringify({
config: {
streaming_mode: false,
summary: { content: summaryOf(lastContent) || '回答完成' },
summary: { content: summaryOf(lastContent) || t('回答完成') },
},
}),
sequence: ++sequence,
@ -107,6 +231,144 @@ export class VerifiedFeishuChannel {
}
}
async sendFile(chatId, file, { replyTo, signal } = {}) {
return this.#sendArtifact(chatId, file, {
replyTo,
signal,
messageType: 'file',
presentation: 'feishu-file',
});
}
async sendImage(chatId, file, { replyTo, signal } = {}) {
return this.#sendArtifact(chatId, file, {
replyTo,
signal,
messageType: 'image',
presentation: 'feishu-image',
});
}
async #sendArtifact(chatId, file, {
replyTo,
signal,
messageType,
presentation,
}) {
signal?.throwIfAborted();
if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
if (!file || typeof file !== 'object'
|| typeof file.artifactId !== 'string' || !file.artifactId
|| typeof file.deliveryKey !== 'string' || !file.deliveryKey
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('A materialized result file is required');
}
let uploaded;
try {
uploaded = await waitForFileOperation((operationSignal) => {
operationSignal.throwIfAborted();
const pending = messageType === 'image'
? this.#client.im.v1.image.create({
data: {
image_type: 'message',
image: file.bytes,
},
})
: this.#client.im.v1.file.create({
data: {
file_type: 'stream',
file_name: file.fileName,
file: file.bytes,
},
});
trackOutboundArtifactProviderPromise(file, pending);
return pending;
}, {
signal,
timeoutMs: this.#fileUploadTimeoutMs,
stage: 'upload',
});
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw fileDeliveryError('upload', error);
}
signal?.throwIfAborted();
const resourceKey = messageType === 'image'
? uploaded?.image_key ?? uploaded?.data?.image_key
: uploaded?.file_key ?? uploaded?.data?.file_key;
if (typeof resourceKey !== 'string' || !resourceKey) {
throw fileDeliveryError('upload', undefined, uploaded?.code);
}
const uuid = deliveryUuid(file, chatId, messageType);
const content = JSON.stringify(messageType === 'image'
? { image_key: resourceKey }
: { file_key: resourceKey });
const request = replyTo
? {
path: { message_id: replyTo },
data: { msg_type: messageType, content, uuid },
}
: {
params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, msg_type: messageType, content, uuid },
};
const send = () => {
const pending = replyTo
? this.#client.im.v1.message.reply(request)
: this.#client.im.v1.message.create(request);
trackOutboundArtifactProviderPromise(file, pending);
return pending;
};
let response;
try {
response = await waitForFileOperation(async (operationSignal) => {
operationSignal.throwIfAborted();
let result;
try {
result = await send();
} catch (error) {
if (providerErrorCode(error) !== 230049) throw error;
result = { code: 230049 };
}
operationSignal.throwIfAborted();
// Feishu documents 230049 as an uncertain asynchronous send result.
// Reuse the same resource key and UUID once so the provider can deduplicate.
if (Number(result?.code) === 230049) {
result = await send();
operationSignal.throwIfAborted();
}
return result;
}, {
signal,
timeoutMs: this.#fileMessageTimeoutMs,
stage: 'message send',
});
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw fileDeliveryError('message send', error, undefined, { uncertain: true });
}
if (Number.isFinite(Number(response?.code)) && Number(response.code) !== 0) {
throw fileDeliveryError('message send', undefined, response.code, { uncertain: true });
}
const messageId = response?.data?.message_id;
if (typeof messageId !== 'string' || !messageId) {
throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
}
return createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation,
providerMessageIds: [messageId],
artifacts: [{
artifactId: file.artifactId,
outcome: 'sent',
}],
});
}
async #sendCard(chatId, cardId, replyTo) {
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
const response = replyTo

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);
@ -125,6 +126,31 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
}
}
async function readStream(stream, { signal }) {
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
throw new Error('Feishu file download returned no readable stream');
}
signal?.throwIfAborted();
const abort = () => stream.destroy?.(
signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
);
signal?.addEventListener('abort', abort, { once: true });
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
signal?.throwIfAborted();
const data = Buffer.from(chunk);
size += data.length;
chunks.push(data);
}
signal?.throwIfAborted();
return Buffer.concat(chunks, size);
} finally {
signal?.removeEventListener('abort', abort);
}
}
function providerCode(value) {
if (!value || typeof value !== 'object') return null;
const code = value.code ?? value.error?.code;
@ -196,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 },
);
}
@ -224,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 });
@ -232,6 +258,26 @@ function feishuImageSource(event, client, key) {
};
}
function feishuFileSource(event, client, file) {
const key = nonEmptyString(file?.file_key);
if (!key) return null;
return {
name: nonEmptyString(file?.file_name) ?? 'file',
async load({ signal } = {}) {
signal?.throwIfAborted();
const resource = await client?.im?.v1?.messageResource?.get?.({
path: {
message_id: event.message.message_id,
file_key: key,
},
params: { type: 'file' },
});
signal?.throwIfAborted();
return readStream(resource?.getReadableStream?.(), { signal });
},
};
}
export function extractInboundMessage(event, client) {
const messageType = event?.message?.message_type;
const parsed = parsedMessageContent(event);
@ -240,9 +286,11 @@ export function extractInboundMessage(event, client) {
? nonEmptyString(parsed?.image_key)
: null;
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
return {
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
files: file ? [file] : [],
};
}

View file

@ -9,6 +9,7 @@ export const REQUIRED_TENANT_SCOPES = Object.freeze([
'im:message:send_as_bot',
'im:message.reactions:write_only',
'im:message:recall',
'im:resource',
'cardkit:card:write',
]);