@xmanrui/dsh-im 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +233 -43
- package/lib/index.js +138 -131
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/api.js +23 -1
- package/plugin-src/client/channels/feishu/index.js +235 -46
- package/plugin-src/client/channels/feishu/styles.js +2 -0
- package/plugin-src/client/i18n.js +31 -0
- package/plugin-src/host/channels/feishu/production.mjs +3 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +146 -12
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +1080 -1
- package/src/channels/feishu/feishu-cards.mjs +230 -0
- package/src/channels/feishu/feishu-runtime.mjs +198 -0
- package/src/channels/feishu/multi-bot-controller.mjs +357 -2
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/feishu/repair-manager.mjs +109 -0
- package/src/channels/feishu/state-store.mjs +85 -1
- package/src/channels/shared/harness-client.mjs +119 -0
- package/src/channels/shared/workspace-command.mjs +2 -2
- package/src/channels/weixin/weixin-api.mjs +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import QRCode from 'qrcode';
|
|
1
2
|
import {
|
|
2
3
|
conversationKey,
|
|
3
4
|
extractInboundMessage,
|
|
@@ -27,12 +28,49 @@ import {
|
|
|
27
28
|
isModelCommand,
|
|
28
29
|
runModelCommand,
|
|
29
30
|
} from '../shared/model-command.mjs';
|
|
30
|
-
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
31
|
+
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
|
31
32
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
33
|
+
import {
|
|
34
|
+
MENU_PAGE_SIZE,
|
|
35
|
+
completionCard,
|
|
36
|
+
menuCard,
|
|
37
|
+
menuHelpText,
|
|
38
|
+
sessionListCard,
|
|
39
|
+
watchListCard,
|
|
40
|
+
workspaceListCard,
|
|
41
|
+
} from './feishu-cards.mjs';
|
|
42
|
+
import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
|
|
32
43
|
|
|
33
44
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
34
45
|
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
35
46
|
|
|
47
|
+
const MENU_COMMAND = /^\/m(?:enu)?$/i;
|
|
48
|
+
const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
|
|
49
|
+
const REPAIR_COMMAND = /^\/repair(?:\s+(qr|status|cancel|verify))?\s*$/i;
|
|
50
|
+
const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
|
|
51
|
+
const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
|
|
52
|
+
const WATCHLIST_COMMAND = /^\/watchlist$/i;
|
|
53
|
+
const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
|
|
54
|
+
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
|
55
|
+
const NUMBER_REPLY = /^\d{1,2}$/;
|
|
56
|
+
/** A displayed menu stays number-tappable for this long. */
|
|
57
|
+
const MENU_TTL_MS = 10 * 60_000;
|
|
58
|
+
const MAX_TRACKED_MENUS = 50;
|
|
59
|
+
const REPAIR_LINK_WAIT_MS = 15_000;
|
|
60
|
+
const REPAIR_POLL_INTERVAL_MS = 1_000;
|
|
61
|
+
const REPAIR_ACTIVE_STATES = new Set([
|
|
62
|
+
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched', 'saving',
|
|
63
|
+
]);
|
|
64
|
+
const REPAIR_TERMINAL_STATES = new Set([
|
|
65
|
+
'succeeded', 'expired', 'cancelled', 'error',
|
|
66
|
+
]);
|
|
67
|
+
const REPAIR_URL_HOSTS = new Set([
|
|
68
|
+
'accounts.feishu.cn',
|
|
69
|
+
'open.feishu.cn',
|
|
70
|
+
'accounts.larksuite.com',
|
|
71
|
+
'open.larksuite.com',
|
|
72
|
+
]);
|
|
73
|
+
|
|
36
74
|
const HELP_TEXT = [
|
|
37
75
|
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
|
38
76
|
'',
|
|
@@ -49,18 +87,130 @@ const HELP_TEXT = [
|
|
|
49
87
|
'/stop 停止当前任务',
|
|
50
88
|
'/steer 补充指令 纠偏当前任务',
|
|
51
89
|
'/status 检查连接状态',
|
|
90
|
+
'/repair 修复卡片按钮回调',
|
|
91
|
+
'/m(或 /menu) 打开交互卡片菜单',
|
|
92
|
+
'/watch [Session ID 或序号] 关注会话,任务完成自动推送',
|
|
93
|
+
'/unwatch [Session ID 或序号] 取消关注',
|
|
94
|
+
'/watchlist 查看关注列表',
|
|
95
|
+
'/archived on|off 会话列表是否包含归档会话',
|
|
52
96
|
'/help 显示本帮助',
|
|
53
97
|
].join('\n');
|
|
54
98
|
|
|
99
|
+
const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
|
|
100
|
+
|
|
101
|
+
/** Safe user-facing text for bind/workspace failures (no raw messages). */
|
|
102
|
+
function safeErrorText(error) {
|
|
103
|
+
switch (error?.code) {
|
|
104
|
+
case 'workspace-not-absolute':
|
|
105
|
+
return '工作区必须是绝对路径。';
|
|
106
|
+
case 'workspace-not-found':
|
|
107
|
+
return '工作区路径不存在。';
|
|
108
|
+
case 'workspace-not-directory':
|
|
109
|
+
return '工作区路径必须指向一个目录。';
|
|
110
|
+
case 'workspace-bot-not-found':
|
|
111
|
+
return '机器人正在移除或已重新接入,无法操作原会话的工作区。';
|
|
112
|
+
default:
|
|
113
|
+
return '操作失败,请稍后重试。';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
55
117
|
function nonEmptyString(value) {
|
|
56
118
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
57
119
|
}
|
|
58
120
|
|
|
121
|
+
function orderedHistoryEvents(history) {
|
|
122
|
+
return (Array.isArray(history?.events) ? history.events : [])
|
|
123
|
+
.map((entry) => entry?.event ?? entry)
|
|
124
|
+
.filter((entry) => entry && typeof entry === 'object' && Number.isFinite(entry.seq))
|
|
125
|
+
.sort((left, right) => left.seq - right.seq);
|
|
126
|
+
}
|
|
127
|
+
|
|
59
128
|
function senderOpenId(event) {
|
|
60
129
|
return nonEmptyString(event?.sender?.sender_id?.open_id)
|
|
61
130
|
?? nonEmptyString(event?.sender?.sender_id?.user_id);
|
|
62
131
|
}
|
|
63
132
|
|
|
133
|
+
function strictSenderOpenId(event) {
|
|
134
|
+
return nonEmptyString(event?.sender?.sender_id?.open_id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function abortableDelay(milliseconds, signal) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
if (signal?.aborted) {
|
|
140
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const timer = setTimeout(done, milliseconds);
|
|
144
|
+
timer.unref?.();
|
|
145
|
+
function done() {
|
|
146
|
+
signal?.removeEventListener('abort', aborted);
|
|
147
|
+
resolve();
|
|
148
|
+
}
|
|
149
|
+
function aborted() {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
152
|
+
}
|
|
153
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function repairSnapshot(value, { botId } = {}) {
|
|
158
|
+
const source = value && typeof value === 'object' ? value : {};
|
|
159
|
+
const registration = source.registration && typeof source.registration === 'object'
|
|
160
|
+
? source.registration
|
|
161
|
+
: source;
|
|
162
|
+
const operation = nonEmptyString(registration.operation) ?? nonEmptyString(source.operation);
|
|
163
|
+
if (operation && operation !== 'callback_repair') {
|
|
164
|
+
throw new Error('The active Feishu operation is not a callback repair');
|
|
165
|
+
}
|
|
166
|
+
const selectedBotId = nonEmptyString(registration.botId) ?? nonEmptyString(source.botId);
|
|
167
|
+
if (botId && selectedBotId && selectedBotId !== botId) {
|
|
168
|
+
throw new Error('The Feishu repair belongs to another bot');
|
|
169
|
+
}
|
|
170
|
+
const state = nonEmptyString(registration.state);
|
|
171
|
+
const attempt = registration.attemptId ?? registration.attempt;
|
|
172
|
+
const attemptId = typeof attempt === 'string' || Number.isFinite(attempt)
|
|
173
|
+
? String(attempt)
|
|
174
|
+
: null;
|
|
175
|
+
if (!state || !attemptId) throw new Error('Feishu returned an invalid repair status');
|
|
176
|
+
const verificationUrl = nonEmptyString(registration.verificationUrl)
|
|
177
|
+
?? nonEmptyString(registration.qrCodeUrl);
|
|
178
|
+
const expiresAt = Number(registration.expiresAt);
|
|
179
|
+
const remainingSeconds = Number(registration.remainingSeconds);
|
|
180
|
+
const pollIntervalMs = Number(registration.pollIntervalMs)
|
|
181
|
+
|| (Number(registration.pollIntervalSeconds) * 1000);
|
|
182
|
+
return {
|
|
183
|
+
state,
|
|
184
|
+
attemptId,
|
|
185
|
+
botId: selectedBotId,
|
|
186
|
+
verificationUrl,
|
|
187
|
+
expiresAt: Number.isFinite(expiresAt) ? expiresAt : null,
|
|
188
|
+
remainingSeconds: Number.isFinite(remainingSeconds) ? remainingSeconds : null,
|
|
189
|
+
pollIntervalMs: Number.isFinite(pollIntervalMs) && pollIntervalMs > 0
|
|
190
|
+
? pollIntervalMs
|
|
191
|
+
: null,
|
|
192
|
+
error: registration.error && typeof registration.error === 'object'
|
|
193
|
+
? { code: nonEmptyString(registration.error.code), message: nonEmptyString(registration.error.message) }
|
|
194
|
+
: null,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function safeRepairUrl(rawUrl, expectedAppId) {
|
|
199
|
+
const url = new URL(rawUrl);
|
|
200
|
+
if (url.protocol !== 'https:' || !REPAIR_URL_HOSTS.has(url.hostname)) {
|
|
201
|
+
throw new Error('Feishu returned an untrusted repair URL');
|
|
202
|
+
}
|
|
203
|
+
if (url.searchParams.get('tp') !== 'sdk'
|
|
204
|
+
|| url.searchParams.get('clientID') !== expectedAppId
|
|
205
|
+
|| url.searchParams.has('createOnly')) {
|
|
206
|
+
throw new Error('Feishu returned an invalid existing-app repair URL');
|
|
207
|
+
}
|
|
208
|
+
if (url.toString().includes('{{client_id}}') || url.toString().includes('%7B%7Bclient_id%7D%7D')) {
|
|
209
|
+
throw new Error('Feishu returned an unresolved client id placeholder');
|
|
210
|
+
}
|
|
211
|
+
return url.toString();
|
|
212
|
+
}
|
|
213
|
+
|
|
64
214
|
function canClaimInteractionReply(event, pending) {
|
|
65
215
|
return pending.needsPresentation !== true
|
|
66
216
|
&& pending.questions[pending.index]
|
|
@@ -97,6 +247,24 @@ export class FeishuHarnessBridge {
|
|
|
97
247
|
#replyTimeoutMs;
|
|
98
248
|
#logger;
|
|
99
249
|
#signal;
|
|
250
|
+
#botId;
|
|
251
|
+
#appId;
|
|
252
|
+
#repair;
|
|
253
|
+
#repairOwnerOpenIds;
|
|
254
|
+
#repairAttempt = null;
|
|
255
|
+
#repairMonitorVersion = 0;
|
|
256
|
+
#repairPollIntervalMs;
|
|
257
|
+
#repairLinkWaitMs;
|
|
258
|
+
/** Number-tappable menus: conversation key → menu state. */
|
|
259
|
+
#menus = new Map();
|
|
260
|
+
/** Interactive-card message id → route context for button callbacks. */
|
|
261
|
+
#cardKeys = new Map();
|
|
262
|
+
/** The global event-mux watcher (one per bridge). */
|
|
263
|
+
#eventWatcher = null;
|
|
264
|
+
/** Serializes live completions and reconnect compensation. */
|
|
265
|
+
#eventTail = Promise.resolve();
|
|
266
|
+
/** Earliest completion that still needs delivery for each watch. */
|
|
267
|
+
#failedWatchSeqs = new Map();
|
|
100
268
|
|
|
101
269
|
constructor({
|
|
102
270
|
client,
|
|
@@ -105,6 +273,12 @@ export class FeishuHarnessBridge {
|
|
|
105
273
|
state,
|
|
106
274
|
status,
|
|
107
275
|
allowedSenderOpenIds = new Set(),
|
|
276
|
+
botId,
|
|
277
|
+
appId,
|
|
278
|
+
repair,
|
|
279
|
+
repairOwnerOpenIds,
|
|
280
|
+
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
|
281
|
+
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
|
108
282
|
replyTimeoutMs = 600_000,
|
|
109
283
|
logger = console,
|
|
110
284
|
signal,
|
|
@@ -112,17 +286,45 @@ export class FeishuHarnessBridge {
|
|
|
112
286
|
if (!client || !harness || !state || !status) {
|
|
113
287
|
throw new TypeError('Feishu bridge dependencies are required');
|
|
114
288
|
}
|
|
289
|
+
if (repair !== undefined && repair !== null) {
|
|
290
|
+
if (!repair || typeof repair.start !== 'function'
|
|
291
|
+
|| typeof repair.status !== 'function'
|
|
292
|
+
|| typeof repair.cancel !== 'function') {
|
|
293
|
+
throw new TypeError('Feishu repair capability requires start/status/cancel');
|
|
294
|
+
}
|
|
295
|
+
if (!nonEmptyString(botId) || !nonEmptyString(appId)) {
|
|
296
|
+
throw new TypeError('Feishu repair capability requires botId and appId');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!Number.isFinite(repairPollIntervalMs) || repairPollIntervalMs <= 0
|
|
300
|
+
|| !Number.isFinite(repairLinkWaitMs) || repairLinkWaitMs <= 0) {
|
|
301
|
+
throw new TypeError('Feishu repair timing values must be positive numbers');
|
|
302
|
+
}
|
|
115
303
|
this.#client = client;
|
|
116
304
|
this.#channel = channel;
|
|
117
305
|
this.#harness = harness;
|
|
118
306
|
this.#state = state;
|
|
119
307
|
this.#status = status;
|
|
120
308
|
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
|
309
|
+
this.#botId = nonEmptyString(botId);
|
|
310
|
+
this.#appId = nonEmptyString(appId);
|
|
311
|
+
this.#repair = repair ?? null;
|
|
312
|
+
const repairOwners = repairOwnerOpenIds ?? allowedSenderOpenIds;
|
|
313
|
+
this.#repairOwnerOpenIds = new Set(
|
|
314
|
+
[...(repairOwners ?? [])].filter((value) => typeof value === 'string' && value && value !== '*'),
|
|
315
|
+
);
|
|
316
|
+
this.#repairPollIntervalMs = repairPollIntervalMs;
|
|
317
|
+
this.#repairLinkWaitMs = repairLinkWaitMs;
|
|
121
318
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
122
319
|
this.#logger = logger;
|
|
123
320
|
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
|
124
321
|
this.#signal = signal;
|
|
125
322
|
ensureStatus(this.#status);
|
|
323
|
+
// Persisted watches must resume at runtime start, not on the first
|
|
324
|
+
// message. Older hosts without the mux watcher simply skip this.
|
|
325
|
+
if (typeof this.#harness?.watchHarnessEvents === 'function') {
|
|
326
|
+
queueMicrotask(() => this.#ensureEventWatcher());
|
|
327
|
+
}
|
|
126
328
|
}
|
|
127
329
|
|
|
128
330
|
accept(event) {
|
|
@@ -347,6 +549,7 @@ export class FeishuHarnessBridge {
|
|
|
347
549
|
)),
|
|
348
550
|
...this.#interactionTasks,
|
|
349
551
|
...this.#commandTasks,
|
|
552
|
+
this.#eventTail,
|
|
350
553
|
]);
|
|
351
554
|
}
|
|
352
555
|
|
|
@@ -400,10 +603,19 @@ export class FeishuHarnessBridge {
|
|
|
400
603
|
return;
|
|
401
604
|
}
|
|
402
605
|
|
|
606
|
+
if (commandText !== null && REPAIR_COMMAND_PREFIX.test(commandText)) {
|
|
607
|
+
await this.#handleRepairCommand(event, commandText);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
403
610
|
if (commandText === '/help') {
|
|
404
611
|
await this.#send(event.message.chat_id, HELP_TEXT);
|
|
405
612
|
return;
|
|
406
613
|
}
|
|
614
|
+
if (MENU_COMMAND.test(commandText)) {
|
|
615
|
+
this.#rememberMenu(key, { kind: 'menu', chatId: event.message.chat_id });
|
|
616
|
+
await this.#sendCard(event.message.chat_id, menuCard(), { key });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
407
619
|
if (commandText === '/new') {
|
|
408
620
|
await this.#state.clearSession(key);
|
|
409
621
|
await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
|
|
@@ -414,6 +626,56 @@ export class FeishuHarnessBridge {
|
|
|
414
626
|
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
|
415
627
|
return;
|
|
416
628
|
}
|
|
629
|
+
if (SESSION_LIST_PREFIX.test(commandText)) {
|
|
630
|
+
const selector = commandText.slice('/sessionlist'.length).trim() || null;
|
|
631
|
+
await this.#showSessions({ chatId: event.message.chat_id, key }, selector, 0);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (WORKSPACE_LIST_COMMAND.test(commandText)) {
|
|
635
|
+
await this.#showWorkspaces({ chatId: event.message.chat_id, key });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (WATCH_COMMAND.test(commandText)) {
|
|
639
|
+
const target = (WATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
|
|
640
|
+
await this.#runWatch(key, event.message.chat_id, target);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (UNWATCH_COMMAND.test(commandText)) {
|
|
644
|
+
const target = (UNWATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
|
|
645
|
+
await this.#runUnwatch(key, event.message.chat_id, target);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (WATCHLIST_COMMAND.test(commandText)) {
|
|
649
|
+
await this.#showWatchList(key, event.message.chat_id);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (ARCHIVED_COMMAND.test(commandText)) {
|
|
653
|
+
const match = ARCHIVED_COMMAND.exec(commandText);
|
|
654
|
+
const value = match[1]?.toLowerCase();
|
|
655
|
+
if (value !== 'on' && value !== 'off') {
|
|
656
|
+
await this.#send(event.message.chat_id, '用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)');
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
|
|
660
|
+
await this.#state.setIncludeArchivedSessions(value === 'on');
|
|
661
|
+
}
|
|
662
|
+
await this.#send(
|
|
663
|
+
event.message.chat_id,
|
|
664
|
+
value === 'on' ? '已开启:会话列表包含归档会话。' : '已关闭:会话列表隐藏归档会话。',
|
|
665
|
+
);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (NUMBER_REPLY.test(commandText)) {
|
|
669
|
+
const menu = this.#takeMenu(key);
|
|
670
|
+
if (menu) {
|
|
671
|
+
await this.#handleMenuPick(menu, Number(commandText), {
|
|
672
|
+
chatId: event.message.chat_id,
|
|
673
|
+
key,
|
|
674
|
+
event,
|
|
675
|
+
});
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
417
679
|
const workspaceCommand = commandText === null
|
|
418
680
|
? null
|
|
419
681
|
: await runWorkspaceCommand(text, this.#harness, key);
|
|
@@ -449,6 +711,823 @@ export class FeishuHarnessBridge {
|
|
|
449
711
|
}
|
|
450
712
|
}
|
|
451
713
|
|
|
714
|
+
// ── Interactive cards: menus and session/workspace lists ────────────────
|
|
715
|
+
|
|
716
|
+
// Existing-app callback repair. This path deliberately uses ordinary text
|
|
717
|
+
// and number replies because callback buttons are the capability being fixed.
|
|
718
|
+
async #handleRepairCommand(event, commandText) {
|
|
719
|
+
if (event?.message?.chat_type !== 'p2p') {
|
|
720
|
+
await this.#send(event.message.chat_id, '为避免授权链接暴露,请私聊机器人发送 /repair。');
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const actorOpenId = strictSenderOpenId(event);
|
|
724
|
+
if (!actorOpenId || !this.#repairOwnerOpenIds.has(actorOpenId)) {
|
|
725
|
+
await this.#send(
|
|
726
|
+
event.message.chat_id,
|
|
727
|
+
this.#repairOwnerOpenIds.size === 0
|
|
728
|
+
? '当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。'
|
|
729
|
+
: '此操作只能由机器人接入者在私聊中发起,未进行任何修改。',
|
|
730
|
+
);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (!this.#repair) {
|
|
734
|
+
await this.#send(event.message.chat_id, '当前 Host 版本暂不支持聊天内修复,请先更新插件。');
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const parsed = REPAIR_COMMAND.exec(commandText);
|
|
739
|
+
if (!parsed) {
|
|
740
|
+
await this.#send(event.message.chat_id, '用法:/repair、/repair qr、/repair status、/repair cancel 或 /repair verify');
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const operation = parsed[1]?.toLowerCase() ?? 'start';
|
|
744
|
+
const chatId = event.message.chat_id;
|
|
745
|
+
if (operation === 'start') {
|
|
746
|
+
await this.#startRepair({ actorOpenId, chatId });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const attempt = this.#repairAttempt;
|
|
751
|
+
if (!attempt) {
|
|
752
|
+
await this.#send(
|
|
753
|
+
chatId,
|
|
754
|
+
'当前 Runtime 没有可恢复的修复任务记录(机器人可能刚完成密钥更新并重启)。本命令不会启动新的授权;请查看机器人发送的验证结果,确认上一次任务已结束后再发送 /repair。',
|
|
755
|
+
);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (attempt.actorOpenId !== actorOpenId) {
|
|
759
|
+
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (operation === 'cancel') {
|
|
763
|
+
let snapshot;
|
|
764
|
+
try {
|
|
765
|
+
const result = await this.#repair.cancel(this.#repairArgs(attempt));
|
|
766
|
+
snapshot = repairSnapshot(result, { botId: this.#botId });
|
|
767
|
+
attempt.snapshot = snapshot;
|
|
768
|
+
} catch {
|
|
769
|
+
await this.#send(chatId, '暂时无法取消修复任务,请稍后重试。');
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (snapshot.state === 'cancelled') {
|
|
773
|
+
attempt.stopped = true;
|
|
774
|
+
this.#repairMonitorVersion += 1;
|
|
775
|
+
}
|
|
776
|
+
await this.#send(chatId, this.#repairStatusText(snapshot));
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
let snapshot;
|
|
781
|
+
try {
|
|
782
|
+
snapshot = await this.#refreshRepairAttempt(attempt);
|
|
783
|
+
} catch {
|
|
784
|
+
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (operation === 'qr') {
|
|
788
|
+
if (!REPAIR_ACTIVE_STATES.has(snapshot.state) || !attempt.verificationUrl) {
|
|
789
|
+
await this.#send(chatId, this.#repairStatusText(snapshot, { verificationFocused: true }));
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
await this.#sendRepairQr(chatId, attempt.verificationUrl, snapshot);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
await this.#send(chatId, this.#repairStatusText(snapshot, {
|
|
796
|
+
verificationFocused: operation === 'verify',
|
|
797
|
+
}));
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
#repairArgs(attempt) {
|
|
801
|
+
return {
|
|
802
|
+
botId: this.#botId,
|
|
803
|
+
attemptId: attempt.attemptId,
|
|
804
|
+
actorOpenId: attempt.actorOpenId,
|
|
805
|
+
chatId: attempt.chatId,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async #startRepair({ actorOpenId, chatId }) {
|
|
810
|
+
const previous = this.#repairAttempt;
|
|
811
|
+
if (previous && REPAIR_ACTIVE_STATES.has(previous.snapshot.state)) {
|
|
812
|
+
if (previous.actorOpenId !== actorOpenId) {
|
|
813
|
+
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。');
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
try {
|
|
817
|
+
const current = await this.#refreshRepairAttempt(previous);
|
|
818
|
+
if (REPAIR_ACTIVE_STATES.has(current.state) && previous.verificationUrl) {
|
|
819
|
+
await this.#sendRepairLink(chatId, previous.verificationUrl, current, { existing: true });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
} catch {
|
|
823
|
+
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
let snapshot;
|
|
829
|
+
try {
|
|
830
|
+
snapshot = repairSnapshot(await this.#repair.start({
|
|
831
|
+
botId: this.#botId,
|
|
832
|
+
actorOpenId,
|
|
833
|
+
chatId,
|
|
834
|
+
}), { botId: this.#botId });
|
|
835
|
+
snapshot = await this.#waitForRepairLink(snapshot, { actorOpenId, chatId });
|
|
836
|
+
} catch {
|
|
837
|
+
await this.#send(chatId, '修复流程暂时失败,现有机器人连接不受影响;请稍后发送 /repair 重试。');
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const attempt = {
|
|
841
|
+
attemptId: snapshot.attemptId,
|
|
842
|
+
actorOpenId,
|
|
843
|
+
chatId,
|
|
844
|
+
snapshot,
|
|
845
|
+
verificationUrl: null,
|
|
846
|
+
stopped: false,
|
|
847
|
+
announcedSaving: false,
|
|
848
|
+
announcedTerminal: false,
|
|
849
|
+
};
|
|
850
|
+
this.#repairAttempt = attempt;
|
|
851
|
+
|
|
852
|
+
if (snapshot.verificationUrl) {
|
|
853
|
+
try {
|
|
854
|
+
attempt.verificationUrl = safeRepairUrl(snapshot.verificationUrl, this.#appId);
|
|
855
|
+
} catch {
|
|
856
|
+
attempt.stopped = true;
|
|
857
|
+
await this.#repair.cancel(this.#repairArgs(attempt)).catch(() => undefined);
|
|
858
|
+
await this.#send(chatId, '飞书返回了无法安全验证的授权链接,已中止本次修复。');
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
|
|
863
|
+
attempt.announcedTerminal = true;
|
|
864
|
+
if (snapshot.state !== 'succeeded') {
|
|
865
|
+
await this.#send(chatId, this.#repairStatusText(snapshot));
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (!attempt.verificationUrl) {
|
|
870
|
+
attempt.stopped = true;
|
|
871
|
+
await this.#send(chatId, '飞书未返回授权链接,已中止本次修复。');
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot);
|
|
875
|
+
this.#monitorRepair(attempt);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async #waitForRepairLink(initial, context) {
|
|
879
|
+
let current = initial;
|
|
880
|
+
const deadline = Date.now() + this.#repairLinkWaitMs;
|
|
881
|
+
while (!current.verificationUrl && REPAIR_ACTIVE_STATES.has(current.state)) {
|
|
882
|
+
if (Date.now() >= deadline) throw new Error('Feishu repair link timed out');
|
|
883
|
+
await abortableDelay(Math.min(100, this.#repairPollIntervalMs), this.#signal);
|
|
884
|
+
current = repairSnapshot(await this.#repair.status({
|
|
885
|
+
botId: this.#botId,
|
|
886
|
+
attemptId: current.attemptId,
|
|
887
|
+
actorOpenId: context.actorOpenId,
|
|
888
|
+
chatId: context.chatId,
|
|
889
|
+
}), { botId: this.#botId });
|
|
890
|
+
}
|
|
891
|
+
return current;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async #refreshRepairAttempt(attempt) {
|
|
895
|
+
const snapshot = repairSnapshot(
|
|
896
|
+
await this.#repair.status(this.#repairArgs(attempt)),
|
|
897
|
+
{ botId: this.#botId },
|
|
898
|
+
);
|
|
899
|
+
if (snapshot.attemptId !== attempt.attemptId) {
|
|
900
|
+
throw new Error('Feishu repair attempt changed unexpectedly');
|
|
901
|
+
}
|
|
902
|
+
attempt.snapshot = snapshot;
|
|
903
|
+
if (snapshot.verificationUrl) {
|
|
904
|
+
attempt.verificationUrl = safeRepairUrl(snapshot.verificationUrl, this.#appId);
|
|
905
|
+
}
|
|
906
|
+
return snapshot;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
#monitorRepair(attempt) {
|
|
910
|
+
const version = ++this.#repairMonitorVersion;
|
|
911
|
+
void (async () => {
|
|
912
|
+
while (!attempt.stopped && this.#repairAttempt === attempt
|
|
913
|
+
&& this.#repairMonitorVersion === version
|
|
914
|
+
&& !this.#signal?.aborted) {
|
|
915
|
+
const delayMs = Math.max(
|
|
916
|
+
250,
|
|
917
|
+
Math.min(10_000, attempt.snapshot.pollIntervalMs ?? this.#repairPollIntervalMs),
|
|
918
|
+
);
|
|
919
|
+
await abortableDelay(delayMs, this.#signal);
|
|
920
|
+
if (attempt.stopped || this.#repairAttempt !== attempt || this.#repairMonitorVersion !== version) return;
|
|
921
|
+
const snapshot = await this.#refreshRepairAttempt(attempt);
|
|
922
|
+
if (snapshot.state === 'saving' && !attempt.announcedSaving) {
|
|
923
|
+
attempt.announcedSaving = true;
|
|
924
|
+
await this.#send(
|
|
925
|
+
attempt.chatId,
|
|
926
|
+
'授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。',
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
|
|
930
|
+
attempt.stopped = true;
|
|
931
|
+
// Runtime sends the verified-success notice before resolving the
|
|
932
|
+
// controller probe. Avoid duplicating it here; failure terminals
|
|
933
|
+
// still need an explicit chat-side explanation.
|
|
934
|
+
if (snapshot.state !== 'succeeded' && !attempt.announcedTerminal) {
|
|
935
|
+
attempt.announcedTerminal = true;
|
|
936
|
+
await this.#send(attempt.chatId, this.#repairStatusText(snapshot));
|
|
937
|
+
}
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
})().catch(async () => {
|
|
942
|
+
if (this.#signal?.aborted || attempt.stopped || this.#repairAttempt !== attempt) return;
|
|
943
|
+
attempt.stopped = true;
|
|
944
|
+
this.#logger.warn?.('[dsh-feishu] callback repair status monitoring failed');
|
|
945
|
+
await this.#send(
|
|
946
|
+
attempt.chatId,
|
|
947
|
+
'修复状态查询中断,现有机器人连接不受影响;发送 /repair status 重试查询。',
|
|
948
|
+
).catch(() => undefined);
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async #sendRepairLink(chatId, url, snapshot, { existing = false } = {}) {
|
|
953
|
+
const remaining = snapshot.remainingSeconds
|
|
954
|
+
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
|
|
955
|
+
const expiry = remaining === null
|
|
956
|
+
? '链接为短期有效'
|
|
957
|
+
: `链接约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟后过期`;
|
|
958
|
+
await this.#send(chatId, [
|
|
959
|
+
existing ? '已有一个修复任务在等待授权。' : '🔧 准备修复卡片按钮。',
|
|
960
|
+
'本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。',
|
|
961
|
+
'',
|
|
962
|
+
'当前设备直接打开:',
|
|
963
|
+
url,
|
|
964
|
+
'',
|
|
965
|
+
`若要用另一台设备扫码,发送 /repair qr。${expiry}。`,
|
|
966
|
+
].join('\n'));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async #sendRepairQr(chatId, url, snapshot) {
|
|
970
|
+
try {
|
|
971
|
+
const image = await QRCode.toBuffer(url, {
|
|
972
|
+
errorCorrectionLevel: 'M', margin: 1, width: 480, type: 'png',
|
|
973
|
+
});
|
|
974
|
+
const uploaded = await this.#client.im.v1.image.create({
|
|
975
|
+
data: { image_type: 'message', image },
|
|
976
|
+
});
|
|
977
|
+
const imageKey = nonEmptyString(uploaded?.image_key) ?? nonEmptyString(uploaded?.data?.image_key);
|
|
978
|
+
if (!imageKey) throw new Error('Feishu QR upload returned no image key');
|
|
979
|
+
const remaining = snapshot.remainingSeconds
|
|
980
|
+
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
|
|
981
|
+
await this.#send(
|
|
982
|
+
chatId,
|
|
983
|
+
`请用另一台设备扫码完成授权${remaining === null ? '' : `(剩余约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟)`}。`,
|
|
984
|
+
);
|
|
985
|
+
const response = await this.#client.im.v1.message.create({
|
|
986
|
+
params: { receive_id_type: 'chat_id' },
|
|
987
|
+
data: {
|
|
988
|
+
receive_id: chatId,
|
|
989
|
+
msg_type: 'image',
|
|
990
|
+
content: JSON.stringify({ image_key: imageKey }),
|
|
991
|
+
},
|
|
992
|
+
});
|
|
993
|
+
if (response?.code && response.code !== 0) throw new Error('Feishu QR message send failed');
|
|
994
|
+
} catch {
|
|
995
|
+
await this.#send(chatId, `二维码暂时无法发送,请直接打开授权链接:\n${url}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
#repairStatusText(snapshot, { verificationFocused = false } = {}) {
|
|
1000
|
+
if (snapshot.state === 'succeeded') {
|
|
1001
|
+
return '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
|
|
1002
|
+
}
|
|
1003
|
+
if (snapshot.state === 'expired' || snapshot.error?.code === 'expired_token') {
|
|
1004
|
+
return '授权链接已过期;平台未返回成功结果,无法确认已修复。发送 /repair 生成新链接。';
|
|
1005
|
+
}
|
|
1006
|
+
if (snapshot.state === 'cancelled' || snapshot.error?.code === 'abort') {
|
|
1007
|
+
return '已取消本次修复授权,未确认完成修复。';
|
|
1008
|
+
}
|
|
1009
|
+
if (snapshot.error?.code === 'access_denied') {
|
|
1010
|
+
return '你已取消或拒绝授权,没有确认修复;发送 /repair 可重试。';
|
|
1011
|
+
}
|
|
1012
|
+
if (snapshot.error?.code === 'card_action_probe_timeout'
|
|
1013
|
+
|| snapshot.error?.code === 'card-action-probe-timeout') {
|
|
1014
|
+
return '授权已提交,但未收到测试按钮回调。可能尚未点击或配置仍在传播;稍后发送 /repair verify 查询,不要盲目重复授权。';
|
|
1015
|
+
}
|
|
1016
|
+
if (snapshot.state === 'error') {
|
|
1017
|
+
return '修复流程暂时失败,现有机器人连接不受影响;发送 /repair 可重试。';
|
|
1018
|
+
}
|
|
1019
|
+
if (snapshot.state === 'saving') {
|
|
1020
|
+
return '授权已确认,正在等待专用测试按钮的真实回调;回调到达前不会宣告成功。';
|
|
1021
|
+
}
|
|
1022
|
+
if (verificationFocused) {
|
|
1023
|
+
return '授权尚未完成,暂时不能验证卡片按钮。请先打开授权链接并确认。';
|
|
1024
|
+
}
|
|
1025
|
+
const remaining = snapshot.remainingSeconds === null
|
|
1026
|
+
? ''
|
|
1027
|
+
: `,剩余约 ${Math.max(1, Math.ceil(snapshot.remainingSeconds / 60))} 分钟`;
|
|
1028
|
+
return `修复任务正在等待授权${remaining}。发送 /repair qr 可获取二维码,/repair cancel 可取消。`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Card button callback (card.action.trigger). The operator must be an
|
|
1033
|
+
* allowed sender: group members outside the allowlist must never drive
|
|
1034
|
+
* session binding, workspace switches or other card actions.
|
|
1035
|
+
*/
|
|
1036
|
+
onCardAction(event) {
|
|
1037
|
+
const operatorOpenId = nonEmptyString(event?.operator?.open_id)
|
|
1038
|
+
?? nonEmptyString(event?.operator?.user_id)
|
|
1039
|
+
// Keep accepting the legacy nested shape while preferring the current
|
|
1040
|
+
// card.action.trigger v2 payload used by the official SDK.
|
|
1041
|
+
?? nonEmptyString(event?.operator?.operator_id?.open_id)
|
|
1042
|
+
?? nonEmptyString(event?.operator?.operator_id?.user_id);
|
|
1043
|
+
const operatorAllowed = operatorOpenId !== null
|
|
1044
|
+
&& (this.#allowedSenderOpenIds.has('*') || this.#allowedSenderOpenIds.has(operatorOpenId));
|
|
1045
|
+
if (!operatorAllowed) {
|
|
1046
|
+
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed sender');
|
|
1047
|
+
return Promise.resolve();
|
|
1048
|
+
}
|
|
1049
|
+
const action = typeof event?.action?.value?.action === 'string'
|
|
1050
|
+
? event.action.value.action
|
|
1051
|
+
: null;
|
|
1052
|
+
if (!action) return Promise.resolve();
|
|
1053
|
+
const messageId = nonEmptyString(event?.context?.open_message_id);
|
|
1054
|
+
const entry = messageId ? this.#cardKeys.get(messageId) : null;
|
|
1055
|
+
if (!entry) {
|
|
1056
|
+
// The card predates this process (the in-memory mapping resets on
|
|
1057
|
+
// restart) or never came from us: nudge instead of staying silent.
|
|
1058
|
+
const chatId = nonEmptyString(event?.context?.open_chat_id);
|
|
1059
|
+
if (chatId) {
|
|
1060
|
+
this.#send(chatId, '这个菜单已过期,请回复 /m 重新打开。').catch(() => undefined);
|
|
1061
|
+
}
|
|
1062
|
+
return Promise.resolve();
|
|
1063
|
+
}
|
|
1064
|
+
// The promise is returned so tests (and future callers) can await the
|
|
1065
|
+
// action; the runtime dispatcher ignores it.
|
|
1066
|
+
return this.#handleCardAction(action, entry).catch((error) => {
|
|
1067
|
+
this.#logger.warn?.('[dsh-feishu] card action failed:', error.message);
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async #handleCardAction(action, { chatId, key, sessionWorkspace = null }) {
|
|
1072
|
+
if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
|
|
1073
|
+
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
|
|
1074
|
+
await this.#showSessions({ chatId, key }, sessionWorkspace, page);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (action === 'workspaces') {
|
|
1078
|
+
await this.#showWorkspaces({ chatId, key });
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (action === 'watchlist') {
|
|
1082
|
+
await this.#showWatchList(key, chatId);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (action === 'new') {
|
|
1086
|
+
await this.#state.clearSession(key);
|
|
1087
|
+
await this.#send(chatId, '已开启全新 Harness 会话。');
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (action === 'status') {
|
|
1091
|
+
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
1092
|
+
await this.#send(chatId, '飞书机器人与 DeepSeek Harness 连接正常。');
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (action === 'help') {
|
|
1096
|
+
await this.#send(chatId, menuHelpText());
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (action.startsWith('use:')) {
|
|
1100
|
+
await this.#bindSession(key, chatId, action.slice('use:'.length));
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (action.startsWith('workspace:')) {
|
|
1104
|
+
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (action.startsWith('unwatch:')) {
|
|
1108
|
+
await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length));
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (action.startsWith('watch:')) {
|
|
1112
|
+
await this.#runWatch(key, chatId, action.slice('watch:'.length));
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
#rememberMenu(key, menu) {
|
|
1117
|
+
if (this.#menus.size >= MAX_TRACKED_MENUS) {
|
|
1118
|
+
const oldest = this.#menus.keys().next().value;
|
|
1119
|
+
if (oldest !== undefined) this.#menus.delete(oldest);
|
|
1120
|
+
}
|
|
1121
|
+
this.#menus.delete(key);
|
|
1122
|
+
this.#menus.set(key, { ...menu, expiresAt: Date.now() + MENU_TTL_MS });
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
#takeMenu(key) {
|
|
1126
|
+
const menu = this.#menus.get(key);
|
|
1127
|
+
if (!menu) return null;
|
|
1128
|
+
if (menu.expiresAt < Date.now()) {
|
|
1129
|
+
this.#menus.delete(key);
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
return menu;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async #handleMenuPick(menu, number, { chatId, key, event }) {
|
|
1136
|
+
if (menu.kind === 'menu') {
|
|
1137
|
+
const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair', 'watchlist'][number - 1];
|
|
1138
|
+
if (!action) {
|
|
1139
|
+
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (action === 'repair') {
|
|
1143
|
+
await this.#handleRepairCommand(event, '/repair');
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
await this.#handleCardAction(action, { chatId, key });
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
if (menu.kind === 'sessions') {
|
|
1150
|
+
const session = menu.sessions[number - 1];
|
|
1151
|
+
if (!session?.sessionId) {
|
|
1152
|
+
await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
// The number label sits on the session (bind) button of the row.
|
|
1156
|
+
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (menu.kind === 'workspaces') {
|
|
1160
|
+
const workspace = menu.paths[number - 1];
|
|
1161
|
+
if (!workspace) {
|
|
1162
|
+
await this.#send(chatId, `只有 ${menu.paths.length} 个工作区,回复 /workspacelist 重新查看。`);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (menu.kind === 'watches') {
|
|
1169
|
+
const entry = menu.entries[number - 1];
|
|
1170
|
+
if (!entry?.sessionId) {
|
|
1171
|
+
await this.#send(chatId, `关注列表只有 ${menu.entries.length} 个会话。`);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** The sessions visible under the bot's archived policy. */
|
|
1179
|
+
#visibleSessions(sessions) {
|
|
1180
|
+
if (this.#state?.includesArchivedSessions?.() === false) {
|
|
1181
|
+
return sessions.filter((session) => session.archived !== true);
|
|
1182
|
+
}
|
|
1183
|
+
return sessions;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async #showSessions({ chatId, key }, selector, page = 0) {
|
|
1187
|
+
try {
|
|
1188
|
+
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness);
|
|
1189
|
+
if (resolved.error) {
|
|
1190
|
+
await this.#send(chatId, resolved.error);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace);
|
|
1194
|
+
const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
|
1195
|
+
const workspace = listed?.workspace ?? resolved.workspace;
|
|
1196
|
+
if (sessions.length === 0) {
|
|
1197
|
+
await this.#send(chatId, `工作区:${workspace}\n该工作区暂无会话。`);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
|
|
1201
|
+
const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0;
|
|
1202
|
+
const watchedSet = new Set(
|
|
1203
|
+
(this.#state.watchEntries?.(key) ?? []).map((entry) => entry.sessionId),
|
|
1204
|
+
);
|
|
1205
|
+
const pageSlice = sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE);
|
|
1206
|
+
this.#rememberMenu(key, {
|
|
1207
|
+
kind: 'sessions',
|
|
1208
|
+
sessions: pageSlice.map((session) => ({ ...session, watched: watchedSet.has(session.sessionId) })),
|
|
1209
|
+
});
|
|
1210
|
+
await this.#sendCard(
|
|
1211
|
+
chatId,
|
|
1212
|
+
sessionListCard(workspace, sessions, safePage, sessions.length, watchedSet),
|
|
1213
|
+
{
|
|
1214
|
+
key,
|
|
1215
|
+
// Keep the canonical selector result for later page callbacks. The
|
|
1216
|
+
// list response's workspace is display data and is not authoritative.
|
|
1217
|
+
sessionWorkspace: resolved.workspace,
|
|
1218
|
+
},
|
|
1219
|
+
);
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
|
|
1222
|
+
await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。');
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
async #showWorkspaces({ chatId, key }) {
|
|
1227
|
+
try {
|
|
1228
|
+
const { current, paths } = await workspacePathSnapshot(this.#harness);
|
|
1229
|
+
this.#rememberMenu(key, { kind: 'workspaces', paths });
|
|
1230
|
+
await this.#sendCard(chatId, workspaceListCard(paths, current), { key });
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message);
|
|
1233
|
+
await this.#send(chatId, '暂时无法获取工作区列表,请稍后重试。');
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async #bindSession(key, chatId, sessionId) {
|
|
1238
|
+
try {
|
|
1239
|
+
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
|
|
1240
|
+
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || '暂无标题';
|
|
1241
|
+
await this.#send(chatId, `已绑定会话「${title}」\nID:${bound?.sessionId ?? sessionId}`);
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
await this.#send(chatId, `绑定失败:${safeErrorText(error)}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async #switchWorkspace(key, chatId, workspace) {
|
|
1248
|
+
try {
|
|
1249
|
+
const current = await this.#harness.switchWorkspace(workspace);
|
|
1250
|
+
await this.#send(chatId, `工作区已切换为:${current}`);
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
await this.#send(chatId, `切换失败:${safeErrorText(error)}`);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async #sendCard(chatId, cardJson, options = {}) {
|
|
1257
|
+
const response = await this.#client.im.v1.message.create({
|
|
1258
|
+
params: { receive_id_type: 'chat_id' },
|
|
1259
|
+
data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
|
|
1260
|
+
});
|
|
1261
|
+
if (response?.code && response.code !== 0) {
|
|
1262
|
+
throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
|
|
1263
|
+
}
|
|
1264
|
+
const messageId = nonEmptyString(response?.data?.message_id);
|
|
1265
|
+
if (options.key && messageId) {
|
|
1266
|
+
this.#cardKeys.set(messageId, {
|
|
1267
|
+
key: options.key,
|
|
1268
|
+
chatId,
|
|
1269
|
+
sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
|
|
1270
|
+
? options.sessionWorkspace
|
|
1271
|
+
: null,
|
|
1272
|
+
});
|
|
1273
|
+
if (this.#cardKeys.size > 200) {
|
|
1274
|
+
const oldest = this.#cardKeys.keys().next().value;
|
|
1275
|
+
if (oldest !== undefined) this.#cardKeys.delete(oldest);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return messageId;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// ── Watches: read-only session tracking + completion pushes ─────────────
|
|
1282
|
+
|
|
1283
|
+
#ensureEventWatcher() {
|
|
1284
|
+
if (this.#eventWatcher) return;
|
|
1285
|
+
if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
|
|
1286
|
+
if (this.#signal?.aborted) return;
|
|
1287
|
+
const signal = this.#signal ?? new AbortController().signal;
|
|
1288
|
+
try {
|
|
1289
|
+
this.#eventWatcher = this.#harness.watchHarnessEvents({
|
|
1290
|
+
signal,
|
|
1291
|
+
onSessionEvent: (payload) => this.#onHarnessEvent(payload),
|
|
1292
|
+
onReconnect: () => {
|
|
1293
|
+
void this.#queueEventTask(() => this.#compensateMissedEvents());
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
Promise.resolve(this.#eventWatcher).catch((error) => {
|
|
1297
|
+
if (!signal.aborted) {
|
|
1298
|
+
this.#logger.warn?.('[dsh-feishu] event watcher stopped:', error.message);
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
this.#eventWatcher = null;
|
|
1303
|
+
this.#logger.warn?.('[dsh-feishu] event watcher failed to start:', error.message);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
#queueEventTask(task) {
|
|
1308
|
+
const next = this.#eventTail.then(task, task).catch((error) => {
|
|
1309
|
+
if (!this.#signal?.aborted) {
|
|
1310
|
+
this.#logger.warn?.('[dsh-feishu] completion event failed:', error.message);
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
this.#eventTail = next;
|
|
1314
|
+
return next;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Resolve a /watch target READ-ONLY: a session id is validated against
|
|
1319
|
+
* the registered workspaces' listings, an index against the current
|
|
1320
|
+
* workspace. Nothing is bound and no workspace is switched.
|
|
1321
|
+
*/
|
|
1322
|
+
async #resolveWatchTarget(target) {
|
|
1323
|
+
if (typeof target !== 'string' || target === '') {
|
|
1324
|
+
return { error: '用法:/watch <Session ID 或当前工作区序号>' };
|
|
1325
|
+
}
|
|
1326
|
+
const numeric = /^\d{1,4}$/.test(target) ? Number(target) : null;
|
|
1327
|
+
const currentPath = typeof this.#harness?.currentWorkspace === 'function'
|
|
1328
|
+
? this.#harness.currentWorkspace()
|
|
1329
|
+
: null;
|
|
1330
|
+
const listSessions = async (workspace) => {
|
|
1331
|
+
const listed = await this.#harness.listWorkspaceSessions(workspace);
|
|
1332
|
+
return Array.isArray(listed?.sessions) ? listed.sessions : [];
|
|
1333
|
+
};
|
|
1334
|
+
if (numeric !== null) {
|
|
1335
|
+
if (!currentPath) return { error: '当前机器人没有可用的工作区,无法按序号解析会话。' };
|
|
1336
|
+
const sessions = this.#visibleSessions(await listSessions(currentPath));
|
|
1337
|
+
const session = sessions[numeric - 1];
|
|
1338
|
+
if (!session?.sessionId) {
|
|
1339
|
+
return { error: `当前工作区只有 ${sessions.length} 个会话。` };
|
|
1340
|
+
}
|
|
1341
|
+
return { sessionId: session.sessionId, title: session.title ?? '暂无标题' };
|
|
1342
|
+
}
|
|
1343
|
+
const extraPaths = typeof this.#harness?.listWorkspaces === 'function'
|
|
1344
|
+
? (await this.#harness.listWorkspaces()).filter((path) => path !== currentPath)
|
|
1345
|
+
: [];
|
|
1346
|
+
const paths = [currentPath, ...extraPaths].filter(Boolean);
|
|
1347
|
+
for (const workspace of paths) {
|
|
1348
|
+
const sessions = await listSessions(workspace);
|
|
1349
|
+
const session = sessions.find((candidate) => candidate.sessionId === target);
|
|
1350
|
+
if (session) return { sessionId: target, title: session.title ?? '暂无标题' };
|
|
1351
|
+
}
|
|
1352
|
+
return { error: '没有找到这个会话,请用 /sessionlist 查看可用会话。' };
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
async #latestSessionSeq(sessionId) {
|
|
1356
|
+
if (typeof this.#harness?.rpc !== 'function') return null;
|
|
1357
|
+
const history = await this.#harness.rpc(
|
|
1358
|
+
'session.history',
|
|
1359
|
+
{ sessionId, maxMessages: 20 },
|
|
1360
|
+
30_000,
|
|
1361
|
+
{ signal: this.#signal },
|
|
1362
|
+
);
|
|
1363
|
+
return orderedHistoryEvents(history).at(-1)?.seq ?? -1;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async #runWatch(key, chatId, target) {
|
|
1367
|
+
this.#ensureEventWatcher();
|
|
1368
|
+
if (typeof this.#state?.setWatch !== 'function') {
|
|
1369
|
+
await this.#send(chatId, '当前状态存储不支持关注。');
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
let resolved;
|
|
1373
|
+
try {
|
|
1374
|
+
resolved = await this.#resolveWatchTarget(target);
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
await this.#send(chatId, `无法解析会话:${safeErrorText(error)}`);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (resolved.error) {
|
|
1380
|
+
await this.#send(chatId, resolved.error);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const existing = this.#state.watchEntries?.(key) ?? [];
|
|
1384
|
+
const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
|
|
1385
|
+
if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
|
|
1386
|
+
await this.#send(chatId, `每个聊天最多关注 ${MAX_WATCHES_PER_KEY} 个会话。`);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
try {
|
|
1390
|
+
const lastSeq = typeof existingEntry?.lastSeq === 'number'
|
|
1391
|
+
? existingEntry.lastSeq
|
|
1392
|
+
: await this.#latestSessionSeq(resolved.sessionId);
|
|
1393
|
+
await this.#state.setWatch(key, {
|
|
1394
|
+
sessionId: resolved.sessionId,
|
|
1395
|
+
title: resolved.title,
|
|
1396
|
+
chatId,
|
|
1397
|
+
lastSeq,
|
|
1398
|
+
});
|
|
1399
|
+
await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`);
|
|
1400
|
+
await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
await this.#send(chatId, `关注失败:${safeErrorText(error)}`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
async #runUnwatch(key, chatId, target) {
|
|
1407
|
+
if (typeof this.#state?.removeWatch !== 'function') return;
|
|
1408
|
+
const entries = this.#state.watchEntries?.(key) ?? [];
|
|
1409
|
+
const entry = typeof target === 'string' && /^\d{1,4}$/.test(target)
|
|
1410
|
+
? entries[Number(target) - 1]
|
|
1411
|
+
: entries.find((candidate) => candidate.sessionId === target);
|
|
1412
|
+
if (!entry) {
|
|
1413
|
+
await this.#send(chatId, '关注列表里没有这个会话,回复 /watchlist 查看。');
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
try {
|
|
1417
|
+
await this.#state.removeWatch(key, entry.sessionId);
|
|
1418
|
+
this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
|
|
1419
|
+
await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`);
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
await this.#send(chatId, `取消失败:${safeErrorText(error)}`);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
async #showWatchList(key, chatId) {
|
|
1426
|
+
const entries = this.#state.watchEntries?.(key) ?? [];
|
|
1427
|
+
this.#rememberMenu(key, { kind: 'watches', entries });
|
|
1428
|
+
await this.#sendCard(chatId, watchListCard(entries), { key });
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** Queue live turn completions behind any reconnect compensation. */
|
|
1432
|
+
#onHarnessEvent({ sessionId, event }) {
|
|
1433
|
+
if (this.#signal?.aborted
|
|
1434
|
+
|| !sessionId
|
|
1435
|
+
|| !event
|
|
1436
|
+
|| typeof event !== 'object'
|
|
1437
|
+
|| event.type !== 'turn/end'
|
|
1438
|
+
|| !Number.isFinite(event.seq)) return;
|
|
1439
|
+
void this.#queueEventTask(async () => {
|
|
1440
|
+
const hasFailedDelivery = (this.#state.keysWatching?.(sessionId) ?? [])
|
|
1441
|
+
.some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
|
|
1442
|
+
if (hasFailedDelivery) await this.#compensateSession(sessionId);
|
|
1443
|
+
await this.#deliverCompletion(sessionId, event);
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
async #deliverCompletion(sessionId, event) {
|
|
1448
|
+
if (this.#signal?.aborted || typeof this.#state?.keysWatching !== 'function') return;
|
|
1449
|
+
const reason = event?.data?.reason?.kind ?? event?.data?.reason ?? null;
|
|
1450
|
+
for (const key of this.#state.keysWatching(sessionId)) {
|
|
1451
|
+
if (this.#signal?.aborted) return;
|
|
1452
|
+
const entry = this.#state.watchEntry?.(key, sessionId);
|
|
1453
|
+
const deliveryKey = `${key}\0${sessionId}`;
|
|
1454
|
+
let failedSeq = this.#failedWatchSeqs.get(deliveryKey);
|
|
1455
|
+
if (typeof failedSeq === 'number'
|
|
1456
|
+
&& typeof entry?.lastSeq === 'number'
|
|
1457
|
+
&& entry.lastSeq >= failedSeq) {
|
|
1458
|
+
this.#failedWatchSeqs.delete(deliveryKey);
|
|
1459
|
+
failedSeq = undefined;
|
|
1460
|
+
}
|
|
1461
|
+
if (!entry?.chatId
|
|
1462
|
+
|| (typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq)
|
|
1463
|
+
|| (typeof failedSeq === 'number' && event.seq > failedSeq)) continue;
|
|
1464
|
+
try {
|
|
1465
|
+
await this.#sendCard(
|
|
1466
|
+
entry.chatId,
|
|
1467
|
+
completionCard(sessionId, entry.title, reason),
|
|
1468
|
+
{ key },
|
|
1469
|
+
);
|
|
1470
|
+
const current = this.#state.watchEntry?.(key, sessionId);
|
|
1471
|
+
if (!current
|
|
1472
|
+
|| current.chatId !== entry.chatId
|
|
1473
|
+
|| (typeof current.lastSeq === 'number' && current.lastSeq >= event.seq)) continue;
|
|
1474
|
+
await this.#state.setWatch(key, { ...current, lastSeq: event.seq });
|
|
1475
|
+
if (failedSeq === event.seq) this.#failedWatchSeqs.delete(deliveryKey);
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
this.#failedWatchSeqs.set(
|
|
1478
|
+
deliveryKey,
|
|
1479
|
+
typeof failedSeq === 'number' ? Math.min(failedSeq, event.seq) : event.seq,
|
|
1480
|
+
);
|
|
1481
|
+
this.#logger.warn?.('[dsh-feishu] completion push failed:', error.message);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
async #compensateSession(sessionId) {
|
|
1487
|
+
if (this.#signal?.aborted || typeof this.#harness?.rpc !== 'function') return;
|
|
1488
|
+
try {
|
|
1489
|
+
const history = await this.#harness.rpc(
|
|
1490
|
+
'session.history',
|
|
1491
|
+
{ sessionId, maxMessages: 20 },
|
|
1492
|
+
30_000,
|
|
1493
|
+
{ signal: this.#signal },
|
|
1494
|
+
);
|
|
1495
|
+
const events = orderedHistoryEvents(history);
|
|
1496
|
+
const latestSeq = events.at(-1)?.seq ?? -1;
|
|
1497
|
+
const keys = typeof this.#state?.keysWatching === 'function'
|
|
1498
|
+
? this.#state.keysWatching(sessionId)
|
|
1499
|
+
: [];
|
|
1500
|
+
|
|
1501
|
+
// Watches created by older versions have no baseline. Establish one
|
|
1502
|
+
// without replaying completions that predate the watch.
|
|
1503
|
+
for (const key of keys) {
|
|
1504
|
+
const entry = this.#state.watchEntry?.(key, sessionId);
|
|
1505
|
+
if (entry && typeof entry.lastSeq !== 'number') {
|
|
1506
|
+
await this.#state.setWatch(key, { ...entry, lastSeq: latestSeq });
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
for (const event of events) {
|
|
1511
|
+
if (event.type === 'turn/end') await this.#deliverCompletion(sessionId, event);
|
|
1512
|
+
}
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
if (!this.#signal?.aborted) {
|
|
1515
|
+
this.#logger.warn?.(`[dsh-feishu] watch compensation failed for ${sessionId}:`, error.message);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Replay recent turn completions missed while the mux was disconnected. */
|
|
1521
|
+
async #compensateMissedEvents() {
|
|
1522
|
+
const sessionIds = typeof this.#state?.watchedSessionIds === 'function'
|
|
1523
|
+
? this.#state.watchedSessionIds()
|
|
1524
|
+
: [];
|
|
1525
|
+
for (const sessionId of sessionIds) {
|
|
1526
|
+
if (this.#signal?.aborted) return;
|
|
1527
|
+
await this.#compensateSession(sessionId);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
452
1531
|
#interactionAskOptions(event, key) {
|
|
453
1532
|
return {
|
|
454
1533
|
timeoutMs: this.#replyTimeoutMs,
|