mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
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:
commit
044ab74c28
167 changed files with 22497 additions and 1793 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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('绑定该会话后可继续追问,输入文字即可。')) },
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', '飞书运行时已停止'));
|
||||
|
|
|
|||
|
|
@ -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] : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue