mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
Add shared /history parsing, read-only session access, message filtering, and bounded replies. Default to three messages, cap at five, and exclude saved history commands. Include channel integration tests, translations, command help, and the Issue #62 implementation plan.
3652 lines
138 KiB
JavaScript
3652 lines
138 KiB
JavaScript
import QRCode from 'qrcode';
|
||
import {
|
||
conversationKey,
|
||
extractInboundMessage,
|
||
extractText,
|
||
isAllowedSender,
|
||
isBotSender,
|
||
splitText,
|
||
} from './message-utils.mjs';
|
||
import {
|
||
hasInboundImages,
|
||
imagePromptDiagnostic,
|
||
imagePromptUserMessage,
|
||
promptContentForMessage,
|
||
} from '../shared/image-prompt.mjs';
|
||
import {
|
||
hasInboundFiles,
|
||
inboundFileUserMessage,
|
||
} from '../shared/inbound-file.mjs';
|
||
import {
|
||
harnessAnswerForQuestion,
|
||
harnessQuestionText,
|
||
validHarnessQuestion,
|
||
} from '../shared/harness-question.mjs';
|
||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||
import {
|
||
BatchInputManager,
|
||
batchInputBusyMessage,
|
||
batchInputGroupUnsupportedMessage,
|
||
isBatchInputCommand,
|
||
} from '../shared/batch-input.mjs';
|
||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
|
||
import {
|
||
isControlCommand,
|
||
runControlCommand,
|
||
} from '../shared/control-command.mjs';
|
||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||
import {
|
||
isModelCommand,
|
||
runModelCommand,
|
||
} from '../shared/model-command.mjs';
|
||
import {
|
||
isPresetCommand,
|
||
runPresetCommand,
|
||
} 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 {
|
||
channelDeliveryFailure,
|
||
clearLastMessageFailure,
|
||
messageFailureText,
|
||
setLastMessageFailure,
|
||
} from '../shared/message-failure.mjs';
|
||
import { beginStatusReaction } from '../shared/status-reaction.mjs';
|
||
import {
|
||
MENU_PAGE_SIZE,
|
||
PRESET_FOLLOW_DEFAULT_SENTINEL,
|
||
STEER_CUSTOM_SENTINEL,
|
||
completionCard,
|
||
customSteerCard,
|
||
helpCard,
|
||
menuCard,
|
||
menuHelpText,
|
||
modelCard,
|
||
presetCard,
|
||
sessionListCard,
|
||
statusCard,
|
||
steerCard,
|
||
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';
|
||
|
||
// 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;
|
||
const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
|
||
const REPAIR_COMMAND = /^\/repair(?:\s+(qr|status|cancel|verify))?\s*$/i;
|
||
const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
|
||
const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
|
||
const WATCHLIST_COMMAND = /^\/watchlist$/i;
|
||
const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
|
||
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
||
const NUMBER_REPLY = /^\d{1,2}$/;
|
||
/** A displayed menu stays number-tappable for this long. */
|
||
const MENU_TTL_MS = 10 * 60_000;
|
||
const MAX_TRACKED_MENUS = 50;
|
||
/** Bound callback work per conversation so retries cannot exhaust Host RPCs. */
|
||
const MAX_PENDING_CARD_ACTIONS_PER_KEY = 8;
|
||
/** Bound actual stop/steer submissions independently from ordinary card UI work. */
|
||
const MAX_PENDING_CARD_CONTROLS_PER_KEY = 8;
|
||
/** Provider retries may arrive after the original callback has already settled. */
|
||
const CARD_ACTION_DEDUPE_TTL_MS = 10 * 60_000;
|
||
const MAX_COMPLETED_CARD_ACTIONS = 400;
|
||
/** Keep pre-persistence completion arrivals long enough for a new watch to baseline. */
|
||
const COMPLETION_OBSERVATION_TTL_MS = 10 * 60_000;
|
||
const MAX_OBSERVED_COMPLETION_SESSIONS = 100;
|
||
const MAX_OBSERVED_COMPLETIONS_PER_SESSION = 50;
|
||
const MAX_OBSERVED_COMPLETIONS = 500;
|
||
/** Collapse callback-flood notices instead of amplifying overload into more API calls. */
|
||
const CARD_OVERLOAD_NOTICE_COOLDOWN_MS = 5_000;
|
||
/** Cards should degrade promptly when one optional Host data source is slow. */
|
||
const CARD_DATA_TIMEOUT_MS = 5_000;
|
||
const REPAIR_LINK_WAIT_MS = 15_000;
|
||
const REPAIR_POLL_INTERVAL_MS = 1_000;
|
||
const REPAIR_AUTHORIZATION_STATES = new Set([
|
||
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
|
||
]);
|
||
const REPAIR_ACTIVE_STATES = new Set([...REPAIR_AUTHORIZATION_STATES, 'saving']);
|
||
const REPAIR_TERMINAL_STATES = new Set([
|
||
'succeeded', 'expired', 'cancelled', 'error',
|
||
]);
|
||
const REPAIR_URL_HOSTS = new Set([
|
||
'accounts.feishu.cn',
|
||
'open.feishu.cn',
|
||
'accounts.larksuite.com',
|
||
'open.larksuite.com',
|
||
]);
|
||
|
||
const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
|
||
/** Matches fast card commands that should not be queued behind a running task. */
|
||
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|sessionlist(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
|
||
|
||
/** Canonical workspace/session help advertised by every bridge family. */
|
||
const WORKSPACE_HELP_LINES = [
|
||
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
||
'/workspacelist 列出工作区绝对路径',
|
||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||
];
|
||
|
||
/** Safe user-facing text for bind/workspace failures (no raw messages). */
|
||
function safeErrorText(error) {
|
||
switch (error?.code) {
|
||
case 'workspace-not-absolute':
|
||
return t('工作区必须是绝对路径。');
|
||
case 'workspace-not-found':
|
||
return t('工作区路径不存在。');
|
||
case 'workspace-not-directory':
|
||
return t('工作区路径必须指向一个目录。');
|
||
case 'workspace-bot-not-found':
|
||
return t('机器人正在移除或已重新接入,无法操作原会话的工作区。');
|
||
default:
|
||
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。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。', { 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;
|
||
}
|
||
|
||
/** Accept SDK payload fields that may already be objects or JSON strings. */
|
||
function callbackObject(value) {
|
||
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
||
if (typeof value !== 'string' || !value.trim()) return {};
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/** Normalize a single-select value without corrupting valid commas in an id/path. */
|
||
function callbackSingleOption(value) {
|
||
if (value === null || value === undefined) return null;
|
||
if (Array.isArray(value)) {
|
||
for (const entry of value) {
|
||
const selected = callbackSingleOption(entry);
|
||
if (selected !== null) return selected;
|
||
}
|
||
return null;
|
||
}
|
||
if (value && typeof value === 'object') {
|
||
if ('value' in value) return callbackSingleOption(value.value);
|
||
if ('option' in value) return callbackSingleOption(value.option);
|
||
return null;
|
||
}
|
||
if (typeof value !== 'string') return null;
|
||
const source = value.trim();
|
||
if (!source) return null;
|
||
if (source.startsWith('[') || source.startsWith('{') || source.startsWith('"')) {
|
||
try {
|
||
const parsed = JSON.parse(source);
|
||
if (parsed !== value) return callbackSingleOption(parsed);
|
||
} catch { /* plain value below */ }
|
||
}
|
||
return source;
|
||
}
|
||
|
||
/** Normalize multi-select values across current and legacy SDK shapes. */
|
||
function callbackMultiOptionValues(value) {
|
||
if (value === null || value === undefined) return [];
|
||
if (Array.isArray(value)) return value.flatMap((entry) => callbackMultiOptionValues(entry));
|
||
if (value && typeof value === 'object') {
|
||
if ('value' in value) return callbackMultiOptionValues(value.value);
|
||
if ('option' in value) return callbackMultiOptionValues(value.option);
|
||
return [];
|
||
}
|
||
if (typeof value !== 'string') return [];
|
||
const source = value.trim();
|
||
if (!source) return [];
|
||
if (source.startsWith('[') || source.startsWith('{') || source.startsWith('"')) {
|
||
try {
|
||
const parsed = JSON.parse(source);
|
||
if (parsed !== value) return callbackMultiOptionValues(parsed);
|
||
} catch { /* plain value below */ }
|
||
}
|
||
return source.split(',').map((entry) => entry.trim()).filter(Boolean);
|
||
}
|
||
|
||
function validLastSeq(value) {
|
||
return Number.isSafeInteger(value) && value >= -1;
|
||
}
|
||
|
||
function validEventSeq(value) {
|
||
return Number.isSafeInteger(value) && value >= 0;
|
||
}
|
||
|
||
function watchBoundary(entry) {
|
||
return Number.isSafeInteger(entry?.watchStartedAt) && entry.watchStartedAt >= 0
|
||
? entry.watchStartedAt
|
||
: null;
|
||
}
|
||
|
||
function watchNeedsBaseline(entry) {
|
||
return entry && (watchBoundary(entry) !== null || !validLastSeq(entry.lastSeq));
|
||
}
|
||
|
||
function orderedHistoryEvents(history) {
|
||
return (Array.isArray(history?.events) ? history.events : [])
|
||
.map((entry) => entry?.event ?? entry)
|
||
.filter((entry) => entry && typeof entry === 'object' && validEventSeq(entry.seq))
|
||
.sort((left, right) => left.seq - right.seq);
|
||
}
|
||
|
||
function senderOpenId(event) {
|
||
return nonEmptyString(event?.sender?.sender_id?.open_id)
|
||
?? nonEmptyString(event?.sender?.sender_id?.user_id);
|
||
}
|
||
|
||
function strictSenderOpenId(event) {
|
||
return nonEmptyString(event?.sender?.sender_id?.open_id);
|
||
}
|
||
|
||
function abortableDelay(milliseconds, signal) {
|
||
return new Promise((resolve, reject) => {
|
||
if (signal?.aborted) {
|
||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||
return;
|
||
}
|
||
const timer = setTimeout(done, milliseconds);
|
||
timer.unref?.();
|
||
function done() {
|
||
signal?.removeEventListener('abort', aborted);
|
||
resolve();
|
||
}
|
||
function aborted() {
|
||
clearTimeout(timer);
|
||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||
}
|
||
signal?.addEventListener('abort', aborted, { once: true });
|
||
});
|
||
}
|
||
|
||
function repairSnapshot(value, { botId } = {}) {
|
||
const source = value && typeof value === 'object' ? value : {};
|
||
const registration = source.registration && typeof source.registration === 'object'
|
||
? source.registration
|
||
: source;
|
||
const operation = nonEmptyString(registration.operation) ?? nonEmptyString(source.operation);
|
||
if (operation && operation !== 'callback_repair') {
|
||
throw new Error('The active Feishu operation is not a callback repair');
|
||
}
|
||
const selectedBotId = nonEmptyString(registration.botId) ?? nonEmptyString(source.botId);
|
||
if (botId && selectedBotId && selectedBotId !== botId) {
|
||
throw new Error('The Feishu repair belongs to another bot');
|
||
}
|
||
const state = nonEmptyString(registration.state);
|
||
const attempt = registration.attemptId ?? registration.attempt;
|
||
const attemptId = typeof attempt === 'string' || Number.isFinite(attempt)
|
||
? String(attempt)
|
||
: null;
|
||
if (!state || !attemptId) throw new Error('Feishu returned an invalid repair status');
|
||
const verificationUrl = nonEmptyString(registration.verificationUrl)
|
||
?? nonEmptyString(registration.qrCodeUrl);
|
||
const expiresAt = Number(registration.expiresAt);
|
||
const remainingSeconds = Number(registration.remainingSeconds);
|
||
const pollIntervalMs = Number(registration.pollIntervalMs)
|
||
|| (Number(registration.pollIntervalSeconds) * 1000);
|
||
return {
|
||
state,
|
||
attemptId,
|
||
botId: selectedBotId,
|
||
verificationUrl,
|
||
expiresAt: Number.isFinite(expiresAt) ? expiresAt : null,
|
||
remainingSeconds: Number.isFinite(remainingSeconds) ? remainingSeconds : null,
|
||
pollIntervalMs: Number.isFinite(pollIntervalMs) && pollIntervalMs > 0
|
||
? pollIntervalMs
|
||
: null,
|
||
error: registration.error && typeof registration.error === 'object'
|
||
? { code: nonEmptyString(registration.error.code), message: nonEmptyString(registration.error.message) }
|
||
: null,
|
||
};
|
||
}
|
||
|
||
function safeRepairUrl(rawUrl, expectedAppId) {
|
||
const url = new URL(rawUrl);
|
||
if (url.protocol !== 'https:' || !REPAIR_URL_HOSTS.has(url.hostname)) {
|
||
throw new Error('Feishu returned an untrusted repair URL');
|
||
}
|
||
if (url.searchParams.get('tp') !== 'sdk'
|
||
|| url.searchParams.get('clientID') !== expectedAppId
|
||
|| url.searchParams.has('createOnly')) {
|
||
throw new Error('Feishu returned an invalid existing-app repair URL');
|
||
}
|
||
if (url.toString().includes('{{client_id}}') || url.toString().includes('%7B%7Bclient_id%7D%7D')) {
|
||
throw new Error('Feishu returned an unresolved client id placeholder');
|
||
}
|
||
return url.toString();
|
||
}
|
||
|
||
function canClaimInteractionReply(event, pending) {
|
||
return pending.needsPresentation !== true
|
||
&& pending.questions[pending.index]
|
||
&& senderOpenId(event) === pending.actor
|
||
&& event?.message?.message_type === 'text'
|
||
&& nonEmptyString(extractText(event));
|
||
}
|
||
|
||
function ensureStatus(status) {
|
||
for (const key of ['messagesReceived', 'messagesReplied', 'messagesRejected']) {
|
||
status[key] ??= 0;
|
||
}
|
||
status.lastMessageAt ??= null;
|
||
status.lastReplyAt ??= null;
|
||
status.lastRejectedAt ??= null;
|
||
status.lastError ??= null;
|
||
status.lastMessageError ??= null;
|
||
}
|
||
|
||
export class FeishuHarnessBridge {
|
||
#client;
|
||
#channel;
|
||
#harness;
|
||
#state;
|
||
#queues = new Map();
|
||
#batchInputs = new BatchInputManager();
|
||
#pendingInteractions = new Map();
|
||
#interactionKeys = new Map();
|
||
#resolvedQuestionReplies = new Map();
|
||
#acceptedMessageIds = new Set();
|
||
#interactionTasks = new Set();
|
||
#commandTasks = new Set();
|
||
/** All accepted card work, including tasks waiting behind an earlier click. */
|
||
#cardActionTasks = new Set();
|
||
/** Per-conversation navigation/configuration serialization tails. */
|
||
#cardActionTails = new Map();
|
||
/** Per-conversation serialization for actual stop/steer side effects. */
|
||
#cardControlTails = new Map();
|
||
/** Pending callback count per conversation, used for bounded backpressure. */
|
||
#cardActionCounts = new Map();
|
||
/** Pending control count per conversation, isolated from ordinary UI work. */
|
||
#cardControlCounts = new Map();
|
||
/** Same callback retry joins the original task instead of repeating side effects. */
|
||
#cardActionInFlight = new Map();
|
||
/** Stop is idempotent per conversation; coalesce floods while one stop is pending. */
|
||
#cardStopInFlight = new Map();
|
||
/** Stable ids coalesced into a stop move to the completed cache when it settles. */
|
||
#cardStopFollowers = new Map();
|
||
/** Settled provider event ids stay deduplicated for a bounded retry window. */
|
||
#completedCardActions = new Map();
|
||
/** Last overload notice time per conversation. */
|
||
#cardOverloadNoticeAt = new Map();
|
||
#approvals;
|
||
#status;
|
||
#allowedSenderOpenIds;
|
||
#replyTimeoutMs;
|
||
#logger;
|
||
#signal;
|
||
#botId;
|
||
#appId;
|
||
#botOpenId;
|
||
#groupResponseMode;
|
||
#repair;
|
||
#repairAttempt = null;
|
||
#repairMonitorVersion = 0;
|
||
#repairPollIntervalMs;
|
||
#repairLinkWaitMs;
|
||
/** Number-tappable menus: conversation key → menu state. */
|
||
#menus = new Map();
|
||
/** Interactive-card message id → route context for button callbacks. */
|
||
#cardKeys = new Map();
|
||
/** The global event-mux watcher (one per bridge). */
|
||
#eventWatcher = null;
|
||
/** Serializes completion work per session without blocking unrelated sessions. */
|
||
#eventTails = new Map();
|
||
/** Coalesces baseline compensation and records whether a trailing pass is needed. */
|
||
#pendingCompensations = new Map();
|
||
/** Bounded live arrivals bridge target-list, persistence, and history-projection races. */
|
||
#observedCompletionEvents = new Map();
|
||
/** Earliest completion that still needs delivery for each watch. */
|
||
#failedWatchSeqs = new Map();
|
||
#cardDataTimeoutMs;
|
||
|
||
constructor({
|
||
client,
|
||
channel,
|
||
harness,
|
||
state,
|
||
status,
|
||
allowedSenderOpenIds = new Set(),
|
||
botId,
|
||
appId,
|
||
botOpenId,
|
||
groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
|
||
repair,
|
||
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
||
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
||
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
|
||
replyTimeoutMs = 600_000,
|
||
logger = console,
|
||
signal,
|
||
}) {
|
||
if (!client || !harness || !state || !status) {
|
||
throw new TypeError('Feishu bridge dependencies are required');
|
||
}
|
||
if (repair !== undefined && repair !== null) {
|
||
if (!repair || typeof repair.start !== 'function'
|
||
|| typeof repair.status !== 'function'
|
||
|| typeof repair.cancel !== 'function') {
|
||
throw new TypeError('Feishu repair capability requires start/status/cancel');
|
||
}
|
||
if (!nonEmptyString(botId) || !nonEmptyString(appId)) {
|
||
throw new TypeError('Feishu repair capability requires botId and appId');
|
||
}
|
||
}
|
||
if (!Number.isFinite(repairPollIntervalMs) || repairPollIntervalMs <= 0
|
||
|| !Number.isFinite(repairLinkWaitMs) || repairLinkWaitMs <= 0
|
||
|| !Number.isFinite(cardDataTimeoutMs) || cardDataTimeoutMs <= 0) {
|
||
throw new TypeError('Feishu timing values must be positive numbers');
|
||
}
|
||
this.#client = client;
|
||
this.#channel = channel;
|
||
this.#harness = harness;
|
||
this.#state = state;
|
||
this.#status = status;
|
||
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
||
this.#botId = nonEmptyString(botId);
|
||
this.#appId = nonEmptyString(appId);
|
||
this.#botOpenId = nonEmptyString(botOpenId);
|
||
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
||
this.#repair = repair ?? null;
|
||
this.#repairPollIntervalMs = repairPollIntervalMs;
|
||
this.#repairLinkWaitMs = repairLinkWaitMs;
|
||
this.#cardDataTimeoutMs = cardDataTimeoutMs;
|
||
this.#replyTimeoutMs = replyTimeoutMs;
|
||
this.#logger = logger;
|
||
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
||
this.#signal = signal;
|
||
ensureStatus(this.#status);
|
||
// Persisted watches must resume at runtime start, not on the first
|
||
// message. Older hosts without the mux watcher simply skip this.
|
||
if (typeof this.#harness?.watchHarnessEvents === 'function') {
|
||
queueMicrotask(() => this.#ensureEventWatcher());
|
||
}
|
||
}
|
||
|
||
setGroupResponseMode(value) {
|
||
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
||
}
|
||
|
||
#isAddressed(event) {
|
||
if (event?.message?.chat_type === 'p2p') return true;
|
||
const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
|
||
if (!this.#botOpenId) return mentions.length > 0;
|
||
return mentions.some((mention) => mention?.id?.open_id === this.#botOpenId
|
||
|| mention?.open_id === this.#botOpenId);
|
||
}
|
||
|
||
accept(event) {
|
||
if (this.#signal?.aborted) return Promise.resolve();
|
||
const messageId = nonEmptyString(event?.message?.message_id);
|
||
if (!messageId || isBotSender(event)) return Promise.resolve();
|
||
if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
|
||
this.#status.messagesRejected += 1;
|
||
this.#status.lastRejectedAt = new Date().toISOString();
|
||
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the allowlist');
|
||
return Promise.resolve();
|
||
}
|
||
const addressed = this.#isAddressed(event);
|
||
if (event?.message?.chat_type !== 'p2p'
|
||
&& this.#groupResponseMode === FEISHU_GROUP_RESPONSE_MODES.MENTION
|
||
&& !addressed) {
|
||
return Promise.resolve();
|
||
}
|
||
if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
let key;
|
||
try {
|
||
key = conversationKey(event);
|
||
} catch {
|
||
this.#status.messagesRejected += 1;
|
||
this.#status.lastRejectedAt = new Date().toISOString();
|
||
return Promise.resolve();
|
||
}
|
||
|
||
if (event.message.chat_type === 'p2p') {
|
||
const chatId = nonEmptyString(event.message.chat_id);
|
||
if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
|
||
}
|
||
|
||
this.#acceptedMessageIds.add(messageId);
|
||
const processingReaction = this.#beginReaction(messageId);
|
||
const commandMessage = extractInboundMessage(event, this.#client);
|
||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||
const batchText = event.message.message_type === 'text'
|
||
? nonEmptyString(extractText(event)) ?? ''
|
||
: '';
|
||
const batchCommand = event.message.message_type === 'text'
|
||
&& isBatchInputCommand(batchText);
|
||
const pending = this.#pendingInteractions.get(key);
|
||
const batchStatus = this.#batchInputs.status(key);
|
||
if (batchCommand && event.message.chat_type !== 'p2p') {
|
||
return this.#finishBatchResult(
|
||
event,
|
||
messageId,
|
||
processingReaction,
|
||
{ message: batchInputGroupUnsupportedMessage() },
|
||
);
|
||
}
|
||
if (event.message.chat_type === 'p2p'
|
||
&& (batchCommand || batchStatus.phase === 'collecting')) {
|
||
const exactBatchStart = /^\/batch$/iu.test(batchText);
|
||
const result = exactBatchStart
|
||
&& batchStatus.phase === 'idle'
|
||
&& (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
|
||
? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
|
||
: this.#batchInputs.handle(key, batchText, {
|
||
plainText: event.message.message_type === 'text' && Boolean(batchText),
|
||
});
|
||
if (result.handled) {
|
||
if (result.kind === 'submit') {
|
||
const submissionEvent = {
|
||
...event,
|
||
batchSubmission: { token: result.token },
|
||
message: {
|
||
...event.message,
|
||
message_type: 'text',
|
||
content: JSON.stringify({ text: result.prompt }),
|
||
mentions: [],
|
||
},
|
||
};
|
||
return this.#enqueueMessage(
|
||
submissionEvent,
|
||
messageId,
|
||
key,
|
||
processingReaction,
|
||
);
|
||
}
|
||
return this.#finishBatchResult(
|
||
event,
|
||
messageId,
|
||
processingReaction,
|
||
result,
|
||
);
|
||
}
|
||
}
|
||
// Card commands (/m, /help, /status, etc.) bypass the queue so they
|
||
// respond immediately even when a harness task is still streaming.
|
||
if (CARD_COMMAND.test(commandText)) {
|
||
const processing = Promise.resolve()
|
||
.then(() => this.#handle(event, key, { alreadyRecorded: false }))
|
||
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||
.catch((error) => this.#handleMessageFailure(
|
||
event,
|
||
messageId,
|
||
processingReaction,
|
||
error,
|
||
))
|
||
.finally(() => this.#acceptedMessageIds.delete(messageId));
|
||
return processing;
|
||
}
|
||
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
|
||
: hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
||
? runControlCommand
|
||
: (isModelCommand(commandText)
|
||
? runModelCommand
|
||
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
||
// In all-message group mode, history must still be refused locally rather
|
||
// than becoming a normal prompt when no mention is present.
|
||
if (commandRunner && (addressed || commandRunner === runHistoryCommand)) {
|
||
const processing = this.#processFastCommand(
|
||
event,
|
||
messageId,
|
||
key,
|
||
commandMessage,
|
||
commandRunner,
|
||
);
|
||
let current;
|
||
current = processing
|
||
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||
.catch((error) => this.#handleMessageFailure(
|
||
event,
|
||
messageId,
|
||
processingReaction,
|
||
error,
|
||
))
|
||
.finally(() => {
|
||
this.#acceptedMessageIds.delete(messageId);
|
||
this.#commandTasks.delete(current);
|
||
});
|
||
this.#commandTasks.add(current);
|
||
return current;
|
||
}
|
||
if (this.#isResolvedQuestionReply(event, key)) {
|
||
const current = Promise.resolve()
|
||
.then(() => this.#discardResolvedInteractionReply(event, messageId))
|
||
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||
.catch((error) => this.#handleMessageFailure(
|
||
event,
|
||
messageId,
|
||
processingReaction,
|
||
error,
|
||
))
|
||
.finally(() => this.#acceptedMessageIds.delete(messageId));
|
||
return current;
|
||
}
|
||
const approvalReply = this.#approvals.claimReply({
|
||
key,
|
||
actor: senderOpenId(event),
|
||
messageId,
|
||
text: extractText(event) ?? '',
|
||
addressed,
|
||
hasPendingQuestion: Boolean(pending),
|
||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||
? pending.queue
|
||
: null,
|
||
isQuestionPending: () => this.#pendingInteractions.has(key),
|
||
send: (text) => this.#send(event.message.chat_id, text),
|
||
});
|
||
if (approvalReply) {
|
||
const processing = approvalReply.process(async () => {
|
||
if (this.#state.hasSeen(messageId)) return false;
|
||
await this.#state.markSeen(messageId);
|
||
this.#status.lastMessageAt = new Date().toISOString();
|
||
this.#status.messagesReceived += 1;
|
||
return true;
|
||
});
|
||
let current;
|
||
current = processing
|
||
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||
.catch((error) => this.#handleMessageFailure(
|
||
event,
|
||
messageId,
|
||
processingReaction,
|
||
error,
|
||
))
|
||
.finally(() => {
|
||
this.#acceptedMessageIds.delete(messageId);
|
||
this.#interactionTasks.delete(current);
|
||
});
|
||
this.#interactionTasks.add(current);
|
||
return current;
|
||
}
|
||
if (pending && senderOpenId(event) !== pending.actor) {
|
||
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
||
}
|
||
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
||
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
||
}
|
||
if (pending) {
|
||
if (canClaimInteractionReply(event, pending)) pending.claimedReplyMessageId = messageId;
|
||
const previous = pending.queue ?? Promise.resolve();
|
||
const processing = previous
|
||
.catch(() => undefined)
|
||
.then(() => this.#processInteractionReply(
|
||
event,
|
||
messageId,
|
||
key,
|
||
pending,
|
||
processingReaction,
|
||
));
|
||
pending.queue = processing;
|
||
|
||
const releaseInteraction = () => {
|
||
if (pending.claimedReplyMessageId === messageId) {
|
||
pending.claimedReplyMessageId = null;
|
||
}
|
||
if (pending.queue === processing) pending.queue = null;
|
||
};
|
||
let current;
|
||
current = processing
|
||
.then(
|
||
() => {
|
||
releaseInteraction();
|
||
return this.#finishReaction(messageId, processingReaction, 'DONE');
|
||
},
|
||
(error) => {
|
||
releaseInteraction();
|
||
return this.#handleMessageFailure(
|
||
event,
|
||
messageId,
|
||
processingReaction,
|
||
error,
|
||
);
|
||
},
|
||
)
|
||
.finally(() => {
|
||
releaseInteraction();
|
||
this.#acceptedMessageIds.delete(messageId);
|
||
this.#interactionTasks.delete(current);
|
||
});
|
||
this.#interactionTasks.add(current);
|
||
return current;
|
||
}
|
||
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
||
}
|
||
|
||
#finishBatchResult(event, messageId, processingReaction, result) {
|
||
let current;
|
||
current = Promise.resolve()
|
||
.then(async () => {
|
||
if (this.#state.hasSeen(messageId)) return;
|
||
await this.#state.markSeen(messageId);
|
||
this.#status.lastMessageAt = new Date().toISOString();
|
||
this.#status.messagesReceived += 1;
|
||
if (result?.message) await this.#send(event.message.chat_id, result.message);
|
||
this.#status.lastError = null;
|
||
})
|
||
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||
.catch((error) => this.#handleMessageFailure(
|
||
event,
|
||
messageId,
|
||
processingReaction,
|
||
error,
|
||
))
|
||
.finally(() => {
|
||
this.#acceptedMessageIds.delete(messageId);
|
||
this.#commandTasks.delete(current);
|
||
});
|
||
this.#commandTasks.add(current);
|
||
return current;
|
||
}
|
||
|
||
#enqueueMessage(event, messageId, key, processingReaction, {
|
||
releaseMessageId = true,
|
||
alreadyRecorded = false,
|
||
finalize = true,
|
||
} = {}) {
|
||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||
const work = previous
|
||
.catch(() => undefined)
|
||
.then(() => this.#handle(event, key, { alreadyRecorded }));
|
||
const settled = finalize
|
||
? work
|
||
.then(async (receipt) => {
|
||
await this.#finishReaction(messageId, processingReaction, 'DONE');
|
||
return receipt;
|
||
})
|
||
.catch((error) => this.#handleMessageFailure(
|
||
event,
|
||
messageId,
|
||
processingReaction,
|
||
error,
|
||
))
|
||
: work;
|
||
let current;
|
||
current = settled.finally(() => {
|
||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||
});
|
||
this.#queues.set(key, current);
|
||
return current;
|
||
}
|
||
|
||
#recordFailure(error, {
|
||
logLabel = 'operation',
|
||
logLevel = 'warn',
|
||
userMessage,
|
||
reason,
|
||
} = {}) {
|
||
this.#status.lastError = error?.message ?? String(error);
|
||
const failure = setLastMessageFailure(this.#status, error, { userMessage, reason });
|
||
this.#logger?.[logLevel]?.(
|
||
`[dsh-feishu] ${logLabel} failed [${failure.referenceId}]:`,
|
||
error?.message ?? String(error),
|
||
);
|
||
return failure;
|
||
}
|
||
|
||
async #sendFailure(chatId, error, options = {}) {
|
||
const failure = this.#recordFailure(error, options);
|
||
const text = options.appendMessage
|
||
? `${messageFailureText(failure)}\n\n${options.appendMessage}`
|
||
: messageFailureText(failure);
|
||
await this.#send(chatId, text).catch(() => undefined);
|
||
return failure;
|
||
}
|
||
|
||
async #handleMessageFailure(event, messageId, processingReaction, error) {
|
||
if (error?.code === 'turn-stopped') {
|
||
await this.#removeProcessingReaction(messageId, processingReaction);
|
||
if (error?.batchInputMessage) {
|
||
await this.#send(event.message.chat_id, error.batchInputMessage).catch(() => undefined);
|
||
}
|
||
return;
|
||
}
|
||
if (this.#signal?.aborted) {
|
||
await this.#removeProcessingReaction(messageId, processingReaction);
|
||
return;
|
||
}
|
||
const userMessage = inboundFileUserMessage(error)
|
||
?? imagePromptUserMessage(error);
|
||
const failure = this.#recordFailure(error, {
|
||
logLabel: 'message handling',
|
||
logLevel: 'error',
|
||
userMessage,
|
||
reason: imagePromptDiagnostic(error)?.reason,
|
||
});
|
||
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
||
const failureText = error?.batchInputMessage
|
||
? `${messageFailureText(failure)}\n\n${error.batchInputMessage}`
|
||
: messageFailureText(failure);
|
||
await this.#send(
|
||
event.message.chat_id,
|
||
failureText,
|
||
).catch(() => undefined);
|
||
}
|
||
|
||
async waitForIdle() {
|
||
// Drain to a fixed point: awaited work can register compensation or
|
||
// another serialized tail before it settles.
|
||
for (;;) {
|
||
const tasks = [
|
||
...this.#queues.values(),
|
||
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
||
pending.queue ? [pending.queue] : []
|
||
)),
|
||
...this.#interactionTasks,
|
||
...this.#commandTasks,
|
||
...this.#cardActionTasks,
|
||
...this.#eventTails.values(),
|
||
...[...this.#pendingCompensations.values()].map((pending) => pending.promise),
|
||
];
|
||
if (tasks.length === 0) return;
|
||
await Promise.allSettled(tasks);
|
||
if (this.#queues.size === 0
|
||
&& this.#interactionTasks.size === 0
|
||
&& this.#commandTasks.size === 0
|
||
&& this.#cardActionTasks.size === 0
|
||
&& this.#eventTails.size === 0
|
||
&& this.#pendingCompensations.size === 0
|
||
&& ![...this.#pendingInteractions.values()].some((pending) => pending.queue)) return;
|
||
}
|
||
}
|
||
|
||
#cardDataSignal() {
|
||
const timeout = AbortSignal.timeout(this.#cardDataTimeoutMs);
|
||
return this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
|
||
}
|
||
|
||
#hasPendingInteraction(key) {
|
||
return this.#pendingInteractions.has(key) || this.#approvals.hasPending(key);
|
||
}
|
||
|
||
async #processFastCommand(event, messageId, key, message, runner) {
|
||
this.#signal?.throwIfAborted();
|
||
if (this.#state.hasSeen(messageId)) return;
|
||
await this.#state.markSeen(messageId);
|
||
this.#status.lastMessageAt = new Date().toISOString();
|
||
this.#status.messagesReceived += 1;
|
||
const result = await runner(
|
||
nonEmptyString(message.content) ?? '',
|
||
this.#harness,
|
||
this.#state,
|
||
key,
|
||
{
|
||
signal: this.#signal,
|
||
isDirect: event.message.chat_type === 'p2p',
|
||
hasImages: hasInboundImages(message),
|
||
hasFiles: hasInboundFiles(message),
|
||
pendingInteraction: this.#hasPendingInteraction(key),
|
||
control: { owner: this, key },
|
||
},
|
||
);
|
||
if (result?.stopped) {
|
||
await Promise.allSettled([
|
||
this.#cancelPendingInteraction(key),
|
||
this.#approvals.closeRoute(key),
|
||
]);
|
||
}
|
||
for (const reply of result?.messages ?? [result?.message]) {
|
||
if (reply) await this.#send(event.message.chat_id, reply);
|
||
}
|
||
this.#status.lastError = null;
|
||
}
|
||
|
||
async #handle(event, key, { alreadyRecorded = false } = {}) {
|
||
this.#signal?.throwIfAborted();
|
||
const messageId = event.message.message_id;
|
||
if (!alreadyRecorded) {
|
||
if (this.#state.hasSeen(messageId)) return;
|
||
await this.#state.markSeen(messageId);
|
||
this.#status.lastMessageAt = new Date().toISOString();
|
||
this.#status.messagesReceived += 1;
|
||
}
|
||
|
||
const message = extractInboundMessage(event, this.#client);
|
||
const text = message.content;
|
||
const hasImages = hasInboundImages(message);
|
||
const hasFiles = hasInboundFiles(message);
|
||
// 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
|
||
// 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
|
||
// accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
|
||
const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
|
||
if (!text && !hasImages && !hasFiles) {
|
||
await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'));
|
||
return;
|
||
}
|
||
|
||
if (commandText !== null && REPAIR_COMMAND_PREFIX.test(commandText)) {
|
||
await this.#handleRepairCommand(event, commandText);
|
||
return;
|
||
}
|
||
if (commandText === '/help') {
|
||
await this.#send(event.message.chat_id, menuHelpText());
|
||
return;
|
||
}
|
||
if (MENU_COMMAND.test(commandText)) {
|
||
await this.#sendMenuCard(key, event.message.chat_id);
|
||
return;
|
||
}
|
||
if (commandText === '/new') {
|
||
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
|
||
await this.#send(
|
||
event.message.chat_id,
|
||
t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'),
|
||
);
|
||
return;
|
||
}
|
||
await this.#state.clearSession(key);
|
||
await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'));
|
||
await this.#sendMenuCard(key, event.message.chat_id);
|
||
return;
|
||
}
|
||
if (commandText === '/status') {
|
||
await this.#showStatusText(key, event.message.chat_id);
|
||
return;
|
||
}
|
||
if (commandText === '/compact') {
|
||
const compactCommand = await runCompactCommand(commandText, this.#harness, this.#state, key, { signal: this.#signal });
|
||
if (compactCommand) {
|
||
await this.#send(event.message.chat_id, compactCommand.message);
|
||
}
|
||
return;
|
||
}
|
||
if (SESSION_LIST_PREFIX.test(commandText)) {
|
||
const selector = commandText.slice('/sessionlist'.length).trim() || null;
|
||
await this.#showSessions({ chatId: event.message.chat_id, key }, selector, 0);
|
||
return;
|
||
}
|
||
if (WORKSPACE_LIST_COMMAND.test(commandText)) {
|
||
await this.#showWorkspaces({ chatId: event.message.chat_id, key });
|
||
return;
|
||
}
|
||
if (WATCH_COMMAND.test(commandText)) {
|
||
const target = (WATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
|
||
await this.#runWatch(key, event.message.chat_id, target);
|
||
return;
|
||
}
|
||
if (UNWATCH_COMMAND.test(commandText)) {
|
||
const target = (UNWATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
|
||
await this.#runUnwatch(key, event.message.chat_id, target);
|
||
return;
|
||
}
|
||
if (WATCHLIST_COMMAND.test(commandText)) {
|
||
await this.#showWatchList(key, event.message.chat_id);
|
||
return;
|
||
}
|
||
if (ARCHIVED_COMMAND.test(commandText)) {
|
||
const match = ARCHIVED_COMMAND.exec(commandText);
|
||
const value = match[1]?.toLowerCase();
|
||
if (value !== 'on' && value !== 'off') {
|
||
await this.#send(event.message.chat_id, t('用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)'));
|
||
return;
|
||
}
|
||
if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
|
||
await this.#state.setIncludeArchivedSessions(value === 'on');
|
||
}
|
||
await this.#send(
|
||
event.message.chat_id,
|
||
value === 'on' ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
|
||
);
|
||
return;
|
||
}
|
||
if (NUMBER_REPLY.test(commandText)) {
|
||
const menu = this.#takeMenu(key);
|
||
if (menu) {
|
||
await this.#handleMenuPick(menu, Number(commandText), {
|
||
chatId: event.message.chat_id,
|
||
key,
|
||
event,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
const workspaceCommand = commandText === null
|
||
? null
|
||
: await runWorkspaceCommand(text, this.#harness, key);
|
||
if (workspaceCommand) {
|
||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||
await this.#send(event.message.chat_id, reply);
|
||
}
|
||
return;
|
||
}
|
||
const compactCommand = commandText === null
|
||
? null
|
||
: await runCompactCommand(
|
||
commandText,
|
||
this.#harness,
|
||
this.#state,
|
||
key,
|
||
{ signal: this.#signal },
|
||
);
|
||
if (compactCommand) {
|
||
await this.#send(event.message.chat_id, compactCommand.message);
|
||
return;
|
||
}
|
||
|
||
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
||
const batchSubmission = event.batchSubmission ?? null;
|
||
let batchAskCompleted = false;
|
||
try {
|
||
const {
|
||
receipt,
|
||
artifactSendErrors,
|
||
textDeliveryErrors = 0,
|
||
} = await this.#answerWithStream(event, key, message, {
|
||
onAskComplete: batchSubmission
|
||
? () => {
|
||
batchAskCompleted = this.#batchInputs.complete(
|
||
key,
|
||
batchSubmission.token,
|
||
).completed;
|
||
}
|
||
: undefined,
|
||
});
|
||
this.#status.messagesReplied += 1;
|
||
this.#status.lastReplyAt = new Date().toISOString();
|
||
this.#status.lastError = null;
|
||
if (textDeliveryErrors === 0 && artifactSendErrors === 0) {
|
||
clearLastMessageFailure(this.#status);
|
||
}
|
||
return receipt;
|
||
} catch (error) {
|
||
if (batchSubmission && !batchAskCompleted) {
|
||
if (error?.code === 'turn-stopped') {
|
||
this.#batchInputs.complete(key, batchSubmission.token);
|
||
throw error;
|
||
}
|
||
const failed = this.#batchInputs.fail(key, batchSubmission.token);
|
||
if (failed.retained) {
|
||
const batchError = new Error(error?.message ?? String(error), { cause: error });
|
||
batchError.code = error?.code;
|
||
batchError.providerCode = error?.providerCode;
|
||
batchError.method = error?.method;
|
||
if (Number.isInteger(error?.status)) batchError.status = error.status;
|
||
batchError.batchInputMessage = failed.message;
|
||
throw batchError;
|
||
}
|
||
}
|
||
throw error;
|
||
} finally {
|
||
await this.#cancelPendingInteraction(key);
|
||
await this.#approvals.closeRoute(key);
|
||
}
|
||
}
|
||
|
||
// ── Interactive cards: menus and session/workspace lists ────────────────
|
||
|
||
// Existing-app callback repair. This path deliberately uses ordinary text
|
||
// 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, t('为避免授权链接暴露,请私聊机器人发送 /repair。'));
|
||
return;
|
||
}
|
||
const actorOpenId = strictSenderOpenId(event);
|
||
if (!actorOpenId) {
|
||
await this.#send(event.message.chat_id, t('无法识别当前发送者,未发起修复。'));
|
||
return;
|
||
}
|
||
if (!this.#repair) {
|
||
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, t('用法:/repair、/repair qr、/repair status、/repair cancel 或 /repair verify'));
|
||
return;
|
||
}
|
||
const operation = parsed[1]?.toLowerCase() ?? 'start';
|
||
const chatId = event.message.chat_id;
|
||
if (operation === 'start') {
|
||
await this.#startRepair({ actorOpenId, chatId });
|
||
return;
|
||
}
|
||
|
||
const attempt = this.#repairAttempt;
|
||
if (!attempt) {
|
||
await this.#send(
|
||
chatId,
|
||
t('当前 Runtime 没有可恢复的修复任务记录(机器人可能刚完成密钥更新并重启)。本命令不会启动新的授权;请查看机器人发送的验证结果,确认上一次任务已结束后再发送 /repair。'),
|
||
);
|
||
return;
|
||
}
|
||
if (attempt.actorOpenId !== actorOpenId) {
|
||
await this.#send(chatId, t('另一位用户正在修复该机器人,本次不会显示其授权信息。'));
|
||
return;
|
||
}
|
||
if (operation === 'cancel') {
|
||
let snapshot;
|
||
try {
|
||
const result = await this.#repair.cancel(this.#repairArgs(attempt));
|
||
snapshot = repairSnapshot(result, { botId: this.#botId });
|
||
attempt.snapshot = snapshot;
|
||
} catch {
|
||
await this.#send(chatId, t('暂时无法取消修复任务,请稍后重试。'));
|
||
return;
|
||
}
|
||
if (snapshot.state === 'cancelled') {
|
||
attempt.stopped = true;
|
||
this.#repairMonitorVersion += 1;
|
||
}
|
||
await this.#send(chatId, this.#repairStatusText(snapshot));
|
||
return;
|
||
}
|
||
|
||
let snapshot;
|
||
try {
|
||
snapshot = await this.#refreshRepairAttempt(attempt);
|
||
} catch {
|
||
await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
|
||
return;
|
||
}
|
||
if (operation === 'qr') {
|
||
if (!REPAIR_ACTIVE_STATES.has(snapshot.state) || !attempt.verificationUrl) {
|
||
await this.#send(chatId, this.#repairStatusText(snapshot, { verificationFocused: true }));
|
||
return;
|
||
}
|
||
await this.#sendRepairQr(chatId, attempt.verificationUrl, snapshot);
|
||
return;
|
||
}
|
||
await this.#send(chatId, this.#repairStatusText(snapshot, {
|
||
verificationFocused: operation === 'verify',
|
||
}));
|
||
}
|
||
|
||
#repairArgs(attempt) {
|
||
return {
|
||
botId: this.#botId,
|
||
attemptId: attempt.attemptId,
|
||
actorOpenId: attempt.actorOpenId,
|
||
chatId: attempt.chatId,
|
||
};
|
||
}
|
||
|
||
async #startRepair({ actorOpenId, chatId }) {
|
||
const previous = this.#repairAttempt;
|
||
let restarted = false;
|
||
if (previous && REPAIR_ACTIVE_STATES.has(previous.snapshot.state)) {
|
||
if (previous.actorOpenId !== actorOpenId) {
|
||
await this.#send(chatId, t('另一位用户正在修复该机器人,本次不会显示其授权信息。'));
|
||
return;
|
||
}
|
||
let current;
|
||
try {
|
||
current = await this.#refreshRepairAttempt(previous);
|
||
} catch {
|
||
await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
|
||
return;
|
||
}
|
||
// Once Feishu has accepted the update, starting another attempt could
|
||
// race the credential swap and callback probe. Status commands remain
|
||
// available while that non-cancellable convergence is in progress.
|
||
if (current.state === 'saving') {
|
||
await this.#send(chatId, this.#repairStatusText(current));
|
||
return;
|
||
}
|
||
// Feishu launcher links carry one-time user codes. Opening one with the
|
||
// wrong Open Platform account can consume it even though authorization
|
||
// did not succeed, so a fresh bare /repair must replace a still-waiting
|
||
// attempt instead of redisplaying the same unusable URL.
|
||
if (REPAIR_AUTHORIZATION_STATES.has(current.state)) {
|
||
let cancelled;
|
||
try {
|
||
cancelled = repairSnapshot(
|
||
await this.#repair.cancel(this.#repairArgs(previous)),
|
||
{ botId: this.#botId },
|
||
);
|
||
previous.snapshot = cancelled;
|
||
} catch {
|
||
await this.#send(chatId, t('暂时无法取消旧修复任务,未生成新链接;请稍后重试。'));
|
||
return;
|
||
}
|
||
if (!REPAIR_TERMINAL_STATES.has(cancelled.state)) {
|
||
await this.#send(chatId, this.#repairStatusText(cancelled));
|
||
return;
|
||
}
|
||
previous.stopped = true;
|
||
this.#repairMonitorVersion += 1;
|
||
restarted = cancelled.state === 'cancelled';
|
||
} else if (REPAIR_TERMINAL_STATES.has(current.state)) {
|
||
previous.stopped = true;
|
||
this.#repairMonitorVersion += 1;
|
||
}
|
||
}
|
||
|
||
let snapshot;
|
||
try {
|
||
snapshot = repairSnapshot(await this.#repair.start({
|
||
botId: this.#botId,
|
||
actorOpenId,
|
||
chatId,
|
||
}), { botId: this.#botId });
|
||
snapshot = await this.#waitForRepairLink(snapshot, { actorOpenId, chatId });
|
||
} catch {
|
||
await this.#send(chatId, t('修复流程暂时失败,现有机器人连接不受影响;请稍后发送 /repair 重试。'));
|
||
return;
|
||
}
|
||
const attempt = {
|
||
attemptId: snapshot.attemptId,
|
||
actorOpenId,
|
||
chatId,
|
||
snapshot,
|
||
verificationUrl: null,
|
||
stopped: false,
|
||
announcedSaving: false,
|
||
announcedTerminal: false,
|
||
};
|
||
this.#repairAttempt = attempt;
|
||
|
||
if (snapshot.verificationUrl) {
|
||
try {
|
||
attempt.verificationUrl = safeRepairUrl(snapshot.verificationUrl, this.#appId);
|
||
} catch {
|
||
attempt.stopped = true;
|
||
await this.#repair.cancel(this.#repairArgs(attempt)).catch(() => undefined);
|
||
await this.#send(chatId, t('飞书返回了无法安全验证的授权链接,已中止本次修复。'));
|
||
return;
|
||
}
|
||
}
|
||
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
|
||
attempt.announcedTerminal = true;
|
||
if (snapshot.state !== 'succeeded') {
|
||
await this.#send(chatId, this.#repairStatusText(snapshot));
|
||
}
|
||
return;
|
||
}
|
||
if (!attempt.verificationUrl) {
|
||
attempt.stopped = true;
|
||
await this.#send(chatId, t('飞书未返回授权链接,已中止本次修复。'));
|
||
return;
|
||
}
|
||
await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot, { restarted });
|
||
this.#monitorRepair(attempt);
|
||
}
|
||
|
||
async #waitForRepairLink(initial, context) {
|
||
let current = initial;
|
||
const deadline = Date.now() + this.#repairLinkWaitMs;
|
||
while (!current.verificationUrl && REPAIR_ACTIVE_STATES.has(current.state)) {
|
||
if (Date.now() >= deadline) throw new Error('Feishu repair link timed out');
|
||
await abortableDelay(Math.min(100, this.#repairPollIntervalMs), this.#signal);
|
||
current = repairSnapshot(await this.#repair.status({
|
||
botId: this.#botId,
|
||
attemptId: current.attemptId,
|
||
actorOpenId: context.actorOpenId,
|
||
chatId: context.chatId,
|
||
}), { botId: this.#botId });
|
||
}
|
||
return current;
|
||
}
|
||
|
||
async #refreshRepairAttempt(attempt) {
|
||
const snapshot = repairSnapshot(
|
||
await this.#repair.status(this.#repairArgs(attempt)),
|
||
{ botId: this.#botId },
|
||
);
|
||
if (snapshot.attemptId !== attempt.attemptId) {
|
||
throw new Error('Feishu repair attempt changed unexpectedly');
|
||
}
|
||
attempt.snapshot = snapshot;
|
||
if (snapshot.verificationUrl) {
|
||
attempt.verificationUrl = safeRepairUrl(snapshot.verificationUrl, this.#appId);
|
||
}
|
||
return snapshot;
|
||
}
|
||
|
||
#monitorRepair(attempt) {
|
||
const version = ++this.#repairMonitorVersion;
|
||
void (async () => {
|
||
while (!attempt.stopped && this.#repairAttempt === attempt
|
||
&& this.#repairMonitorVersion === version
|
||
&& !this.#signal?.aborted) {
|
||
const delayMs = Math.max(
|
||
250,
|
||
Math.min(10_000, attempt.snapshot.pollIntervalMs ?? this.#repairPollIntervalMs),
|
||
);
|
||
await abortableDelay(delayMs, this.#signal);
|
||
if (attempt.stopped || this.#repairAttempt !== attempt || this.#repairMonitorVersion !== version) return;
|
||
const snapshot = await this.#refreshRepairAttempt(attempt);
|
||
if (snapshot.state === 'saving' && !attempt.announcedSaving) {
|
||
attempt.announcedSaving = true;
|
||
await this.#send(
|
||
attempt.chatId,
|
||
t('授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。'),
|
||
);
|
||
}
|
||
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
|
||
attempt.stopped = true;
|
||
// Runtime sends the verified-success notice before resolving the
|
||
// controller probe. Avoid duplicating it here; failure terminals
|
||
// still need an explicit chat-side explanation.
|
||
if (snapshot.state !== 'succeeded' && !attempt.announcedTerminal) {
|
||
attempt.announcedTerminal = true;
|
||
await this.#send(attempt.chatId, this.#repairStatusText(snapshot));
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
})().catch(async () => {
|
||
if (this.#signal?.aborted || attempt.stopped || this.#repairAttempt !== attempt) return;
|
||
attempt.stopped = true;
|
||
this.#logger.warn?.('[dsh-feishu] callback repair status monitoring failed');
|
||
await this.#send(
|
||
attempt.chatId,
|
||
t('修复状态查询中断,现有机器人连接不受影响;发送 /repair status 重试查询。'),
|
||
).catch(() => undefined);
|
||
});
|
||
}
|
||
|
||
async #sendRepairLink(chatId, url, snapshot, { restarted = false } = {}) {
|
||
const remaining = snapshot.remainingSeconds
|
||
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
|
||
const expiry = remaining === null
|
||
? t('链接为短期有效')
|
||
: t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
|
||
await this.#send(chatId, [
|
||
restarted ? t('旧授权链接已作废,已生成新的修复链接。') : t('🔧 准备补全权限与回调。'),
|
||
t('本次最多增量添加三项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);以及 im:resource(用于上传机器人发送的图片或文件)。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
|
||
'',
|
||
t('当前设备直接打开:'),
|
||
url,
|
||
'',
|
||
t('若要用另一台设备扫码,发送 /repair qr。{expiry}。', { expiry }),
|
||
].join('\n'));
|
||
}
|
||
|
||
async #sendRepairQr(chatId, url, snapshot) {
|
||
try {
|
||
const image = await QRCode.toBuffer(url, {
|
||
errorCorrectionLevel: 'M', margin: 1, width: 480, type: 'png',
|
||
});
|
||
const uploaded = await this.#client.im.v1.image.create({
|
||
data: { image_type: 'message', image },
|
||
});
|
||
const imageKey = nonEmptyString(uploaded?.image_key) ?? nonEmptyString(uploaded?.data?.image_key);
|
||
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,
|
||
t('请用另一台设备扫码完成授权{remaining}。', { remaining: remainingText }),
|
||
);
|
||
const response = await this.#client.im.v1.message.create({
|
||
params: { receive_id_type: 'chat_id' },
|
||
data: {
|
||
receive_id: chatId,
|
||
msg_type: 'image',
|
||
content: JSON.stringify({ image_key: imageKey }),
|
||
},
|
||
});
|
||
if (response?.code && response.code !== 0) throw new Error('Feishu QR message send failed');
|
||
} catch {
|
||
await this.#send(chatId, t('二维码暂时无法发送,请直接打开授权链接:\n{url}', { url }));
|
||
}
|
||
}
|
||
|
||
#repairStatusText(snapshot, { verificationFocused = false } = {}) {
|
||
if (snapshot.state === 'succeeded') {
|
||
return t('✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。');
|
||
}
|
||
if (snapshot.state === 'expired' || snapshot.error?.code === 'expired_token') {
|
||
return t('授权链接已过期;平台未返回成功结果,无法确认已修复。发送 /repair 生成新链接。');
|
||
}
|
||
if (snapshot.state === 'cancelled' || snapshot.error?.code === 'abort') {
|
||
return t('已取消本次修复授权,未确认完成修复。');
|
||
}
|
||
if (snapshot.error?.code === 'access_denied') {
|
||
return t('你已取消或拒绝授权,没有确认修复;发送 /repair 可重试。');
|
||
}
|
||
if (snapshot.error?.code === 'card_action_probe_timeout'
|
||
|| snapshot.error?.code === 'card-action-probe-timeout') {
|
||
return t('授权已提交,但未收到测试按钮回调。可能尚未点击或配置仍在传播;稍后发送 /repair verify 查询,不要盲目重复授权。');
|
||
}
|
||
if (snapshot.state === 'error') {
|
||
return t('修复流程暂时失败,现有机器人连接不受影响;发送 /repair 可重试。');
|
||
}
|
||
if (snapshot.state === 'saving') {
|
||
return t('授权已确认,正在等待专用测试按钮的真实回调;回调到达前不会宣告成功。');
|
||
}
|
||
if (verificationFocused) {
|
||
return t('授权尚未完成,暂时不能验证卡片按钮。请先打开授权链接并确认。');
|
||
}
|
||
const remaining = snapshot.remainingSeconds === null
|
||
? ''
|
||
: t(',剩余约 {minutes} 分钟', { minutes: Math.max(1, Math.ceil(snapshot.remainingSeconds / 60)) });
|
||
return t('修复任务正在等待授权{remaining}。发送 /repair qr 可获取二维码,/repair cancel 可取消。', { remaining });
|
||
}
|
||
|
||
/**
|
||
* Card button callback (card.action.trigger). The operator must be an
|
||
* allowed sender: group members outside the allowlist must never drive
|
||
* session binding, workspace switches or other card actions.
|
||
*/
|
||
onCardAction(event) {
|
||
const operatorOpenId = nonEmptyString(event?.operator?.open_id)
|
||
?? nonEmptyString(event?.operator?.user_id)
|
||
// Keep accepting the legacy nested shape while preferring the current
|
||
// card.action.trigger v2 payload used by the official SDK.
|
||
?? nonEmptyString(event?.operator?.operator_id?.open_id)
|
||
?? nonEmptyString(event?.operator?.operator_id?.user_id)
|
||
?? nonEmptyString(event?.open_id)
|
||
?? nonEmptyString(event?.user_id);
|
||
const operatorAllowed = operatorOpenId !== null
|
||
&& (this.#allowedSenderOpenIds.has('*') || this.#allowedSenderOpenIds.has(operatorOpenId));
|
||
if (!operatorAllowed) {
|
||
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed sender');
|
||
return Promise.resolve();
|
||
}
|
||
const actionValue = callbackObject(event?.action?.value);
|
||
const formValue = callbackObject(event?.action?.form_value);
|
||
const action = nonEmptyString(actionValue.action)
|
||
?? nonEmptyString(event?.action?.action);
|
||
if (!action) return Promise.resolve();
|
||
// select_static dropdown: resolve pickers to their target actions
|
||
const option = callbackSingleOption(event?.action?.option)
|
||
?? (action.endsWith('_pick') ? callbackMultiOptionValues(event?.action?.options)[0] : null);
|
||
// The official Card 2.0 callback currently uses a comma-separated string
|
||
// for multi-select values; older SDKs emitted arrays or value objects.
|
||
const multiValues = [...new Set([
|
||
...callbackMultiOptionValues(actionValue.options),
|
||
...callbackMultiOptionValues(event?.action?.options),
|
||
...callbackMultiOptionValues(formValue[action]),
|
||
...(action === 'watch_add' || action === 'watch_remove'
|
||
? callbackMultiOptionValues(event?.action?.option)
|
||
: []),
|
||
])];
|
||
const resolvedAction = action === 'workspace_pick' && typeof option === 'string'
|
||
? `workspace:${option}`
|
||
: action === 'session_pick' && typeof option === 'string'
|
||
? `use:${option}`
|
||
: action === 'preset_pick' && typeof option === 'string'
|
||
? `preset:select:${option}`
|
||
: action === 'model_pick' && typeof option === 'string'
|
||
? `model:select:${option}`
|
||
: action === 'archive_pick' && typeof option === 'string'
|
||
? `archive:${option}`
|
||
: action === 'steer_pick' && typeof option === 'string'
|
||
? `steer:${option}`
|
||
: action;
|
||
const messageId = nonEmptyString(event?.context?.open_message_id)
|
||
?? nonEmptyString(event?.open_message_id)
|
||
?? nonEmptyString(event?.message_id);
|
||
const route = messageId ? this.#cardKeys.get(messageId) : null;
|
||
if (!route) {
|
||
// The card predates this process (the in-memory mapping resets on
|
||
// restart) or never came from us: nudge instead of staying silent.
|
||
const chatId = nonEmptyString(event?.context?.open_chat_id)
|
||
?? nonEmptyString(event?.open_chat_id)
|
||
?? nonEmptyString(event?.chat_id);
|
||
if (chatId) {
|
||
this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。')).catch(() => undefined);
|
||
}
|
||
return Promise.resolve();
|
||
}
|
||
// A used card is recent even if it was first created long ago.
|
||
this.#cardKeys.delete(messageId);
|
||
this.#cardKeys.set(messageId, route);
|
||
const entry = { ...route, messageId, selections: multiValues, operatorOpenId };
|
||
const source = nonEmptyString(actionValue.source);
|
||
const formText = nonEmptyString(formValue.steer_text);
|
||
const eventId = nonEmptyString(event?.event_id)
|
||
?? nonEmptyString(event?.header?.event_id)
|
||
?? nonEmptyString(event?.uuid)
|
||
?? nonEmptyString(event?.header?.uuid);
|
||
const identity = JSON.stringify({
|
||
messageId,
|
||
resolvedAction,
|
||
option,
|
||
multiValues,
|
||
source,
|
||
formText,
|
||
});
|
||
const isStop = resolvedAction === 'stop';
|
||
const rawSteer = resolvedAction.startsWith('steer:')
|
||
? resolvedAction.slice('steer:'.length)
|
||
: null;
|
||
const isCustomSteer = rawSteer === 'custom' || rawSteer === STEER_CUSTOM_SENTINEL;
|
||
const isRealSteer = (action === 'steer'
|
||
&& source === 'quick'
|
||
&& option !== null
|
||
&& option !== STEER_CUSTOM_SENTINEL)
|
||
|| (action === 'steer' && source === 'form' && formText !== null)
|
||
|| (rawSteer !== null && rawSteer !== '' && !isCustomSteer);
|
||
|
||
return this.#queueCardAction(entry, identity, async () => {
|
||
// 补充指令卡片:快捷下拉(source=quick, option=指令) / 表单提交(source=form)
|
||
if (action === 'steer') {
|
||
if (source === 'quick' && option) {
|
||
if (option === STEER_CUSTOM_SENTINEL) {
|
||
await this.#sendCard(entry.chatId, customSteerCard(), {
|
||
key: entry.key,
|
||
updateMessageId: entry.messageId,
|
||
});
|
||
return;
|
||
}
|
||
await this.#sendSteer(entry, option);
|
||
return;
|
||
}
|
||
if (source === 'form') {
|
||
if (formText) {
|
||
await this.#sendSteer(entry, formText);
|
||
return;
|
||
}
|
||
await this.#send(entry.chatId, t('请输入补充指令后再提交。'));
|
||
return;
|
||
}
|
||
}
|
||
await this.#handleCardAction(resolvedAction, entry);
|
||
}, {
|
||
lane: isStop || isRealSteer ? 'control' : 'regular',
|
||
coalesceStop: isStop,
|
||
eventId,
|
||
operatorOpenId,
|
||
});
|
||
}
|
||
|
||
#pruneCompletedCardActions(now = Date.now()) {
|
||
for (const [key, expiresAt] of this.#completedCardActions) {
|
||
if (expiresAt <= now) this.#completedCardActions.delete(key);
|
||
}
|
||
while (this.#completedCardActions.size > MAX_COMPLETED_CARD_ACTIONS) {
|
||
const oldest = this.#completedCardActions.keys().next().value;
|
||
if (oldest === undefined) break;
|
||
this.#completedCardActions.delete(oldest);
|
||
}
|
||
}
|
||
|
||
#rememberCompletedCardAction(key, now = Date.now()) {
|
||
if (!key) return;
|
||
this.#completedCardActions.delete(key);
|
||
this.#completedCardActions.set(key, now + CARD_ACTION_DEDUPE_TTL_MS);
|
||
this.#pruneCompletedCardActions(now);
|
||
}
|
||
|
||
#notifyCardOverflow(entry) {
|
||
const conversation = entry.key;
|
||
const now = Date.now();
|
||
const existing = this.#cardOverloadNoticeAt.get(conversation);
|
||
if (existing?.task || (existing && now - existing.at < CARD_OVERLOAD_NOTICE_COOLDOWN_MS)) {
|
||
return existing?.task ?? Promise.resolve();
|
||
}
|
||
this.#logger.warn?.('[dsh-feishu] card action queue is full; dropping callbacks');
|
||
let tracked;
|
||
tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。'))
|
||
.catch(() => undefined)
|
||
.finally(() => {
|
||
this.#cardActionTasks.delete(tracked);
|
||
const current = this.#cardOverloadNoticeAt.get(conversation);
|
||
if (current?.task === tracked) this.#cardOverloadNoticeAt.set(conversation, { at: current.at, task: null });
|
||
});
|
||
this.#cardOverloadNoticeAt.delete(conversation);
|
||
this.#cardOverloadNoticeAt.set(conversation, { at: now, task: tracked });
|
||
this.#cardActionTasks.add(tracked);
|
||
while (this.#cardOverloadNoticeAt.size > 200) {
|
||
const oldest = this.#cardOverloadNoticeAt.keys().next().value;
|
||
if (oldest === undefined) break;
|
||
this.#cardOverloadNoticeAt.delete(oldest);
|
||
}
|
||
return tracked;
|
||
}
|
||
|
||
#queueCardAction(entry, identity, task, {
|
||
lane = 'regular',
|
||
coalesceStop = false,
|
||
eventId = null,
|
||
operatorOpenId = null,
|
||
} = {}) {
|
||
const conversation = entry.key;
|
||
const completedKey = eventId
|
||
? `${conversation}\0${operatorOpenId ?? ''}\0event:${eventId}`
|
||
: null;
|
||
const dedupeKey = completedKey
|
||
?? `${conversation}\0${operatorOpenId ?? ''}\0action:${identity}`;
|
||
const now = Date.now();
|
||
this.#pruneCompletedCardActions(now);
|
||
if (completedKey && (this.#completedCardActions.get(completedKey) ?? 0) > now) {
|
||
return Promise.resolve();
|
||
}
|
||
const duplicate = this.#cardActionInFlight.get(dedupeKey);
|
||
if (duplicate) return duplicate;
|
||
if (coalesceStop) {
|
||
const pendingStop = this.#cardStopInFlight.get(conversation);
|
||
if (pendingStop) {
|
||
if (completedKey) {
|
||
// Remember only a bounded LRU of provider ids while the shared stop
|
||
// is unresolved. They become completed only after that stop settles.
|
||
this.#cardStopFollowers.delete(completedKey);
|
||
this.#cardStopFollowers.set(completedKey, pendingStop);
|
||
while (this.#cardStopFollowers.size > MAX_COMPLETED_CARD_ACTIONS) {
|
||
const oldest = this.#cardStopFollowers.keys().next().value;
|
||
if (oldest === undefined) break;
|
||
this.#cardStopFollowers.delete(oldest);
|
||
}
|
||
}
|
||
return pendingStop;
|
||
}
|
||
}
|
||
|
||
const control = lane === 'control';
|
||
const tails = control ? this.#cardControlTails : this.#cardActionTails;
|
||
const counts = control ? this.#cardControlCounts : this.#cardActionCounts;
|
||
const limit = control ? MAX_PENDING_CARD_CONTROLS_PER_KEY : MAX_PENDING_CARD_ACTIONS_PER_KEY;
|
||
const pending = counts.get(conversation) ?? 0;
|
||
// Reserve one bounded control slot for stop. All further stop clicks join
|
||
// that task, so a flood cannot grow the queue beyond limit + 1.
|
||
const pendingLimit = coalesceStop ? limit + 1 : limit;
|
||
if (pending >= pendingLimit) {
|
||
return this.#notifyCardOverflow(entry);
|
||
}
|
||
|
||
const previous = tails.get(conversation) ?? Promise.resolve();
|
||
counts.set(conversation, pending + 1);
|
||
|
||
let tracked;
|
||
let started = false;
|
||
tracked = previous
|
||
.catch(() => undefined)
|
||
.then(async () => {
|
||
this.#signal?.throwIfAborted();
|
||
started = true;
|
||
await task();
|
||
})
|
||
.catch(async (error) => {
|
||
if (this.#signal?.aborted) return;
|
||
await this.#sendFailure(entry.chatId, error, { logLabel: 'card action' });
|
||
})
|
||
.finally(() => {
|
||
if (this.#cardActionInFlight.get(dedupeKey) === tracked) {
|
||
this.#cardActionInFlight.delete(dedupeKey);
|
||
}
|
||
if (started && completedKey) {
|
||
this.#rememberCompletedCardAction(completedKey);
|
||
}
|
||
if (coalesceStop && this.#cardStopInFlight.get(conversation) === tracked) {
|
||
this.#cardStopInFlight.delete(conversation);
|
||
}
|
||
if (coalesceStop) {
|
||
const settledAt = Date.now();
|
||
for (const [followerKey, pendingStop] of this.#cardStopFollowers) {
|
||
if (pendingStop !== tracked) continue;
|
||
this.#cardStopFollowers.delete(followerKey);
|
||
this.#rememberCompletedCardAction(followerKey, settledAt);
|
||
}
|
||
}
|
||
if (tails.get(conversation) === tracked) tails.delete(conversation);
|
||
const remaining = (counts.get(conversation) ?? 1) - 1;
|
||
if (remaining > 0) counts.set(conversation, remaining);
|
||
else counts.delete(conversation);
|
||
this.#cardActionTasks.delete(tracked);
|
||
});
|
||
this.#cardActionInFlight.set(dedupeKey, tracked);
|
||
if (coalesceStop) this.#cardStopInFlight.set(conversation, tracked);
|
||
this.#cardActionTasks.add(tracked);
|
||
tails.set(conversation, tracked);
|
||
return tracked;
|
||
}
|
||
|
||
async #handleCardAction(action, {
|
||
chatId,
|
||
key,
|
||
messageId = null,
|
||
sessionWorkspace = null,
|
||
sessionPage = 0,
|
||
selections = [],
|
||
}) {
|
||
if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
|
||
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
|
||
await this.#showSessions(
|
||
{ chatId, key },
|
||
sessionWorkspace,
|
||
page,
|
||
{ updateMessageId: messageId },
|
||
);
|
||
return;
|
||
}
|
||
if (action === 'workspaces') {
|
||
await this.#showWorkspaces({ chatId, key }, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action === 'watchlist') {
|
||
await this.#showWatchList(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
// 多选关注下拉:action=watch_add / watch_remove,选中项在 selections 数组
|
||
if (action === 'watch_add' || action === 'watch_remove') {
|
||
if (selections.length === 0) {
|
||
await this.#send(chatId, t('请先选择至少一个会话。'));
|
||
return;
|
||
}
|
||
let changed = 0;
|
||
let failed = 0;
|
||
if (action === 'watch_add') {
|
||
let freshTargets = new Map();
|
||
try {
|
||
freshTargets = await this.#freshWatchTargets(sessionWorkspace);
|
||
} catch (error) {
|
||
this.#logger.warn?.('[dsh-feishu] batch watch validation failed:', error.message);
|
||
}
|
||
for (const sessionId of selections) {
|
||
const validatedTarget = freshTargets.get(sessionId);
|
||
if (!validatedTarget) {
|
||
failed += 1;
|
||
continue;
|
||
}
|
||
const result = await this.#runWatch(key, chatId, sessionId, {
|
||
notify: false,
|
||
validatedTarget,
|
||
});
|
||
if (result.changed) changed += 1;
|
||
else if (!result.ok) failed += 1;
|
||
}
|
||
} else {
|
||
for (const sessionId of selections) {
|
||
const result = await this.#runUnwatch(key, chatId, sessionId, { notify: false });
|
||
if (result.changed) changed += 1;
|
||
else if (!result.ok) failed += 1;
|
||
}
|
||
}
|
||
const summary = changed > 0 && failed > 0
|
||
? action === 'watch_add'
|
||
? t('已批量关注 {count} 个会话,另有 {failed} 个未成功。', { count: changed, failed })
|
||
: t('已取消关注 {count} 个会话,另有 {failed} 个未成功。', { count: changed, failed })
|
||
: changed > 0
|
||
? action === 'watch_add'
|
||
? t('已批量关注 {count} 个会话。', { count: changed })
|
||
: t('已取消关注 {count} 个会话。', { count: changed })
|
||
: failed > 0
|
||
? t('所选会话均未处理成功,请稍后重试。')
|
||
: action === 'watch_add'
|
||
? t('所选会话已在关注列表中。')
|
||
: t('所选会话已不在关注列表中。');
|
||
try {
|
||
await this.#showWatchList(key, chatId, { updateMessageId: messageId });
|
||
} catch (error) {
|
||
this.#logger.warn?.('[dsh-feishu] watch list refresh failed:', error.message);
|
||
}
|
||
await this.#send(chatId, summary).catch((error) => {
|
||
this.#logger.warn?.('[dsh-feishu] watch batch summary failed:', error.message);
|
||
});
|
||
return;
|
||
}
|
||
if (action === 'new') {
|
||
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
|
||
await this.#send(chatId, t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
|
||
return;
|
||
}
|
||
await this.#state.clearSession(key);
|
||
await this.#send(chatId, t('已开启全新 Harness 会话。'));
|
||
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action === 'use:current') {
|
||
const sessionId = this.#state.sessionFor(key);
|
||
if (typeof sessionId !== 'string' || !sessionId) {
|
||
await this.#send(chatId, t('当前没有绑定的会话,请先从会话列表选择。'));
|
||
return;
|
||
}
|
||
await this.#send(chatId, t('已就绪,直接发消息即可继续当前会话。'));
|
||
return;
|
||
}
|
||
if (action === 'archive_toggle' || action === 'archive:on' || action === 'archive:off') {
|
||
const next = action === 'archive:on' ? true : action === 'archive:off' ? false : !(this.#state?.includesArchivedSessions?.() ?? false);
|
||
await this.#state?.setIncludeArchivedSessions?.(next);
|
||
await this.#send(
|
||
chatId,
|
||
next ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
|
||
);
|
||
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action === 'repair') {
|
||
await this.#send(chatId, t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
|
||
return;
|
||
}
|
||
if (action === 'compact') {
|
||
await this.#handleCompact(key, chatId);
|
||
return;
|
||
}
|
||
if (action === 'stop') {
|
||
await this.#handleStop(key, chatId);
|
||
return;
|
||
}
|
||
if (action === 'steer') {
|
||
await this.#showSteerCard(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
// 主菜单「补充指令」下拉:option = steer:<指令> / steer:custom
|
||
if (action.startsWith('steer:')) {
|
||
const raw = action.slice('steer:'.length);
|
||
if (raw === 'custom') {
|
||
await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId });
|
||
return;
|
||
}
|
||
await this.#sendSteer({ key, chatId }, raw);
|
||
return;
|
||
}
|
||
if (action === 'presets') {
|
||
await this.#showPresetCard(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action === 'models') {
|
||
await this.#showModelCard(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action === 'status') {
|
||
await this.#showStatusCard(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action === 'help') {
|
||
await this.#showHelpCard(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action === 'back_to_menu') {
|
||
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action === 'preset_default') {
|
||
await this.#handlePresetDefault(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action.startsWith('preset:select:')) {
|
||
const presetId = action.slice('preset:select:'.length);
|
||
// 哨兵值 = 用户在预设下拉里选了「跟随默认」
|
||
if (presetId === PRESET_FOLLOW_DEFAULT_SENTINEL) {
|
||
await this.#handlePresetDefault(key, chatId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
await this.#handlePresetSelect(key, chatId, presetId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action.startsWith('model:select:')) {
|
||
const modelId = action.slice('model:select:'.length);
|
||
await this.#handleModelSelect(key, chatId, modelId, { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action.startsWith('use:')) {
|
||
await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action.startsWith('workspace:')) {
|
||
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length), { updateMessageId: messageId });
|
||
return;
|
||
}
|
||
if (action.startsWith('unwatch:')) {
|
||
const result = await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length));
|
||
if (result.ok && messageId) {
|
||
await this.#showSessions(
|
||
{ chatId, key },
|
||
sessionWorkspace,
|
||
sessionPage,
|
||
{ updateMessageId: messageId },
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (action.startsWith('watch:')) {
|
||
const sessionId = action.slice('watch:'.length);
|
||
const result = await this.#runWatch(key, chatId, sessionId, {
|
||
workspaceHint: sessionWorkspace,
|
||
});
|
||
if (result.ok && messageId) {
|
||
await this.#showSessions(
|
||
{ chatId, key },
|
||
sessionWorkspace,
|
||
sessionPage,
|
||
{ updateMessageId: messageId },
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#rememberMenu(key, menu) {
|
||
if (this.#menus.size >= MAX_TRACKED_MENUS) {
|
||
const oldest = this.#menus.keys().next().value;
|
||
if (oldest !== undefined) this.#menus.delete(oldest);
|
||
}
|
||
this.#menus.delete(key);
|
||
this.#menus.set(key, { ...menu, expiresAt: Date.now() + MENU_TTL_MS });
|
||
}
|
||
|
||
#takeMenu(key) {
|
||
const menu = this.#menus.get(key);
|
||
if (!menu) return null;
|
||
if (menu.expiresAt < Date.now()) {
|
||
this.#menus.delete(key);
|
||
return null;
|
||
}
|
||
return menu;
|
||
}
|
||
|
||
async #handleMenuPick(menu, number, { chatId, key, event }) {
|
||
if (menu.kind === 'menu') {
|
||
// Number fallback for the total menu:
|
||
// 1=工作区列表 2=新会话 3=会话列表 4=状态 5=修复 6=帮助
|
||
const actions = ['workspaces', 'new', 'sessions', 'status', 'repair', 'help'];
|
||
const action = actions[number - 1];
|
||
if (!action) {
|
||
await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。'));
|
||
return;
|
||
}
|
||
if (action === 'repair') {
|
||
await this.#handleRepairCommand(event, '/repair');
|
||
return;
|
||
}
|
||
await this.#handleCardAction(action, { chatId, key });
|
||
return;
|
||
}
|
||
if (menu.kind === 'sessions') {
|
||
const session = menu.sessions[number - 1];
|
||
if (!session?.sessionId) {
|
||
await this.#send(chatId, t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
|
||
return;
|
||
}
|
||
// The number label sits on the session (bind) button of the row.
|
||
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
|
||
return;
|
||
}
|
||
if (menu.kind === 'workspaces') {
|
||
const workspace = menu.paths[number - 1];
|
||
if (!workspace) {
|
||
await this.#send(chatId, t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
|
||
return;
|
||
}
|
||
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
|
||
return;
|
||
}
|
||
if (menu.kind === 'watches') {
|
||
const entry = menu.entries[number - 1];
|
||
if (!entry?.sessionId) {
|
||
await this.#send(chatId, t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
|
||
return;
|
||
}
|
||
await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
|
||
}
|
||
}
|
||
|
||
/** The sessions visible under the bot's archived policy. */
|
||
#visibleSessions(sessions) {
|
||
if (this.#state?.includesArchivedSessions?.() === false) {
|
||
return sessions.filter((session) => session.archived !== true);
|
||
}
|
||
return sessions;
|
||
}
|
||
|
||
async #showSessions(
|
||
{ chatId, key },
|
||
selector,
|
||
page = 0,
|
||
{ updateMessageId = null } = {},
|
||
) {
|
||
try {
|
||
const signal = this.#cardDataSignal();
|
||
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness, { signal });
|
||
if (resolved.error) {
|
||
await this.#send(chatId, resolved.error);
|
||
return;
|
||
}
|
||
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal });
|
||
const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
||
const workspace = listed?.workspace ?? resolved.workspace;
|
||
if (sessions.length === 0) {
|
||
await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }));
|
||
return;
|
||
}
|
||
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
|
||
const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0;
|
||
const watchedSet = new Set(
|
||
(this.#state.watchEntries?.(key) ?? []).map((entry) => entry.sessionId),
|
||
);
|
||
const pageSlice = sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE);
|
||
this.#rememberMenu(key, {
|
||
kind: 'sessions',
|
||
sessions: pageSlice.map((session) => ({ ...session, watched: watchedSet.has(session.sessionId) })),
|
||
});
|
||
await this.#sendCard(
|
||
chatId,
|
||
sessionListCard(workspace, sessions, safePage, sessions.length, watchedSet),
|
||
{
|
||
key,
|
||
updateMessageId,
|
||
// Keep the canonical selector result for later page callbacks. The
|
||
// list response's workspace is display data and is not authoritative.
|
||
sessionWorkspace: resolved.workspace,
|
||
sessionPage: safePage,
|
||
},
|
||
);
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'session list' });
|
||
}
|
||
}
|
||
|
||
async #showWorkspaces({ chatId, key }, { updateMessageId = null } = {}) {
|
||
try {
|
||
const { current, paths } = await workspacePathSnapshot(
|
||
this.#harness,
|
||
{ signal: this.#cardDataSignal() },
|
||
);
|
||
this.#rememberMenu(key, { kind: 'workspaces', paths });
|
||
await this.#sendCard(
|
||
chatId,
|
||
workspaceListCard(paths, current),
|
||
{ key, updateMessageId },
|
||
);
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'workspace list' });
|
||
}
|
||
}
|
||
|
||
async #bindSession(key, chatId, sessionId, { updateMessageId = null } = {}) {
|
||
try {
|
||
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
|
||
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
|
||
await this.#send(chatId, [
|
||
t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
|
||
t('发送 /history 查看最近对话。'),
|
||
].join('\n'));
|
||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, {
|
||
logLabel: 'session binding',
|
||
userMessage: t('绑定失败:{message}', { message: safeErrorText(error) }),
|
||
});
|
||
}
|
||
}
|
||
|
||
async #switchWorkspace(key, chatId, workspace, { updateMessageId = null } = {}) {
|
||
try {
|
||
const current = await this.#harness.switchWorkspace(workspace);
|
||
await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
|
||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, {
|
||
logLabel: 'workspace switch',
|
||
userMessage: t('切换失败:{message}', { message: safeErrorText(error) }),
|
||
});
|
||
}
|
||
}
|
||
|
||
#rememberCardRoute(messageId, chatId, options) {
|
||
if (!options.key || !messageId) return;
|
||
this.#cardKeys.delete(messageId);
|
||
this.#cardKeys.set(messageId, {
|
||
key: options.key,
|
||
chatId,
|
||
sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
|
||
? options.sessionWorkspace
|
||
: null,
|
||
sessionPage: Number.isSafeInteger(options.sessionPage) && options.sessionPage >= 0
|
||
? options.sessionPage
|
||
: 0,
|
||
});
|
||
if (this.#cardKeys.size > 200) {
|
||
const oldest = this.#cardKeys.keys().next().value;
|
||
if (oldest !== undefined) this.#cardKeys.delete(oldest);
|
||
}
|
||
}
|
||
|
||
async #sendCard(chatId, cardJson, options = {}) {
|
||
const updateMessageId = nonEmptyString(options.updateMessageId);
|
||
|
||
if (updateMessageId) {
|
||
try {
|
||
const response = await this.#client.im.v1.message.patch({
|
||
path: { message_id: updateMessageId },
|
||
data: { content: cardJson },
|
||
});
|
||
if (response?.code && response.code !== 0) {
|
||
throw new Error(`Feishu card update failed: ${response.msg || response.code}`);
|
||
}
|
||
this.#rememberCardRoute(updateMessageId, chatId, options);
|
||
return updateMessageId;
|
||
} catch (error) {
|
||
this.#logger.warn?.('[dsh-feishu] card update failed:', error?.code ?? error?.message ?? error, 'sending new');
|
||
}
|
||
}
|
||
|
||
const response = await this.#client.im.v1.message.create({
|
||
params: { receive_id_type: 'chat_id' },
|
||
data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
|
||
});
|
||
if (response?.code && response.code !== 0) {
|
||
throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
|
||
}
|
||
const messageId = nonEmptyString(response?.data?.message_id);
|
||
this.#rememberCardRoute(messageId, chatId, options);
|
||
return messageId;
|
||
}
|
||
|
||
async #sendMenuCard(key, chatId, { updateMessageId = null } = {}) {
|
||
let currentSessionId = null;
|
||
let directSessionTitle = null;
|
||
try {
|
||
const sessionId = this.#state.sessionFor(key);
|
||
if (typeof sessionId === 'string' && sessionId) {
|
||
currentSessionId = sessionId;
|
||
const session = this.#harness.workspaceSession?.(sessionId);
|
||
directSessionTitle = nonEmptyString(session?.title)
|
||
?? nonEmptyString(session?.name)
|
||
?? nonEmptyString(session?.displayName);
|
||
}
|
||
} catch { /* render without a selected session */ }
|
||
|
||
const dataSignal = this.#cardDataSignal();
|
||
// Independent sections start together. Each one degrades on its own so a
|
||
// slow preset/model RPC cannot force redundant session-list scans.
|
||
const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal })
|
||
.catch(() => {
|
||
const current = typeof this.#harness.currentWorkspace === 'function'
|
||
? this.#harness.currentWorkspace()
|
||
: null;
|
||
return { current, paths: current ? [current] : [] };
|
||
});
|
||
const sessionTask = (async () => {
|
||
const current = typeof this.#harness.currentWorkspace === 'function'
|
||
? this.#harness.currentWorkspace()
|
||
: null;
|
||
if (!current || typeof this.#harness.listWorkspaceSessions !== 'function') return [];
|
||
try {
|
||
const listed = await this.#harness.listWorkspaceSessions(current, { signal: dataSignal });
|
||
return this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
||
} catch {
|
||
return [];
|
||
}
|
||
})();
|
||
const presetTask = (async () => {
|
||
try {
|
||
const settings = await this.#harness.agentPresetSettings({ signal: dataSignal });
|
||
return { ...settings.agentPresetCatalog, _currentId: settings.agentPreset };
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
const modelTask = (async () => {
|
||
try {
|
||
if (currentSessionId) {
|
||
const session = this.#harness.workspaceSession?.(currentSessionId);
|
||
if (typeof session?.models === 'function') {
|
||
return await session.models({ signal: dataSignal });
|
||
}
|
||
}
|
||
return await this.#harness.listModels({ signal: dataSignal });
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
|
||
const [snapshot, listedSessions, presetCatalog, modelCatalog] = await Promise.all([
|
||
workspaceTask,
|
||
sessionTask,
|
||
presetTask,
|
||
modelTask,
|
||
]);
|
||
const workspaces = Array.isArray(snapshot.paths) ? snapshot.paths : [];
|
||
const currentWorkspace = snapshot.current ?? null;
|
||
const currentMatch = listedSessions.find((session) => session.sessionId === currentSessionId);
|
||
const currentSessionTitle = currentSessionId
|
||
? nonEmptyString(currentMatch?.title)
|
||
?? nonEmptyString(currentMatch?.name)
|
||
?? directSessionTitle
|
||
?? currentSessionId
|
||
: null;
|
||
let sessions = listedSessions
|
||
.map((session) => ({
|
||
id: session.sessionId,
|
||
title: session.title ?? session.name ?? session.sessionId,
|
||
}))
|
||
.slice(0, 20);
|
||
// 确保当前绑定会话始终出现在下拉最前(它可能不在最近列表里),
|
||
// 否则 initial_index 找不到默认展示项,下拉会显示占位文本。
|
||
if (currentSessionId) {
|
||
sessions = sessions.filter((s) => s.id !== currentSessionId);
|
||
sessions.unshift({ id: currentSessionId, title: currentSessionTitle ?? currentSessionId });
|
||
sessions = sessions.slice(0, 20);
|
||
}
|
||
const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
|
||
this.#rememberMenu(key, { kind: 'menu', chatId });
|
||
await this.#sendCard(
|
||
chatId,
|
||
menuCard({
|
||
workspaces, currentWorkspace,
|
||
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
||
sessions, archiveVisible, presetCatalog, modelCatalog,
|
||
}),
|
||
{ key, updateMessageId },
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Resolve a human-readable title for a bound session when available.
|
||
* Attempts the workspace-session object first, then falls back to the
|
||
* current workspace's session list. Returns null on any failure.
|
||
*/
|
||
async #resolveSessionTitle(key, sessionId) {
|
||
try {
|
||
if (typeof this.#harness.workspaceSession === 'function') {
|
||
const session = this.#harness.workspaceSession(sessionId);
|
||
if (session && typeof session === 'object') {
|
||
const direct = nonEmptyString(session.title)
|
||
?? nonEmptyString(session.name)
|
||
?? nonEmptyString(session.displayName);
|
||
if (direct) return direct;
|
||
}
|
||
}
|
||
// Fallback: scan the current workspace session list for this id
|
||
const current = typeof this.#harness.currentWorkspace === 'function'
|
||
? this.#harness.currentWorkspace()
|
||
: null;
|
||
if (current && typeof this.#harness.listWorkspaceSessions === 'function') {
|
||
const listed = await this.#harness.listWorkspaceSessions(current);
|
||
const match = (Array.isArray(listed?.sessions) ? listed.sessions : [])
|
||
.find((s) => s.sessionId === sessionId);
|
||
if (match) {
|
||
const title = nonEmptyString(match.title) ?? nonEmptyString(match.name);
|
||
if (title) return title;
|
||
}
|
||
}
|
||
} catch { /* best-effort */ }
|
||
return null;
|
||
}
|
||
|
||
// ── Sub-card handlers: preset, model, status, help, compact ──────────────
|
||
|
||
/**
|
||
* Fetch the preset catalog and show the preset selection card.
|
||
*/
|
||
async #showPresetCard(key, chatId, { updateMessageId = null } = {}) {
|
||
try {
|
||
const settings = await this.#harness.agentPresetSettings({ signal: this.#cardDataSignal() });
|
||
const catalog = settings.agentPresetCatalog;
|
||
// Inject the current preset id so the card can render the selection
|
||
catalog._currentId = settings.agentPreset;
|
||
await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId });
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'preset card' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch the model catalog and show the model selection card.
|
||
*/
|
||
async #showModelCard(key, chatId, { updateMessageId = null } = {}) {
|
||
try {
|
||
const signal = this.#cardDataSignal();
|
||
await this.#harness.ensureRunning({ signal });
|
||
// Try to get the session-bound catalog first, fall back to harness-level
|
||
const sessionId = this.#state?.sessionFor?.(key);
|
||
let catalog;
|
||
if (typeof sessionId === 'string' && sessionId) {
|
||
const session = this.#harness.workspaceSession(sessionId);
|
||
if (session?.models) {
|
||
catalog = await session.models({ signal });
|
||
}
|
||
}
|
||
if (!catalog) {
|
||
catalog = await this.#harness.listModels({ signal });
|
||
}
|
||
await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId });
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'model card' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gather system status and show the status card.
|
||
*/
|
||
async #showStatusText(key, chatId) {
|
||
try {
|
||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||
const lines = [t('连接正常')];
|
||
const ws = typeof this.#harness.currentWorkspace === 'function'
|
||
? this.#harness.currentWorkspace()
|
||
: null;
|
||
if (ws) lines.push(t('工作区:{workspace}', { workspace: ws }));
|
||
const settings = typeof this.#harness.agentPresetSettings === 'function'
|
||
? await this.#harness.agentPresetSettings({ signal: this.#signal }).catch(() => null)
|
||
: null;
|
||
if (settings) {
|
||
const item = settings.agentPresetCatalog?.items?.find((i) => i.id === settings.agentPreset);
|
||
lines.push(t('预设:{preset}', {
|
||
preset: item
|
||
? `${item.label}(${item.id})`
|
||
: (settings.agentPreset || t('跟随默认')),
|
||
}));
|
||
}
|
||
await this.#send(chatId, lines.join('\n'));
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'status text' });
|
||
}
|
||
}
|
||
|
||
async #showStatusCard(key, chatId, { updateMessageId = null } = {}) {
|
||
try {
|
||
const signal = this.#cardDataSignal();
|
||
await this.#harness.ensureRunning({ signal });
|
||
const info = { connected: true, workspace: null, preset: null, model: null, sessionCount: 0 };
|
||
|
||
// Current workspace
|
||
try {
|
||
const ws = typeof this.#harness.currentWorkspace === 'function'
|
||
? this.#harness.currentWorkspace()
|
||
: null;
|
||
info.workspace = ws || t('未知');
|
||
} catch { /* ignore */ }
|
||
|
||
// Preset
|
||
try {
|
||
const settings = await this.#harness.agentPresetSettings({ signal });
|
||
const item = settings.agentPresetCatalog.items.find((i) => i.id === settings.agentPreset);
|
||
info.preset = item
|
||
? `${item.label}(${item.id})`
|
||
: (settings.agentPreset || t('跟随默认'));
|
||
} catch { /* ignore */ }
|
||
|
||
// Model (from bound session or harness)
|
||
try {
|
||
const sessionId = this.#state?.sessionFor?.(key);
|
||
if (typeof sessionId === 'string' && sessionId) {
|
||
const session = this.#harness.workspaceSession(sessionId);
|
||
if (session?.models) {
|
||
const cat = await session.models({ signal });
|
||
if (cat.current) info.model = `${cat.current.provider}/${cat.current.model}`;
|
||
}
|
||
}
|
||
} catch { /* ignore */ }
|
||
|
||
// Session count
|
||
try {
|
||
const ws = typeof this.#harness.currentWorkspace === 'function'
|
||
? this.#harness.currentWorkspace()
|
||
: null;
|
||
if (ws) {
|
||
const listed = await this.#harness.listWorkspaceSessions(ws, { signal });
|
||
if (Array.isArray(listed?.sessions)) info.sessionCount = listed.sessions.length;
|
||
}
|
||
} catch { /* ignore */ }
|
||
|
||
await this.#sendCard(chatId, statusCard(info), { key, updateMessageId });
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'status card' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show the help card with all command descriptions.
|
||
*/
|
||
async #showHelpCard(key, chatId, { updateMessageId = null } = {}) {
|
||
await this.#sendCard(
|
||
chatId,
|
||
helpCard(WORKSPACE_HELP_LINES.map((line) => t(line))),
|
||
{ key, updateMessageId },
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Run the /compact command and show the result.
|
||
*/
|
||
async #handleCompact(key, chatId) {
|
||
try {
|
||
const result = await runCompactCommand(
|
||
'/compact', this.#harness, this.#state, key, { signal: this.#signal },
|
||
);
|
||
await this.#send(chatId, result?.message || t('上下文压缩失败。'));
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'compact' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stop the running task in the bound session (mirrors `/stop`).
|
||
*/
|
||
async #handleStop(key, chatId) {
|
||
try {
|
||
const result = await runControlCommand(
|
||
'/stop', this.#harness, this.#state, key, {
|
||
signal: this.#signal,
|
||
control: { owner: this, key },
|
||
},
|
||
);
|
||
if (result?.stopped) {
|
||
await Promise.allSettled([
|
||
this.#cancelPendingInteraction(key),
|
||
this.#approvals.closeRoute(key),
|
||
]);
|
||
}
|
||
await this.#send(chatId, result?.message || t('/stop 执行完成。'));
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'stop' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show the steer card (quick-select dropdown + free-text input).
|
||
*/
|
||
async #showSteerCard(key, chatId, { updateMessageId = null } = {}) {
|
||
const hasSession = Boolean(this.#state.sessionFor?.(key));
|
||
this.#rememberMenu(key, { kind: 'steer' });
|
||
await this.#sendCard(chatId, steerCard({ hasSession }), { key, updateMessageId });
|
||
}
|
||
|
||
/**
|
||
* Send a steer instruction to the bound session (mirrors `/steer <text>`).
|
||
*/
|
||
async #sendSteer(entry, text) {
|
||
const { key, chatId } = entry;
|
||
const result = await runControlCommand(
|
||
`/steer ${text}`, this.#harness, this.#state, key, {
|
||
signal: this.#signal,
|
||
pendingInteraction: this.#hasPendingInteraction(key),
|
||
control: { owner: this, key },
|
||
},
|
||
);
|
||
await this.#send(chatId, result?.message || t('已提交补充指令。'));
|
||
}
|
||
|
||
/**
|
||
* Reset the preset to follow the Host default.
|
||
*/
|
||
async #handlePresetDefault(key, chatId, { updateMessageId = null } = {}) {
|
||
try {
|
||
const result = await runPresetCommand(
|
||
'/preset --default', this.#harness, this.#state, key, { signal: this.#signal },
|
||
);
|
||
for (const reply of result?.messages ?? [result?.message]) {
|
||
if (reply) await this.#send(chatId, reply);
|
||
}
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'preset reset' });
|
||
return;
|
||
}
|
||
try {
|
||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||
} catch (error) {
|
||
this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset reset:', error.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle preset selection from the preset dropdown.
|
||
*/
|
||
async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null } = {}) {
|
||
try {
|
||
const selector = /^\d+$/u.test(presetId) ? `id:${presetId}` : presetId;
|
||
const result = await runPresetCommand(
|
||
`/preset ${selector}`, this.#harness, this.#state, key, { signal: this.#signal },
|
||
);
|
||
for (const reply of result?.messages ?? [result?.message]) {
|
||
if (reply) await this.#send(chatId, reply);
|
||
}
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'preset selection' });
|
||
return;
|
||
}
|
||
try {
|
||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||
} catch (error) {
|
||
this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset select:', error.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle model selection from the model dropdown.
|
||
*
|
||
* Reuses `runModelCommand` (the same path as the `/model <id>` text
|
||
* command) so model IDs containing `/` (e.g.
|
||
* `openrouter/anthropic/claude-sonnet-4`) keep working, and all the
|
||
* catalog validation, busy checks, pending-interaction checks and the
|
||
* session binding lock stay in one place.
|
||
*/
|
||
async #handleModelSelect(key, chatId, modelId, { updateMessageId = null } = {}) {
|
||
try {
|
||
const result = await runModelCommand(
|
||
`/model ${modelId}`, this.#harness, this.#state, key, {
|
||
signal: this.#signal,
|
||
pendingInteraction: this.#hasPendingInteraction(key),
|
||
control: { owner: this, key },
|
||
},
|
||
);
|
||
for (const reply of result?.messages ?? [result?.message]) {
|
||
if (reply) await this.#send(chatId, reply);
|
||
}
|
||
} catch (error) {
|
||
await this.#sendFailure(chatId, error, { logLabel: 'model selection' });
|
||
return;
|
||
}
|
||
try {
|
||
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
||
} catch (error) {
|
||
this.#logger.warn?.('[dsh-feishu] menu refresh failed after model select:', error.message);
|
||
}
|
||
}
|
||
|
||
// ── Watches: read-only session tracking + completion pushes ─────────────
|
||
|
||
#ensureEventWatcher() {
|
||
if (this.#eventWatcher) return;
|
||
if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
|
||
if (this.#signal?.aborted) return;
|
||
const signal = this.#signal ?? new AbortController().signal;
|
||
try {
|
||
this.#eventWatcher = this.#harness.watchHarnessEvents({
|
||
signal,
|
||
onSessionEvent: (payload) => this.#onHarnessEvent(payload),
|
||
onReconnect: () => {
|
||
void this.#compensateMissedEvents();
|
||
},
|
||
});
|
||
Promise.resolve(this.#eventWatcher).catch((error) => {
|
||
if (!signal.aborted) {
|
||
this.#logger.warn?.('[dsh-feishu] event watcher stopped:', error.message);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
this.#eventWatcher = null;
|
||
this.#logger.warn?.('[dsh-feishu] event watcher failed to start:', error.message);
|
||
}
|
||
}
|
||
|
||
#queueEventTask(sessionId, task) {
|
||
const previous = this.#eventTails.get(sessionId) ?? Promise.resolve();
|
||
let next;
|
||
next = previous.then(task, task).catch((error) => {
|
||
if (!this.#signal?.aborted) {
|
||
this.#logger.warn?.('[dsh-feishu] completion event failed:', error.message);
|
||
}
|
||
}).finally(() => {
|
||
if (this.#eventTails.get(sessionId) === next) this.#eventTails.delete(sessionId);
|
||
});
|
||
this.#eventTails.set(sessionId, next);
|
||
return next;
|
||
}
|
||
|
||
#pruneObservedCompletionEvents(now = Date.now()) {
|
||
let total = 0;
|
||
for (const [sessionId, observed] of this.#observedCompletionEvents) {
|
||
for (const [seq, record] of observed) {
|
||
if (!Number.isSafeInteger(record?.arrivalAt)
|
||
|| record.arrivalAt < 0
|
||
|| record.arrivalAt + COMPLETION_OBSERVATION_TTL_MS <= now) {
|
||
observed.delete(seq);
|
||
}
|
||
}
|
||
while (observed.size > MAX_OBSERVED_COMPLETIONS_PER_SESSION) {
|
||
const oldest = observed.keys().next().value;
|
||
if (oldest === undefined) break;
|
||
observed.delete(oldest);
|
||
}
|
||
if (observed.size === 0) this.#observedCompletionEvents.delete(sessionId);
|
||
else total += observed.size;
|
||
}
|
||
while (this.#observedCompletionEvents.size > MAX_OBSERVED_COMPLETION_SESSIONS) {
|
||
const oldestSessionId = this.#observedCompletionEvents.keys().next().value;
|
||
if (oldestSessionId === undefined) break;
|
||
total -= this.#observedCompletionEvents.get(oldestSessionId)?.size ?? 0;
|
||
this.#observedCompletionEvents.delete(oldestSessionId);
|
||
}
|
||
while (total > MAX_OBSERVED_COMPLETIONS) {
|
||
const oldestSessionId = this.#observedCompletionEvents.keys().next().value;
|
||
if (oldestSessionId === undefined) break;
|
||
const observed = this.#observedCompletionEvents.get(oldestSessionId);
|
||
const oldestSeq = observed?.keys().next().value;
|
||
if (oldestSeq === undefined) {
|
||
this.#observedCompletionEvents.delete(oldestSessionId);
|
||
continue;
|
||
}
|
||
observed.delete(oldestSeq);
|
||
total -= 1;
|
||
if (observed.size === 0) this.#observedCompletionEvents.delete(oldestSessionId);
|
||
}
|
||
}
|
||
|
||
#recordObservedCompletion(sessionId, event, now = Date.now()) {
|
||
this.#pruneObservedCompletionEvents(now);
|
||
let observed = this.#observedCompletionEvents.get(sessionId);
|
||
if (!observed) observed = new Map();
|
||
const rawReason = event?.data?.reason;
|
||
const reason = typeof rawReason === 'string'
|
||
? rawReason
|
||
: typeof rawReason?.kind === 'string'
|
||
? { kind: rawReason.kind }
|
||
: null;
|
||
observed.delete(event.seq);
|
||
observed.set(event.seq, {
|
||
arrivalAt: now,
|
||
event: {
|
||
type: 'turn/end',
|
||
seq: event.seq,
|
||
...(Number.isSafeInteger(event.time) && event.time >= 0 ? { time: event.time } : {}),
|
||
data: { reason },
|
||
},
|
||
});
|
||
// Refresh the session as a unit so both session and entry eviction are LRU.
|
||
this.#observedCompletionEvents.delete(sessionId);
|
||
this.#observedCompletionEvents.set(sessionId, observed);
|
||
this.#pruneObservedCompletionEvents(now);
|
||
}
|
||
|
||
/**
|
||
* Resolve a /watch target READ-ONLY: a session id is validated against
|
||
* the registered workspaces' listings, an index against the current
|
||
* workspace. Nothing is bound and no workspace is switched.
|
||
*/
|
||
async #resolveWatchTarget(target, { workspaceHint = null, signal = this.#signal } = {}) {
|
||
if (typeof target !== 'string' || target === '') {
|
||
return { error: t('用法:/watch <Session ID 或当前工作区序号>') };
|
||
}
|
||
const numeric = /^\d{1,4}$/.test(target) ? Number(target) : null;
|
||
const currentPath = typeof this.#harness?.currentWorkspace === 'function'
|
||
? this.#harness.currentWorkspace()
|
||
: null;
|
||
const listSessions = async (workspace) => {
|
||
const listed = await this.#harness.listWorkspaceSessions(workspace, { signal });
|
||
return Array.isArray(listed?.sessions) ? listed.sessions : [];
|
||
};
|
||
if (numeric !== null) {
|
||
if (!currentPath) return { error: t('当前机器人没有可用的工作区,无法按序号解析会话。') };
|
||
const sessions = this.#visibleSessions(await listSessions(currentPath));
|
||
const session = sessions[numeric - 1];
|
||
if (!session?.sessionId) {
|
||
return { error: t('当前工作区只有 {count} 个会话。', { count: sessions.length }) };
|
||
}
|
||
return {
|
||
sessionId: session.sessionId,
|
||
title: session.title ?? t('暂无标题'),
|
||
workspace: currentPath,
|
||
...(validLastSeq(session.lastSeq) ? { lastSeq: session.lastSeq } : {}),
|
||
};
|
||
}
|
||
let paths;
|
||
if (nonEmptyString(workspaceHint)) {
|
||
paths = [workspaceHint];
|
||
} else {
|
||
const extraPaths = typeof this.#harness?.listWorkspaces === 'function'
|
||
? (await this.#harness.listWorkspaces({ signal })).filter((path) => path !== currentPath)
|
||
: [];
|
||
paths = [currentPath, ...extraPaths].filter(Boolean);
|
||
}
|
||
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 ?? t('暂无标题'),
|
||
workspace,
|
||
...(validLastSeq(session.lastSeq) ? { lastSeq: session.lastSeq } : {}),
|
||
};
|
||
}
|
||
}
|
||
return { error: t('没有找到这个会话,请用 /sessionlist 查看可用会话。') };
|
||
}
|
||
|
||
async #freshWatchTargets(workspace) {
|
||
const selectedWorkspace = nonEmptyString(workspace)
|
||
?? (typeof this.#harness?.currentWorkspace === 'function'
|
||
? nonEmptyString(this.#harness.currentWorkspace())
|
||
: null);
|
||
if (!selectedWorkspace || typeof this.#harness?.listWorkspaceSessions !== 'function') {
|
||
return new Map();
|
||
}
|
||
const listed = await this.#harness.listWorkspaceSessions(
|
||
selectedWorkspace,
|
||
{ signal: this.#cardDataSignal() },
|
||
);
|
||
return new Map(this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : [])
|
||
.filter((session) => nonEmptyString(session?.sessionId))
|
||
.map((session) => [session.sessionId, {
|
||
sessionId: session.sessionId,
|
||
title: session.title ?? session.name ?? t('暂无标题'),
|
||
workspace: selectedWorkspace,
|
||
...(validLastSeq(session.lastSeq) ? { lastSeq: session.lastSeq } : {}),
|
||
}]));
|
||
}
|
||
|
||
#scheduleCompensation(sessionId) {
|
||
const pending = this.#pendingCompensations.get(sessionId);
|
||
if (pending) {
|
||
pending.requested = true;
|
||
return pending.promise;
|
||
}
|
||
const state = { requested: true, promise: null };
|
||
this.#pendingCompensations.set(sessionId, state);
|
||
state.promise = Promise.resolve().then(async () => {
|
||
try {
|
||
// A watch can be persisted after an in-progress compensation already
|
||
// snapshotted its keys. Remember that request and run one trailing pass.
|
||
do {
|
||
state.requested = false;
|
||
await this.#queueEventTask(sessionId, () => this.#compensateSession(sessionId));
|
||
} while (state.requested && !this.#signal?.aborted);
|
||
} finally {
|
||
if (this.#pendingCompensations.get(sessionId) === state) {
|
||
this.#pendingCompensations.delete(sessionId);
|
||
}
|
||
}
|
||
});
|
||
return state.promise;
|
||
}
|
||
|
||
async #runWatch(key, chatId, target, {
|
||
notify = true,
|
||
validatedTarget = null,
|
||
workspaceHint = null,
|
||
} = {}) {
|
||
const watchRequestedAt = Date.now();
|
||
const reply = async (message) => {
|
||
if (!notify) return;
|
||
await this.#send(chatId, message).catch((error) => {
|
||
this.#logger.warn?.('[dsh-feishu] watch notification failed:', error.message);
|
||
});
|
||
};
|
||
this.#ensureEventWatcher();
|
||
if (typeof this.#state?.setWatch !== 'function') {
|
||
await reply(t('当前状态存储不支持关注。'));
|
||
return { ok: false, changed: false, reason: 'unsupported' };
|
||
}
|
||
let resolved;
|
||
try {
|
||
resolved = validatedTarget?.sessionId === target
|
||
? validatedTarget
|
||
: await this.#resolveWatchTarget(target, {
|
||
workspaceHint,
|
||
signal: workspaceHint ? this.#cardDataSignal() : this.#signal,
|
||
});
|
||
} catch (error) {
|
||
await reply(t('无法解析会话:{message}', { message: safeErrorText(error) }));
|
||
return { ok: false, changed: false, reason: 'resolve' };
|
||
}
|
||
if (resolved.error) {
|
||
await reply(resolved.error);
|
||
return { ok: false, changed: false, reason: 'not-found' };
|
||
}
|
||
const existing = this.#state.watchEntries?.(key) ?? [];
|
||
const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
|
||
if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
|
||
await reply(t('每个聊天最多关注 {count} 个会话。', { count: MAX_WATCHES_PER_KEY }));
|
||
return { ok: false, changed: false, reason: 'limit' };
|
||
}
|
||
const lastSeq = validLastSeq(existingEntry?.lastSeq)
|
||
? existingEntry.lastSeq
|
||
: validLastSeq(resolved.lastSeq)
|
||
? resolved.lastSeq
|
||
: null;
|
||
// session.list's projection asOfSeq can be stale for a cold session. Every
|
||
// new watch therefore keeps a durable wall-clock boundary; lastSeq is only
|
||
// a lower bound for the history scan. Existing settled and legacy entries
|
||
// keep their prior semantics when /watch is repeated.
|
||
const existingBoundary = watchBoundary(existingEntry);
|
||
const watchStartedAt = existingBoundary ?? (existingEntry ? null : watchRequestedAt);
|
||
try {
|
||
await this.#state.setWatch(key, {
|
||
sessionId: resolved.sessionId,
|
||
title: resolved.title,
|
||
chatId,
|
||
lastSeq,
|
||
...(watchStartedAt !== null ? { watchStartedAt } : {}),
|
||
});
|
||
} catch (error) {
|
||
await reply(t('关注失败:{message}', { message: safeErrorText(error) }));
|
||
return { ok: false, changed: false, reason: 'persist' };
|
||
}
|
||
// Always compensate a newly created or still-unsettled watch. This closes
|
||
// both target-list and durable-persistence windows.
|
||
if (!existingEntry || watchStartedAt !== null || !validLastSeq(lastSeq)) {
|
||
void this.#scheduleCompensation(resolved.sessionId);
|
||
}
|
||
await reply(t('已关注会话「{title}」,任务完成会推送结果。', { title: String(resolved.title).replace(/\s+/gu, ' ') }));
|
||
return { ok: true, changed: !existingEntry, entry: this.#state.watchEntry?.(key, resolved.sessionId) };
|
||
}
|
||
|
||
async #runUnwatch(key, chatId, target, { notify = true } = {}) {
|
||
const reply = async (message) => {
|
||
if (!notify) return;
|
||
await this.#send(chatId, message).catch((error) => {
|
||
this.#logger.warn?.('[dsh-feishu] unwatch notification failed:', error.message);
|
||
});
|
||
};
|
||
if (typeof this.#state?.removeWatch !== 'function') {
|
||
return { ok: false, changed: false, reason: 'unsupported' };
|
||
}
|
||
const entries = this.#state.watchEntries?.(key) ?? [];
|
||
const entry = typeof target === 'string' && /^\d{1,4}$/.test(target)
|
||
? entries[Number(target) - 1]
|
||
: entries.find((candidate) => candidate.sessionId === target);
|
||
if (!entry) {
|
||
await reply(t('关注列表里没有这个会话,回复 /watchlist 查看。'));
|
||
return { ok: true, changed: false, reason: 'absent' };
|
||
}
|
||
try {
|
||
await this.#state.removeWatch(key, entry.sessionId);
|
||
this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
|
||
} catch (error) {
|
||
await reply(t('取消失败:{message}', { message: safeErrorText(error) }));
|
||
return { ok: false, changed: false, reason: 'persist' };
|
||
}
|
||
await reply(t('已取消关注「{title}」。', { title: String(entry.title ?? '').replace(/\s+/gu, ' ') }));
|
||
return { ok: true, changed: true, entry };
|
||
}
|
||
|
||
async #showWatchList(key, chatId, { updateMessageId = null } = {}) {
|
||
const entries = this.#state.watchEntries?.(key) ?? [];
|
||
// 收集可选会话(用于「添加关注」多选下拉);失败则传空数组 → 只渲染移除/列表。
|
||
let availableSessions = [];
|
||
let currentWorkspace = null;
|
||
try {
|
||
currentWorkspace = typeof this.#harness?.currentWorkspace === 'function'
|
||
? this.#harness.currentWorkspace()
|
||
: null;
|
||
if (currentWorkspace && typeof this.#harness?.listWorkspaceSessions === 'function') {
|
||
const listed = await this.#harness.listWorkspaceSessions(
|
||
currentWorkspace,
|
||
{ signal: this.#cardDataSignal() },
|
||
);
|
||
availableSessions = this.#visibleSessions(
|
||
Array.isArray(listed?.sessions) ? listed.sessions : [],
|
||
)
|
||
.map((session) => ({
|
||
sessionId: session.sessionId,
|
||
title: session.title ?? session.name ?? session.sessionId,
|
||
}));
|
||
}
|
||
} catch { /* add-select section degrades to remove-only */ }
|
||
this.#rememberMenu(key, { kind: 'watches', entries });
|
||
await this.#sendCard(
|
||
chatId,
|
||
watchListCard(entries, availableSessions),
|
||
{
|
||
key,
|
||
updateMessageId,
|
||
sessionWorkspace: currentWorkspace,
|
||
},
|
||
);
|
||
}
|
||
|
||
/** Queue live turn completions behind any reconnect compensation. */
|
||
#onHarnessEvent({ sessionId, event }) {
|
||
if (this.#signal?.aborted
|
||
|| !sessionId
|
||
|| !event
|
||
|| typeof event !== 'object'
|
||
|| event.type !== 'turn/end'
|
||
|| !validEventSeq(event.seq)) return;
|
||
// Record before consulting state: /watch may still be resolving its target
|
||
// or waiting for setWatch persistence and therefore have no visible entry.
|
||
this.#recordObservedCompletion(sessionId, event);
|
||
void this.#queueEventTask(sessionId, async () => {
|
||
const keys = this.#state.keysWatching?.(sessionId) ?? [];
|
||
const needsBaseline = keys.some((key) => (
|
||
watchNeedsBaseline(this.#state.watchEntry?.(key, sessionId))
|
||
));
|
||
const hasFailedDelivery = keys
|
||
.some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
|
||
if (needsBaseline || hasFailedDelivery) await this.#compensateSession(sessionId);
|
||
await this.#deliverCompletion(sessionId, event);
|
||
});
|
||
}
|
||
|
||
async #deliverCompletion(sessionId, event, { keys: targetKeys = null } = {}) {
|
||
if (this.#signal?.aborted || typeof this.#state?.keysWatching !== 'function') return;
|
||
const reason = event?.data?.reason?.kind ?? event?.data?.reason ?? null;
|
||
const keys = targetKeys ?? this.#state.keysWatching(sessionId);
|
||
for (const key of keys) {
|
||
if (this.#signal?.aborted) return;
|
||
const entry = this.#state.watchEntry?.(key, sessionId);
|
||
const deliveryKey = `${key}\0${sessionId}`;
|
||
let failedSeq = this.#failedWatchSeqs.get(deliveryKey);
|
||
if (validEventSeq(failedSeq)
|
||
&& validLastSeq(entry?.lastSeq)
|
||
&& entry.lastSeq >= failedSeq) {
|
||
this.#failedWatchSeqs.delete(deliveryKey);
|
||
failedSeq = undefined;
|
||
}
|
||
if (!entry?.chatId
|
||
|| watchNeedsBaseline(entry)
|
||
|| (validLastSeq(entry.lastSeq) && entry.lastSeq >= event.seq)
|
||
|| (validEventSeq(failedSeq) && event.seq > failedSeq)) continue;
|
||
try {
|
||
await this.#sendCard(
|
||
entry.chatId,
|
||
completionCard(sessionId, entry.title, reason),
|
||
{ key },
|
||
);
|
||
const current = this.#state.watchEntry?.(key, sessionId);
|
||
if (!current
|
||
|| current.chatId !== entry.chatId
|
||
|| watchNeedsBaseline(current)
|
||
|| (validLastSeq(current.lastSeq) && current.lastSeq >= event.seq)) continue;
|
||
await this.#state.setWatch(key, { ...current, lastSeq: event.seq });
|
||
if (failedSeq === event.seq) this.#failedWatchSeqs.delete(deliveryKey);
|
||
} catch (error) {
|
||
this.#failedWatchSeqs.set(
|
||
deliveryKey,
|
||
validEventSeq(failedSeq) ? Math.min(failedSeq, event.seq) : event.seq,
|
||
);
|
||
this.#logger.warn?.('[dsh-feishu] completion push failed:', error.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
async #compensateSession(sessionId) {
|
||
if (this.#signal?.aborted || typeof this.#harness?.rpc !== 'function') return;
|
||
try {
|
||
const history = await this.#harness.rpc(
|
||
'session.history',
|
||
{ sessionId, maxMessages: 20 },
|
||
30_000,
|
||
{ signal: this.#signal },
|
||
);
|
||
const historicalEvents = orderedHistoryEvents(history);
|
||
this.#pruneObservedCompletionEvents();
|
||
const observed = this.#observedCompletionEvents.get(sessionId) ?? new Map();
|
||
const eventsBySeq = new Map(historicalEvents.map((event) => [event.seq, event]));
|
||
// The event mux can be ahead of the history projection. Retain a bounded,
|
||
// minimal completion payload so a pre-persistence live frame is not lost.
|
||
for (const [seq, record] of observed) {
|
||
if (!eventsBySeq.has(seq)) eventsBySeq.set(seq, record.event);
|
||
}
|
||
const events = [...eventsBySeq.values()].sort((left, right) => left.seq - right.seq);
|
||
const latestSeq = events.at(-1)?.seq ?? -1;
|
||
const keys = typeof this.#state?.keysWatching === 'function'
|
||
? this.#state.keysWatching(sessionId)
|
||
: [];
|
||
|
||
// Establish independent baselines for every chat watching this Session.
|
||
// New watches carry a durable wall-clock boundary; old persisted null
|
||
// watches retain the legacy "baseline latest without replay" behavior.
|
||
for (const key of keys) {
|
||
const entry = this.#state.watchEntry?.(key, sessionId);
|
||
if (!watchNeedsBaseline(entry)) continue;
|
||
const watchStartedAt = watchBoundary(entry);
|
||
const lowerBound = validLastSeq(entry.lastSeq) ? entry.lastSeq : -1;
|
||
const firstKnownNew = watchStartedAt === null
|
||
? null
|
||
: events.find((event) => {
|
||
if (event.seq <= lowerBound) return false;
|
||
const eventTime = Number.isSafeInteger(event.time) && event.time >= 0
|
||
? event.time
|
||
: observed.get(event.seq)?.arrivalAt;
|
||
return Number.isSafeInteger(eventTime) && eventTime >= watchStartedAt;
|
||
});
|
||
// Unknown events before the first event proven post-boundary are
|
||
// conservatively part of the baseline. Sequence order proves all later
|
||
// events are post-boundary. With no proof, replay nothing.
|
||
const baselineSeq = firstKnownNew
|
||
? Math.max(lowerBound, firstKnownNew.seq - 1)
|
||
: Math.max(lowerBound, latestSeq);
|
||
const current = this.#state.watchEntry?.(key, sessionId);
|
||
if (!current
|
||
|| current.chatId !== entry.chatId
|
||
|| current.lastSeq !== entry.lastSeq
|
||
|| watchBoundary(current) !== watchStartedAt) continue;
|
||
const { watchStartedAt: _watchStartedAt, ...settled } = current;
|
||
await this.#state.setWatch(key, { ...settled, lastSeq: baselineSeq });
|
||
}
|
||
|
||
for (const event of events) {
|
||
if (event.type === 'turn/end') await this.#deliverCompletion(sessionId, event, { keys });
|
||
}
|
||
} catch (error) {
|
||
if (!this.#signal?.aborted) {
|
||
this.#logger.warn?.(`[dsh-feishu] watch compensation failed for ${sessionId}:`, error.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Replay recent turn completions missed while the mux was disconnected. */
|
||
async #compensateMissedEvents() {
|
||
const sessionIds = typeof this.#state?.watchedSessionIds === 'function'
|
||
? this.#state.watchedSessionIds()
|
||
: [];
|
||
if (this.#signal?.aborted) return;
|
||
await Promise.allSettled(sessionIds.map((sessionId) => this.#scheduleCompensation(sessionId)));
|
||
}
|
||
|
||
#interactionAskOptions(event, key, files) {
|
||
return {
|
||
timeoutMs: this.#replyTimeoutMs,
|
||
signal: this.#signal,
|
||
control: { owner: this, key },
|
||
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
||
key,
|
||
actor: senderOpenId(event),
|
||
chatId: event.message.chat_id,
|
||
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,
|
||
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
||
userMessage: artifactFailureText(artifact?.fileName, error),
|
||
reason: error?.code,
|
||
}),
|
||
sendFailureNotice: async (_artifact, _error, failure) => ({
|
||
messageId: await this.#send(
|
||
chatId,
|
||
messageFailureText(failure),
|
||
),
|
||
}),
|
||
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,
|
||
artifactSendErrors: delivery.artifactSendErrors,
|
||
};
|
||
}
|
||
return {
|
||
receipt: delivery.receipt,
|
||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||
artifactSendErrors: delivery.artifactSendErrors,
|
||
};
|
||
}
|
||
|
||
async #answerWithStream(event, key, message, { onAskComplete } = {}) {
|
||
const chatId = event.message.chat_id;
|
||
const messageId = event.message.message_id;
|
||
const text = message.content;
|
||
let askCompleted = false;
|
||
const markAskComplete = () => {
|
||
if (askCompleted) return;
|
||
askCompleted = true;
|
||
onAskComplete?.();
|
||
};
|
||
const content = hasInboundImages(message)
|
||
? await promptContentForMessage(message, { signal: this.#signal })
|
||
: undefined;
|
||
if (!this.#channel?.stream) {
|
||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||
harness: this.#harness,
|
||
state: this.#state,
|
||
key,
|
||
text,
|
||
content,
|
||
createOptions: { signal: this.#signal },
|
||
existsOptions: { signal: this.#signal },
|
||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||
});
|
||
markAskComplete();
|
||
let textReceipt;
|
||
let textSendError = null;
|
||
try {
|
||
textReceipt = await this.#sendAnswerText(
|
||
chatId,
|
||
answerTextForDelivery(answer, artifacts),
|
||
{
|
||
deliveryId: messageId,
|
||
presentation: 'feishu-text',
|
||
},
|
||
);
|
||
} catch (error) {
|
||
textSendError = channelDeliveryFailure(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;
|
||
}
|
||
if (textSendError && delivery.artifactSendErrors === 0) {
|
||
setLastMessageFailure(this.#status, textSendError);
|
||
}
|
||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
|
||
}
|
||
|
||
let promptStarted = false;
|
||
let completedAnswer = '';
|
||
let completedArtifacts = [];
|
||
let stream;
|
||
try {
|
||
stream = await this.#channel.stream(chatId, {
|
||
markdown: async (controller) => {
|
||
promptStarted = true;
|
||
const askOptions = {
|
||
...this.#interactionAskOptions(event, key, message.files),
|
||
onUpdate: async (update) => {
|
||
await controller.setContent(this.#progressText(update));
|
||
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
||
},
|
||
};
|
||
const completed = await askInWorkspaceSession({
|
||
harness: this.#harness,
|
||
state: this.#state,
|
||
key,
|
||
text,
|
||
content,
|
||
createOptions: { signal: this.#signal },
|
||
existsOptions: { signal: this.#signal },
|
||
askOptions,
|
||
});
|
||
markAskComplete();
|
||
completedAnswer = completed.answer;
|
||
completedArtifacts = completed.artifacts ?? [];
|
||
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
|
||
},
|
||
}, { replyTo: messageId });
|
||
} catch (error) {
|
||
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
||
if (completedAnswer || completedArtifacts.length > 0) {
|
||
this.#logger.warn?.(
|
||
'[dsh-feishu] native stream failed after generation; sending final text:',
|
||
error.message,
|
||
);
|
||
let textReceipt;
|
||
let textSendError = null;
|
||
try {
|
||
textReceipt = await this.#sendAnswerText(
|
||
chatId,
|
||
answerTextForDelivery(completedAnswer, completedArtifacts),
|
||
{
|
||
deliveryId: messageId,
|
||
presentation: 'feishu-text-fallback',
|
||
},
|
||
);
|
||
} catch (fallbackError) {
|
||
textSendError = channelDeliveryFailure(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;
|
||
}
|
||
if (textSendError && delivery.artifactSendErrors === 0) {
|
||
setLastMessageFailure(this.#status, textSendError);
|
||
}
|
||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
|
||
}
|
||
if (promptStarted) throw error;
|
||
|
||
this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
|
||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||
harness: this.#harness,
|
||
state: this.#state,
|
||
key,
|
||
text,
|
||
content,
|
||
createOptions: { signal: this.#signal },
|
||
existsOptions: { signal: this.#signal },
|
||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||
});
|
||
markAskComplete();
|
||
let textReceipt;
|
||
let textSendError = null;
|
||
try {
|
||
textReceipt = await this.#sendAnswerText(
|
||
chatId,
|
||
answerTextForDelivery(answer, artifacts),
|
||
{
|
||
deliveryId: messageId,
|
||
presentation: 'feishu-text-fallback',
|
||
},
|
||
);
|
||
} catch (fallbackError) {
|
||
textSendError = channelDeliveryFailure(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;
|
||
}
|
||
if (textSendError && delivery.artifactSendErrors === 0) {
|
||
setLastMessageFailure(this.#status, textSendError);
|
||
}
|
||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||
return { ...delivery, textDeliveryErrors: textSendError ? 1 : 0 };
|
||
}
|
||
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;
|
||
}
|
||
|
||
async #processInteractionReply(event, messageId, key, expected, processingReaction) {
|
||
this.#signal?.throwIfAborted();
|
||
const current = this.#pendingInteractions.get(key);
|
||
const claimed = expected.claimedReplyMessageId === messageId;
|
||
if (!current || current !== expected || current.submitting) {
|
||
if (this.#isResolvedQuestionReply(event, key)) {
|
||
return this.#discardResolvedInteractionReply(event, messageId);
|
||
}
|
||
if (claimed && (!current || current !== expected)) {
|
||
return this.#discardResolvedInteractionReply(event, messageId);
|
||
}
|
||
return this.#enqueueMessage(event, messageId, key, processingReaction, {
|
||
releaseMessageId: false,
|
||
finalize: false,
|
||
});
|
||
}
|
||
if (this.#state.hasSeen(messageId)) return;
|
||
await this.#state.markSeen(messageId);
|
||
this.#status.lastMessageAt = new Date().toISOString();
|
||
this.#status.messagesReceived += 1;
|
||
|
||
const text = extractText(event);
|
||
if (!text) {
|
||
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);
|
||
return;
|
||
}
|
||
if (claimed && (!pending || pending !== expected)) {
|
||
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT());
|
||
return;
|
||
}
|
||
return this.#enqueueMessage(event, messageId, key, processingReaction, {
|
||
releaseMessageId: false,
|
||
alreadyRecorded: true,
|
||
finalize: false,
|
||
});
|
||
}
|
||
pending.chatId = event.message.chat_id;
|
||
if (pending.needsPresentation) {
|
||
try {
|
||
await this.#presentInteraction(pending);
|
||
} catch {
|
||
this.#status.lastError = '飞书交互问题发送失败。';
|
||
this.#logger.error?.('[dsh-feishu] failed to retry an interaction question');
|
||
pending.interaction.reconnect?.();
|
||
}
|
||
return;
|
||
}
|
||
const question = pending.questions[pending.index];
|
||
if (!question) return;
|
||
|
||
pending.answers.push(harnessAnswerForQuestion(question, text));
|
||
pending.index += 1;
|
||
if (pending.index < pending.questions.length) {
|
||
if (pending.claimedReplyMessageId === messageId) {
|
||
pending.claimedReplyMessageId = null;
|
||
}
|
||
pending.needsPresentation = true;
|
||
try {
|
||
await this.#presentInteraction(pending);
|
||
} catch {
|
||
this.#status.lastError = '飞书交互问题发送失败。';
|
||
this.#logger.error?.('[dsh-feishu] failed to send the next interaction question');
|
||
pending.interaction.reconnect?.();
|
||
}
|
||
return;
|
||
}
|
||
|
||
pending.submitting = true;
|
||
try {
|
||
await pending.interaction.respond({
|
||
ok: true,
|
||
value: {
|
||
sessionId: pending.sessionId,
|
||
answer: { answers: pending.answers },
|
||
},
|
||
});
|
||
this.#rememberResolvedInteraction(key, pending);
|
||
this.#clearPendingInteraction(key, pending.interactionId);
|
||
this.#status.lastError = null;
|
||
} catch (error) {
|
||
if (this.#signal?.aborted) return;
|
||
if (this.#pendingInteractions.get(key) !== pending) return;
|
||
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);
|
||
return;
|
||
}
|
||
pending.submitting = false;
|
||
pending.answers.pop();
|
||
pending.index -= 1;
|
||
this.#status.lastError = '回答提交失败。';
|
||
this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
|
||
await this.#send(event.message.chat_id, t('回答提交失败,请重新发送当前问题的答案。'))
|
||
.catch(() => undefined);
|
||
}
|
||
}
|
||
|
||
async #handleInteraction(interaction, {
|
||
key,
|
||
actor,
|
||
chatId,
|
||
requiresMention,
|
||
}) {
|
||
if (await this.#approvals.handleRequested(interaction, {
|
||
key,
|
||
actor,
|
||
requiresMention,
|
||
send: (text) => this.#send(chatId, text),
|
||
})) return;
|
||
|
||
// Approval requests return above; the existing question state machine stays unchanged.
|
||
if (interaction?.kind !== 'question') return;
|
||
const questions = interaction?.payload?.questions;
|
||
const interactionId = typeof interaction?.interactionId === 'string'
|
||
? interaction.interactionId
|
||
: interaction?.rpcId;
|
||
if (typeof interaction.rpcId !== 'string'
|
||
|| typeof interactionId !== 'string'
|
||
|| typeof interaction.sessionId !== 'string'
|
||
|| !Array.isArray(questions)
|
||
|| questions.length === 0
|
||
|| questions.some((question) => !validHarnessQuestion(question))) {
|
||
this.#logger.warn?.('[dsh-feishu] ignored an invalid Harness question interaction');
|
||
return;
|
||
}
|
||
|
||
if (interaction.recovered === true) {
|
||
await interaction.respond({
|
||
ok: false,
|
||
error: {
|
||
code: 'cancelled',
|
||
message: 'Feishu safely cancelled an interaction left by an earlier client.',
|
||
details: {},
|
||
},
|
||
});
|
||
await this.#send(
|
||
chatId,
|
||
t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
|
||
).catch(() => undefined);
|
||
return;
|
||
}
|
||
|
||
const existing = this.#pendingInteractions.get(key);
|
||
if (existing?.interactionId === interactionId) {
|
||
existing.interaction = interaction;
|
||
if (existing.needsPresentation) await this.#presentInteraction(existing);
|
||
return;
|
||
}
|
||
if (this.#interactionKeys.has(interactionId)) return;
|
||
if (existing) {
|
||
await interaction.respond({
|
||
ok: false,
|
||
error: {
|
||
code: 'cancelled',
|
||
message: 'Feishu is already handling another user interaction.',
|
||
details: {},
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
|
||
const pending = {
|
||
kind: 'question',
|
||
interactionId,
|
||
sessionId: interaction.sessionId,
|
||
interaction,
|
||
key,
|
||
actor,
|
||
requiresMention,
|
||
questions,
|
||
answers: [],
|
||
index: 0,
|
||
chatId,
|
||
queue: null,
|
||
claimedReplyMessageId: null,
|
||
submitting: false,
|
||
needsPresentation: true,
|
||
questionMessageIds: new Set(),
|
||
inactive: false,
|
||
};
|
||
this.#pendingInteractions.set(key, pending);
|
||
this.#interactionKeys.set(pending.interactionId, key);
|
||
await this.#presentInteraction(pending);
|
||
}
|
||
|
||
async #handleInteractionResolved(resolution) {
|
||
if (await this.#approvals.handleResolved(resolution)) return;
|
||
const interactionId = resolution?.interactionId;
|
||
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
||
const key = this.#interactionKeys.get(interactionId);
|
||
if (!key) return;
|
||
const pending = this.#pendingInteractions.get(key);
|
||
if (pending) this.#rememberResolvedInteraction(key, pending);
|
||
this.#clearPendingInteraction(key, interactionId);
|
||
}
|
||
|
||
async #presentInteraction(pending) {
|
||
const question = pending.questions[pending.index];
|
||
if (!question) return;
|
||
const messageId = await this.#send(
|
||
pending.chatId,
|
||
harnessQuestionText(
|
||
question,
|
||
pending.index,
|
||
pending.questions.length,
|
||
{ requiresMention: pending.requiresMention },
|
||
),
|
||
);
|
||
if (messageId) {
|
||
pending.questionMessageIds.add(messageId);
|
||
if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);
|
||
}
|
||
pending.needsPresentation = false;
|
||
}
|
||
|
||
#rememberResolvedInteraction(key, pending) {
|
||
const expiresAt = Date.now() + RESOLVED_REPLY_TTL_MS;
|
||
for (const messageId of pending.questionMessageIds ?? []) {
|
||
this.#resolvedQuestionReplies.set(messageId, { key, expiresAt });
|
||
}
|
||
}
|
||
|
||
#isResolvedQuestionReply(event, key) {
|
||
const now = Date.now();
|
||
for (const [messageId, resolution] of this.#resolvedQuestionReplies) {
|
||
if (resolution.expiresAt <= now) this.#resolvedQuestionReplies.delete(messageId);
|
||
}
|
||
for (const reference of [event?.message?.parent_id, event?.message?.root_id]) {
|
||
const resolution = this.#resolvedQuestionReplies.get(reference);
|
||
if (resolution?.key === key && resolution.expiresAt > now) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async #discardResolvedInteractionReply(event, messageId) {
|
||
if (this.#state.hasSeen(messageId)) return;
|
||
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);
|
||
}
|
||
|
||
#takePendingInteraction(key, interactionId) {
|
||
const pending = this.#pendingInteractions.get(key);
|
||
if (!pending
|
||
|| (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
|
||
this.#pendingInteractions.delete(key);
|
||
this.#interactionKeys.delete(pending.interactionId);
|
||
pending.inactive = true;
|
||
return pending;
|
||
}
|
||
|
||
#clearPendingInteraction(key, interactionId) {
|
||
return this.#takePendingInteraction(key, interactionId) !== null;
|
||
}
|
||
|
||
async #cancelPendingInteraction(key) {
|
||
const pending = this.#takePendingInteraction(key);
|
||
if (!pending || pending.kind !== 'question') return;
|
||
this.#rememberResolvedInteraction(key, pending);
|
||
try {
|
||
await pending.interaction.respond({
|
||
ok: false,
|
||
error: {
|
||
code: 'cancelled',
|
||
message: 'The Feishu interaction ended before the user answered.',
|
||
details: {},
|
||
},
|
||
}, { signal: AbortSignal.timeout(5_000) });
|
||
} catch (error) {
|
||
if (error?.code !== 'interaction-not-pending') {
|
||
this.#logger.warn?.('[dsh-feishu] failed to cancel a pending Harness interaction');
|
||
}
|
||
}
|
||
}
|
||
|
||
#progressText(update) {
|
||
if (update.type === 'text' && update.text) return update.text;
|
||
if (update.type === 'tool') {
|
||
if (update.name === 'web_search') return t('_正在搜索网络并整理信息…_');
|
||
return t('_正在使用 {name}…_', { name: update.name || t('工具') });
|
||
}
|
||
return t('_{text}_', { text: update.text || t('正在处理…') });
|
||
}
|
||
|
||
#beginReaction(messageId) {
|
||
return beginStatusReaction({
|
||
adapter: this.#channel,
|
||
target: messageId,
|
||
reactions: { processing: 'OnIt', success: 'DONE', error: 'ERROR' },
|
||
status: this.#status,
|
||
logger: this.#logger,
|
||
label: 'feishu',
|
||
});
|
||
}
|
||
|
||
#removeProcessingReaction(_messageId, processingReaction) {
|
||
processingReaction.clear();
|
||
}
|
||
|
||
#finishReaction(_messageId, processingReaction, finalEmojiType) {
|
||
if (finalEmojiType === 'ERROR') processingReaction.error();
|
||
else processingReaction.success();
|
||
}
|
||
|
||
async #send(chatId, text) {
|
||
const response = await this.#client.im.v1.message.create({
|
||
params: { receive_id_type: 'chat_id' },
|
||
data: {
|
||
receive_id: chatId,
|
||
msg_type: 'text',
|
||
content: JSON.stringify({ text }),
|
||
},
|
||
});
|
||
if (response?.code && response.code !== 0) {
|
||
throw new Error(`Feishu send failed: ${response.msg || response.code}`);
|
||
}
|
||
return nonEmptyString(response?.data?.message_id);
|
||
}
|
||
}
|