@xmanrui/dsh-im 0.14.0 → 0.16.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 +246 -46
- package/lib/index.js +137 -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 +39 -0
- package/plugin-src/client/index.js +2 -1
- package/plugin-src/client/styles.js +2 -1
- 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 +723 -1
- package/src/channels/feishu/feishu-cards.mjs +155 -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/shared/workspace-command.mjs +2 -2
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-controller.mjs +56 -12
- package/src/channels/weixin/weixin-runtime.mjs +12 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import QRCode from 'qrcode';
|
|
1
2
|
import {
|
|
2
3
|
conversationKey,
|
|
3
4
|
extractInboundMessage,
|
|
@@ -27,12 +28,43 @@ 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
|
+
menuCard,
|
|
36
|
+
menuHelpText,
|
|
37
|
+
sessionListCard,
|
|
38
|
+
workspaceListCard,
|
|
39
|
+
} from './feishu-cards.mjs';
|
|
32
40
|
|
|
33
41
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
34
42
|
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
35
43
|
|
|
44
|
+
const MENU_COMMAND = /^\/m(?:enu)?$/i;
|
|
45
|
+
const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
|
|
46
|
+
const REPAIR_COMMAND = /^\/repair(?:\s+(qr|status|cancel|verify))?\s*$/i;
|
|
47
|
+
const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
|
|
48
|
+
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
|
49
|
+
const NUMBER_REPLY = /^\d{1,2}$/;
|
|
50
|
+
/** A displayed menu stays number-tappable for this long. */
|
|
51
|
+
const MENU_TTL_MS = 10 * 60_000;
|
|
52
|
+
const MAX_TRACKED_MENUS = 50;
|
|
53
|
+
const REPAIR_LINK_WAIT_MS = 15_000;
|
|
54
|
+
const REPAIR_POLL_INTERVAL_MS = 1_000;
|
|
55
|
+
const REPAIR_ACTIVE_STATES = new Set([
|
|
56
|
+
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched', 'saving',
|
|
57
|
+
]);
|
|
58
|
+
const REPAIR_TERMINAL_STATES = new Set([
|
|
59
|
+
'succeeded', 'expired', 'cancelled', 'error',
|
|
60
|
+
]);
|
|
61
|
+
const REPAIR_URL_HOSTS = new Set([
|
|
62
|
+
'accounts.feishu.cn',
|
|
63
|
+
'open.feishu.cn',
|
|
64
|
+
'accounts.larksuite.com',
|
|
65
|
+
'open.larksuite.com',
|
|
66
|
+
]);
|
|
67
|
+
|
|
36
68
|
const HELP_TEXT = [
|
|
37
69
|
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
|
38
70
|
'',
|
|
@@ -49,9 +81,27 @@ const HELP_TEXT = [
|
|
|
49
81
|
'/stop 停止当前任务',
|
|
50
82
|
'/steer 补充指令 纠偏当前任务',
|
|
51
83
|
'/status 检查连接状态',
|
|
84
|
+
'/repair 修复卡片按钮回调',
|
|
85
|
+
'/m(或 /menu) 打开交互卡片菜单',
|
|
52
86
|
'/help 显示本帮助',
|
|
53
87
|
].join('\n');
|
|
54
88
|
|
|
89
|
+
/** Safe user-facing text for bind/workspace failures (no raw messages). */
|
|
90
|
+
function safeErrorText(error) {
|
|
91
|
+
switch (error?.code) {
|
|
92
|
+
case 'workspace-not-absolute':
|
|
93
|
+
return '工作区必须是绝对路径。';
|
|
94
|
+
case 'workspace-not-found':
|
|
95
|
+
return '工作区路径不存在。';
|
|
96
|
+
case 'workspace-not-directory':
|
|
97
|
+
return '工作区路径必须指向一个目录。';
|
|
98
|
+
case 'workspace-bot-not-found':
|
|
99
|
+
return '机器人正在移除或已重新接入,无法操作原会话的工作区。';
|
|
100
|
+
default:
|
|
101
|
+
return '操作失败,请稍后重试。';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
55
105
|
function nonEmptyString(value) {
|
|
56
106
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
57
107
|
}
|
|
@@ -61,6 +111,87 @@ function senderOpenId(event) {
|
|
|
61
111
|
?? nonEmptyString(event?.sender?.sender_id?.user_id);
|
|
62
112
|
}
|
|
63
113
|
|
|
114
|
+
function strictSenderOpenId(event) {
|
|
115
|
+
return nonEmptyString(event?.sender?.sender_id?.open_id);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function abortableDelay(milliseconds, signal) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
if (signal?.aborted) {
|
|
121
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const timer = setTimeout(done, milliseconds);
|
|
125
|
+
timer.unref?.();
|
|
126
|
+
function done() {
|
|
127
|
+
signal?.removeEventListener('abort', aborted);
|
|
128
|
+
resolve();
|
|
129
|
+
}
|
|
130
|
+
function aborted() {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
133
|
+
}
|
|
134
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function repairSnapshot(value, { botId } = {}) {
|
|
139
|
+
const source = value && typeof value === 'object' ? value : {};
|
|
140
|
+
const registration = source.registration && typeof source.registration === 'object'
|
|
141
|
+
? source.registration
|
|
142
|
+
: source;
|
|
143
|
+
const operation = nonEmptyString(registration.operation) ?? nonEmptyString(source.operation);
|
|
144
|
+
if (operation && operation !== 'callback_repair') {
|
|
145
|
+
throw new Error('The active Feishu operation is not a callback repair');
|
|
146
|
+
}
|
|
147
|
+
const selectedBotId = nonEmptyString(registration.botId) ?? nonEmptyString(source.botId);
|
|
148
|
+
if (botId && selectedBotId && selectedBotId !== botId) {
|
|
149
|
+
throw new Error('The Feishu repair belongs to another bot');
|
|
150
|
+
}
|
|
151
|
+
const state = nonEmptyString(registration.state);
|
|
152
|
+
const attempt = registration.attemptId ?? registration.attempt;
|
|
153
|
+
const attemptId = typeof attempt === 'string' || Number.isFinite(attempt)
|
|
154
|
+
? String(attempt)
|
|
155
|
+
: null;
|
|
156
|
+
if (!state || !attemptId) throw new Error('Feishu returned an invalid repair status');
|
|
157
|
+
const verificationUrl = nonEmptyString(registration.verificationUrl)
|
|
158
|
+
?? nonEmptyString(registration.qrCodeUrl);
|
|
159
|
+
const expiresAt = Number(registration.expiresAt);
|
|
160
|
+
const remainingSeconds = Number(registration.remainingSeconds);
|
|
161
|
+
const pollIntervalMs = Number(registration.pollIntervalMs)
|
|
162
|
+
|| (Number(registration.pollIntervalSeconds) * 1000);
|
|
163
|
+
return {
|
|
164
|
+
state,
|
|
165
|
+
attemptId,
|
|
166
|
+
botId: selectedBotId,
|
|
167
|
+
verificationUrl,
|
|
168
|
+
expiresAt: Number.isFinite(expiresAt) ? expiresAt : null,
|
|
169
|
+
remainingSeconds: Number.isFinite(remainingSeconds) ? remainingSeconds : null,
|
|
170
|
+
pollIntervalMs: Number.isFinite(pollIntervalMs) && pollIntervalMs > 0
|
|
171
|
+
? pollIntervalMs
|
|
172
|
+
: null,
|
|
173
|
+
error: registration.error && typeof registration.error === 'object'
|
|
174
|
+
? { code: nonEmptyString(registration.error.code), message: nonEmptyString(registration.error.message) }
|
|
175
|
+
: null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function safeRepairUrl(rawUrl, expectedAppId) {
|
|
180
|
+
const url = new URL(rawUrl);
|
|
181
|
+
if (url.protocol !== 'https:' || !REPAIR_URL_HOSTS.has(url.hostname)) {
|
|
182
|
+
throw new Error('Feishu returned an untrusted repair URL');
|
|
183
|
+
}
|
|
184
|
+
if (url.searchParams.get('tp') !== 'sdk'
|
|
185
|
+
|| url.searchParams.get('clientID') !== expectedAppId
|
|
186
|
+
|| url.searchParams.has('createOnly')) {
|
|
187
|
+
throw new Error('Feishu returned an invalid existing-app repair URL');
|
|
188
|
+
}
|
|
189
|
+
if (url.toString().includes('{{client_id}}') || url.toString().includes('%7B%7Bclient_id%7D%7D')) {
|
|
190
|
+
throw new Error('Feishu returned an unresolved client id placeholder');
|
|
191
|
+
}
|
|
192
|
+
return url.toString();
|
|
193
|
+
}
|
|
194
|
+
|
|
64
195
|
function canClaimInteractionReply(event, pending) {
|
|
65
196
|
return pending.needsPresentation !== true
|
|
66
197
|
&& pending.questions[pending.index]
|
|
@@ -97,6 +228,18 @@ export class FeishuHarnessBridge {
|
|
|
97
228
|
#replyTimeoutMs;
|
|
98
229
|
#logger;
|
|
99
230
|
#signal;
|
|
231
|
+
#botId;
|
|
232
|
+
#appId;
|
|
233
|
+
#repair;
|
|
234
|
+
#repairOwnerOpenIds;
|
|
235
|
+
#repairAttempt = null;
|
|
236
|
+
#repairMonitorVersion = 0;
|
|
237
|
+
#repairPollIntervalMs;
|
|
238
|
+
#repairLinkWaitMs;
|
|
239
|
+
/** Number-tappable menus: conversation key → menu state. */
|
|
240
|
+
#menus = new Map();
|
|
241
|
+
/** Interactive-card message id → route context for button callbacks. */
|
|
242
|
+
#cardKeys = new Map();
|
|
100
243
|
|
|
101
244
|
constructor({
|
|
102
245
|
client,
|
|
@@ -105,6 +248,12 @@ export class FeishuHarnessBridge {
|
|
|
105
248
|
state,
|
|
106
249
|
status,
|
|
107
250
|
allowedSenderOpenIds = new Set(),
|
|
251
|
+
botId,
|
|
252
|
+
appId,
|
|
253
|
+
repair,
|
|
254
|
+
repairOwnerOpenIds,
|
|
255
|
+
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
|
256
|
+
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
|
108
257
|
replyTimeoutMs = 600_000,
|
|
109
258
|
logger = console,
|
|
110
259
|
signal,
|
|
@@ -112,12 +261,35 @@ export class FeishuHarnessBridge {
|
|
|
112
261
|
if (!client || !harness || !state || !status) {
|
|
113
262
|
throw new TypeError('Feishu bridge dependencies are required');
|
|
114
263
|
}
|
|
264
|
+
if (repair !== undefined && repair !== null) {
|
|
265
|
+
if (!repair || typeof repair.start !== 'function'
|
|
266
|
+
|| typeof repair.status !== 'function'
|
|
267
|
+
|| typeof repair.cancel !== 'function') {
|
|
268
|
+
throw new TypeError('Feishu repair capability requires start/status/cancel');
|
|
269
|
+
}
|
|
270
|
+
if (!nonEmptyString(botId) || !nonEmptyString(appId)) {
|
|
271
|
+
throw new TypeError('Feishu repair capability requires botId and appId');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!Number.isFinite(repairPollIntervalMs) || repairPollIntervalMs <= 0
|
|
275
|
+
|| !Number.isFinite(repairLinkWaitMs) || repairLinkWaitMs <= 0) {
|
|
276
|
+
throw new TypeError('Feishu repair timing values must be positive numbers');
|
|
277
|
+
}
|
|
115
278
|
this.#client = client;
|
|
116
279
|
this.#channel = channel;
|
|
117
280
|
this.#harness = harness;
|
|
118
281
|
this.#state = state;
|
|
119
282
|
this.#status = status;
|
|
120
283
|
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
|
284
|
+
this.#botId = nonEmptyString(botId);
|
|
285
|
+
this.#appId = nonEmptyString(appId);
|
|
286
|
+
this.#repair = repair ?? null;
|
|
287
|
+
const repairOwners = repairOwnerOpenIds ?? allowedSenderOpenIds;
|
|
288
|
+
this.#repairOwnerOpenIds = new Set(
|
|
289
|
+
[...(repairOwners ?? [])].filter((value) => typeof value === 'string' && value && value !== '*'),
|
|
290
|
+
);
|
|
291
|
+
this.#repairPollIntervalMs = repairPollIntervalMs;
|
|
292
|
+
this.#repairLinkWaitMs = repairLinkWaitMs;
|
|
121
293
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
122
294
|
this.#logger = logger;
|
|
123
295
|
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
|
@@ -400,10 +572,19 @@ export class FeishuHarnessBridge {
|
|
|
400
572
|
return;
|
|
401
573
|
}
|
|
402
574
|
|
|
575
|
+
if (commandText !== null && REPAIR_COMMAND_PREFIX.test(commandText)) {
|
|
576
|
+
await this.#handleRepairCommand(event, commandText);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
403
579
|
if (commandText === '/help') {
|
|
404
580
|
await this.#send(event.message.chat_id, HELP_TEXT);
|
|
405
581
|
return;
|
|
406
582
|
}
|
|
583
|
+
if (MENU_COMMAND.test(commandText)) {
|
|
584
|
+
this.#rememberMenu(key, { kind: 'menu', chatId: event.message.chat_id });
|
|
585
|
+
await this.#sendCard(event.message.chat_id, menuCard(), { key });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
407
588
|
if (commandText === '/new') {
|
|
408
589
|
await this.#state.clearSession(key);
|
|
409
590
|
await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
|
|
@@ -414,6 +595,26 @@ export class FeishuHarnessBridge {
|
|
|
414
595
|
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
|
415
596
|
return;
|
|
416
597
|
}
|
|
598
|
+
if (SESSION_LIST_PREFIX.test(commandText)) {
|
|
599
|
+
const selector = commandText.slice('/sessionlist'.length).trim() || null;
|
|
600
|
+
await this.#showSessions({ chatId: event.message.chat_id, key }, selector, 0);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (WORKSPACE_LIST_COMMAND.test(commandText)) {
|
|
604
|
+
await this.#showWorkspaces({ chatId: event.message.chat_id, key });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (NUMBER_REPLY.test(commandText)) {
|
|
608
|
+
const menu = this.#takeMenu(key);
|
|
609
|
+
if (menu) {
|
|
610
|
+
await this.#handleMenuPick(menu, Number(commandText), {
|
|
611
|
+
chatId: event.message.chat_id,
|
|
612
|
+
key,
|
|
613
|
+
event,
|
|
614
|
+
});
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
417
618
|
const workspaceCommand = commandText === null
|
|
418
619
|
? null
|
|
419
620
|
: await runWorkspaceCommand(text, this.#harness, key);
|
|
@@ -449,6 +650,527 @@ export class FeishuHarnessBridge {
|
|
|
449
650
|
}
|
|
450
651
|
}
|
|
451
652
|
|
|
653
|
+
// ── Interactive cards: menus and session/workspace lists ────────────────
|
|
654
|
+
|
|
655
|
+
// Existing-app callback repair. This path deliberately uses ordinary text
|
|
656
|
+
// and number replies because callback buttons are the capability being fixed.
|
|
657
|
+
async #handleRepairCommand(event, commandText) {
|
|
658
|
+
if (event?.message?.chat_type !== 'p2p') {
|
|
659
|
+
await this.#send(event.message.chat_id, '为避免授权链接暴露,请私聊机器人发送 /repair。');
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const actorOpenId = strictSenderOpenId(event);
|
|
663
|
+
if (!actorOpenId || !this.#repairOwnerOpenIds.has(actorOpenId)) {
|
|
664
|
+
await this.#send(
|
|
665
|
+
event.message.chat_id,
|
|
666
|
+
this.#repairOwnerOpenIds.size === 0
|
|
667
|
+
? '当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。'
|
|
668
|
+
: '此操作只能由机器人接入者在私聊中发起,未进行任何修改。',
|
|
669
|
+
);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (!this.#repair) {
|
|
673
|
+
await this.#send(event.message.chat_id, '当前 Host 版本暂不支持聊天内修复,请先更新插件。');
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const parsed = REPAIR_COMMAND.exec(commandText);
|
|
678
|
+
if (!parsed) {
|
|
679
|
+
await this.#send(event.message.chat_id, '用法:/repair、/repair qr、/repair status、/repair cancel 或 /repair verify');
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const operation = parsed[1]?.toLowerCase() ?? 'start';
|
|
683
|
+
const chatId = event.message.chat_id;
|
|
684
|
+
if (operation === 'start') {
|
|
685
|
+
await this.#startRepair({ actorOpenId, chatId });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const attempt = this.#repairAttempt;
|
|
690
|
+
if (!attempt) {
|
|
691
|
+
await this.#send(
|
|
692
|
+
chatId,
|
|
693
|
+
'当前 Runtime 没有可恢复的修复任务记录(机器人可能刚完成密钥更新并重启)。本命令不会启动新的授权;请查看机器人发送的验证结果,确认上一次任务已结束后再发送 /repair。',
|
|
694
|
+
);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (attempt.actorOpenId !== actorOpenId) {
|
|
698
|
+
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。');
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (operation === 'cancel') {
|
|
702
|
+
let snapshot;
|
|
703
|
+
try {
|
|
704
|
+
const result = await this.#repair.cancel(this.#repairArgs(attempt));
|
|
705
|
+
snapshot = repairSnapshot(result, { botId: this.#botId });
|
|
706
|
+
attempt.snapshot = snapshot;
|
|
707
|
+
} catch {
|
|
708
|
+
await this.#send(chatId, '暂时无法取消修复任务,请稍后重试。');
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (snapshot.state === 'cancelled') {
|
|
712
|
+
attempt.stopped = true;
|
|
713
|
+
this.#repairMonitorVersion += 1;
|
|
714
|
+
}
|
|
715
|
+
await this.#send(chatId, this.#repairStatusText(snapshot));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
let snapshot;
|
|
720
|
+
try {
|
|
721
|
+
snapshot = await this.#refreshRepairAttempt(attempt);
|
|
722
|
+
} catch {
|
|
723
|
+
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (operation === 'qr') {
|
|
727
|
+
if (!REPAIR_ACTIVE_STATES.has(snapshot.state) || !attempt.verificationUrl) {
|
|
728
|
+
await this.#send(chatId, this.#repairStatusText(snapshot, { verificationFocused: true }));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
await this.#sendRepairQr(chatId, attempt.verificationUrl, snapshot);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
await this.#send(chatId, this.#repairStatusText(snapshot, {
|
|
735
|
+
verificationFocused: operation === 'verify',
|
|
736
|
+
}));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
#repairArgs(attempt) {
|
|
740
|
+
return {
|
|
741
|
+
botId: this.#botId,
|
|
742
|
+
attemptId: attempt.attemptId,
|
|
743
|
+
actorOpenId: attempt.actorOpenId,
|
|
744
|
+
chatId: attempt.chatId,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async #startRepair({ actorOpenId, chatId }) {
|
|
749
|
+
const previous = this.#repairAttempt;
|
|
750
|
+
if (previous && REPAIR_ACTIVE_STATES.has(previous.snapshot.state)) {
|
|
751
|
+
if (previous.actorOpenId !== actorOpenId) {
|
|
752
|
+
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。');
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
const current = await this.#refreshRepairAttempt(previous);
|
|
757
|
+
if (REPAIR_ACTIVE_STATES.has(current.state) && previous.verificationUrl) {
|
|
758
|
+
await this.#sendRepairLink(chatId, previous.verificationUrl, current, { existing: true });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
} catch {
|
|
762
|
+
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
let snapshot;
|
|
768
|
+
try {
|
|
769
|
+
snapshot = repairSnapshot(await this.#repair.start({
|
|
770
|
+
botId: this.#botId,
|
|
771
|
+
actorOpenId,
|
|
772
|
+
chatId,
|
|
773
|
+
}), { botId: this.#botId });
|
|
774
|
+
snapshot = await this.#waitForRepairLink(snapshot, { actorOpenId, chatId });
|
|
775
|
+
} catch {
|
|
776
|
+
await this.#send(chatId, '修复流程暂时失败,现有机器人连接不受影响;请稍后发送 /repair 重试。');
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const attempt = {
|
|
780
|
+
attemptId: snapshot.attemptId,
|
|
781
|
+
actorOpenId,
|
|
782
|
+
chatId,
|
|
783
|
+
snapshot,
|
|
784
|
+
verificationUrl: null,
|
|
785
|
+
stopped: false,
|
|
786
|
+
announcedSaving: false,
|
|
787
|
+
announcedTerminal: false,
|
|
788
|
+
};
|
|
789
|
+
this.#repairAttempt = attempt;
|
|
790
|
+
|
|
791
|
+
if (snapshot.verificationUrl) {
|
|
792
|
+
try {
|
|
793
|
+
attempt.verificationUrl = safeRepairUrl(snapshot.verificationUrl, this.#appId);
|
|
794
|
+
} catch {
|
|
795
|
+
attempt.stopped = true;
|
|
796
|
+
await this.#repair.cancel(this.#repairArgs(attempt)).catch(() => undefined);
|
|
797
|
+
await this.#send(chatId, '飞书返回了无法安全验证的授权链接,已中止本次修复。');
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
|
|
802
|
+
attempt.announcedTerminal = true;
|
|
803
|
+
if (snapshot.state !== 'succeeded') {
|
|
804
|
+
await this.#send(chatId, this.#repairStatusText(snapshot));
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (!attempt.verificationUrl) {
|
|
809
|
+
attempt.stopped = true;
|
|
810
|
+
await this.#send(chatId, '飞书未返回授权链接,已中止本次修复。');
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot);
|
|
814
|
+
this.#monitorRepair(attempt);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async #waitForRepairLink(initial, context) {
|
|
818
|
+
let current = initial;
|
|
819
|
+
const deadline = Date.now() + this.#repairLinkWaitMs;
|
|
820
|
+
while (!current.verificationUrl && REPAIR_ACTIVE_STATES.has(current.state)) {
|
|
821
|
+
if (Date.now() >= deadline) throw new Error('Feishu repair link timed out');
|
|
822
|
+
await abortableDelay(Math.min(100, this.#repairPollIntervalMs), this.#signal);
|
|
823
|
+
current = repairSnapshot(await this.#repair.status({
|
|
824
|
+
botId: this.#botId,
|
|
825
|
+
attemptId: current.attemptId,
|
|
826
|
+
actorOpenId: context.actorOpenId,
|
|
827
|
+
chatId: context.chatId,
|
|
828
|
+
}), { botId: this.#botId });
|
|
829
|
+
}
|
|
830
|
+
return current;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async #refreshRepairAttempt(attempt) {
|
|
834
|
+
const snapshot = repairSnapshot(
|
|
835
|
+
await this.#repair.status(this.#repairArgs(attempt)),
|
|
836
|
+
{ botId: this.#botId },
|
|
837
|
+
);
|
|
838
|
+
if (snapshot.attemptId !== attempt.attemptId) {
|
|
839
|
+
throw new Error('Feishu repair attempt changed unexpectedly');
|
|
840
|
+
}
|
|
841
|
+
attempt.snapshot = snapshot;
|
|
842
|
+
if (snapshot.verificationUrl) {
|
|
843
|
+
attempt.verificationUrl = safeRepairUrl(snapshot.verificationUrl, this.#appId);
|
|
844
|
+
}
|
|
845
|
+
return snapshot;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
#monitorRepair(attempt) {
|
|
849
|
+
const version = ++this.#repairMonitorVersion;
|
|
850
|
+
void (async () => {
|
|
851
|
+
while (!attempt.stopped && this.#repairAttempt === attempt
|
|
852
|
+
&& this.#repairMonitorVersion === version
|
|
853
|
+
&& !this.#signal?.aborted) {
|
|
854
|
+
const delayMs = Math.max(
|
|
855
|
+
250,
|
|
856
|
+
Math.min(10_000, attempt.snapshot.pollIntervalMs ?? this.#repairPollIntervalMs),
|
|
857
|
+
);
|
|
858
|
+
await abortableDelay(delayMs, this.#signal);
|
|
859
|
+
if (attempt.stopped || this.#repairAttempt !== attempt || this.#repairMonitorVersion !== version) return;
|
|
860
|
+
const snapshot = await this.#refreshRepairAttempt(attempt);
|
|
861
|
+
if (snapshot.state === 'saving' && !attempt.announcedSaving) {
|
|
862
|
+
attempt.announcedSaving = true;
|
|
863
|
+
await this.#send(
|
|
864
|
+
attempt.chatId,
|
|
865
|
+
'授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。',
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
|
|
869
|
+
attempt.stopped = true;
|
|
870
|
+
// Runtime sends the verified-success notice before resolving the
|
|
871
|
+
// controller probe. Avoid duplicating it here; failure terminals
|
|
872
|
+
// still need an explicit chat-side explanation.
|
|
873
|
+
if (snapshot.state !== 'succeeded' && !attempt.announcedTerminal) {
|
|
874
|
+
attempt.announcedTerminal = true;
|
|
875
|
+
await this.#send(attempt.chatId, this.#repairStatusText(snapshot));
|
|
876
|
+
}
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
})().catch(async () => {
|
|
881
|
+
if (this.#signal?.aborted || attempt.stopped || this.#repairAttempt !== attempt) return;
|
|
882
|
+
attempt.stopped = true;
|
|
883
|
+
this.#logger.warn?.('[dsh-feishu] callback repair status monitoring failed');
|
|
884
|
+
await this.#send(
|
|
885
|
+
attempt.chatId,
|
|
886
|
+
'修复状态查询中断,现有机器人连接不受影响;发送 /repair status 重试查询。',
|
|
887
|
+
).catch(() => undefined);
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async #sendRepairLink(chatId, url, snapshot, { existing = false } = {}) {
|
|
892
|
+
const remaining = snapshot.remainingSeconds
|
|
893
|
+
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
|
|
894
|
+
const expiry = remaining === null
|
|
895
|
+
? '链接为短期有效'
|
|
896
|
+
: `链接约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟后过期`;
|
|
897
|
+
await this.#send(chatId, [
|
|
898
|
+
existing ? '已有一个修复任务在等待授权。' : '🔧 准备修复卡片按钮。',
|
|
899
|
+
'本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。',
|
|
900
|
+
'',
|
|
901
|
+
'当前设备直接打开:',
|
|
902
|
+
url,
|
|
903
|
+
'',
|
|
904
|
+
`若要用另一台设备扫码,发送 /repair qr。${expiry}。`,
|
|
905
|
+
].join('\n'));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async #sendRepairQr(chatId, url, snapshot) {
|
|
909
|
+
try {
|
|
910
|
+
const image = await QRCode.toBuffer(url, {
|
|
911
|
+
errorCorrectionLevel: 'M', margin: 1, width: 480, type: 'png',
|
|
912
|
+
});
|
|
913
|
+
const uploaded = await this.#client.im.v1.image.create({
|
|
914
|
+
data: { image_type: 'message', image },
|
|
915
|
+
});
|
|
916
|
+
const imageKey = nonEmptyString(uploaded?.image_key) ?? nonEmptyString(uploaded?.data?.image_key);
|
|
917
|
+
if (!imageKey) throw new Error('Feishu QR upload returned no image key');
|
|
918
|
+
const remaining = snapshot.remainingSeconds
|
|
919
|
+
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
|
|
920
|
+
await this.#send(
|
|
921
|
+
chatId,
|
|
922
|
+
`请用另一台设备扫码完成授权${remaining === null ? '' : `(剩余约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟)`}。`,
|
|
923
|
+
);
|
|
924
|
+
const response = await this.#client.im.v1.message.create({
|
|
925
|
+
params: { receive_id_type: 'chat_id' },
|
|
926
|
+
data: {
|
|
927
|
+
receive_id: chatId,
|
|
928
|
+
msg_type: 'image',
|
|
929
|
+
content: JSON.stringify({ image_key: imageKey }),
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
if (response?.code && response.code !== 0) throw new Error('Feishu QR message send failed');
|
|
933
|
+
} catch {
|
|
934
|
+
await this.#send(chatId, `二维码暂时无法发送,请直接打开授权链接:\n${url}`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
#repairStatusText(snapshot, { verificationFocused = false } = {}) {
|
|
939
|
+
if (snapshot.state === 'succeeded') {
|
|
940
|
+
return '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
|
|
941
|
+
}
|
|
942
|
+
if (snapshot.state === 'expired' || snapshot.error?.code === 'expired_token') {
|
|
943
|
+
return '授权链接已过期;平台未返回成功结果,无法确认已修复。发送 /repair 生成新链接。';
|
|
944
|
+
}
|
|
945
|
+
if (snapshot.state === 'cancelled' || snapshot.error?.code === 'abort') {
|
|
946
|
+
return '已取消本次修复授权,未确认完成修复。';
|
|
947
|
+
}
|
|
948
|
+
if (snapshot.error?.code === 'access_denied') {
|
|
949
|
+
return '你已取消或拒绝授权,没有确认修复;发送 /repair 可重试。';
|
|
950
|
+
}
|
|
951
|
+
if (snapshot.error?.code === 'card_action_probe_timeout'
|
|
952
|
+
|| snapshot.error?.code === 'card-action-probe-timeout') {
|
|
953
|
+
return '授权已提交,但未收到测试按钮回调。可能尚未点击或配置仍在传播;稍后发送 /repair verify 查询,不要盲目重复授权。';
|
|
954
|
+
}
|
|
955
|
+
if (snapshot.state === 'error') {
|
|
956
|
+
return '修复流程暂时失败,现有机器人连接不受影响;发送 /repair 可重试。';
|
|
957
|
+
}
|
|
958
|
+
if (snapshot.state === 'saving') {
|
|
959
|
+
return '授权已确认,正在等待专用测试按钮的真实回调;回调到达前不会宣告成功。';
|
|
960
|
+
}
|
|
961
|
+
if (verificationFocused) {
|
|
962
|
+
return '授权尚未完成,暂时不能验证卡片按钮。请先打开授权链接并确认。';
|
|
963
|
+
}
|
|
964
|
+
const remaining = snapshot.remainingSeconds === null
|
|
965
|
+
? ''
|
|
966
|
+
: `,剩余约 ${Math.max(1, Math.ceil(snapshot.remainingSeconds / 60))} 分钟`;
|
|
967
|
+
return `修复任务正在等待授权${remaining}。发送 /repair qr 可获取二维码,/repair cancel 可取消。`;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Card button callback (card.action.trigger). The operator must be an
|
|
972
|
+
* allowed sender: group members outside the allowlist must never drive
|
|
973
|
+
* session binding, workspace switches or other card actions.
|
|
974
|
+
*/
|
|
975
|
+
onCardAction(event) {
|
|
976
|
+
const operatorOpenId = nonEmptyString(event?.operator?.open_id)
|
|
977
|
+
?? nonEmptyString(event?.operator?.user_id)
|
|
978
|
+
// Keep accepting the legacy nested shape while preferring the current
|
|
979
|
+
// card.action.trigger v2 payload used by the official SDK.
|
|
980
|
+
?? nonEmptyString(event?.operator?.operator_id?.open_id)
|
|
981
|
+
?? nonEmptyString(event?.operator?.operator_id?.user_id);
|
|
982
|
+
const operatorAllowed = operatorOpenId !== null
|
|
983
|
+
&& (this.#allowedSenderOpenIds.has('*') || this.#allowedSenderOpenIds.has(operatorOpenId));
|
|
984
|
+
if (!operatorAllowed) {
|
|
985
|
+
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed sender');
|
|
986
|
+
return Promise.resolve();
|
|
987
|
+
}
|
|
988
|
+
const action = typeof event?.action?.value?.action === 'string'
|
|
989
|
+
? event.action.value.action
|
|
990
|
+
: null;
|
|
991
|
+
if (!action) return Promise.resolve();
|
|
992
|
+
const messageId = nonEmptyString(event?.context?.open_message_id);
|
|
993
|
+
const entry = messageId ? this.#cardKeys.get(messageId) : null;
|
|
994
|
+
if (!entry) return Promise.resolve();
|
|
995
|
+
// The promise is returned so tests (and future callers) can await the
|
|
996
|
+
// action; the runtime dispatcher ignores it.
|
|
997
|
+
return this.#handleCardAction(action, entry).catch((error) => {
|
|
998
|
+
this.#logger.warn?.('[dsh-feishu] card action failed:', error.message);
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async #handleCardAction(action, { chatId, key, sessionWorkspace = null }) {
|
|
1003
|
+
if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
|
|
1004
|
+
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
|
|
1005
|
+
await this.#showSessions({ chatId, key }, sessionWorkspace, page);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (action === 'workspaces') {
|
|
1009
|
+
await this.#showWorkspaces({ chatId, key });
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (action === 'new') {
|
|
1013
|
+
await this.#state.clearSession(key);
|
|
1014
|
+
await this.#send(chatId, '已开启全新 Harness 会话。');
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (action === 'status') {
|
|
1018
|
+
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
1019
|
+
await this.#send(chatId, '飞书机器人与 DeepSeek Harness 连接正常。');
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (action === 'help') {
|
|
1023
|
+
await this.#send(chatId, menuHelpText());
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (action.startsWith('use:')) {
|
|
1027
|
+
await this.#bindSession(key, chatId, action.slice('use:'.length));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (action.startsWith('workspace:')) {
|
|
1031
|
+
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
#rememberMenu(key, menu) {
|
|
1036
|
+
if (this.#menus.size >= MAX_TRACKED_MENUS) {
|
|
1037
|
+
const oldest = this.#menus.keys().next().value;
|
|
1038
|
+
if (oldest !== undefined) this.#menus.delete(oldest);
|
|
1039
|
+
}
|
|
1040
|
+
this.#menus.delete(key);
|
|
1041
|
+
this.#menus.set(key, { ...menu, expiresAt: Date.now() + MENU_TTL_MS });
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
#takeMenu(key) {
|
|
1045
|
+
const menu = this.#menus.get(key);
|
|
1046
|
+
if (!menu) return null;
|
|
1047
|
+
if (menu.expiresAt < Date.now()) {
|
|
1048
|
+
this.#menus.delete(key);
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
return menu;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async #handleMenuPick(menu, number, { chatId, key, event }) {
|
|
1055
|
+
if (menu.kind === 'menu') {
|
|
1056
|
+
const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair'][number - 1];
|
|
1057
|
+
if (!action) {
|
|
1058
|
+
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (action === 'repair') {
|
|
1062
|
+
await this.#handleRepairCommand(event, '/repair');
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
await this.#handleCardAction(action, { chatId, key });
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (menu.kind === 'sessions') {
|
|
1069
|
+
const session = menu.sessions[number - 1];
|
|
1070
|
+
if (!session?.sessionId) {
|
|
1071
|
+
await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`);
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (menu.kind === 'workspaces') {
|
|
1078
|
+
const workspace = menu.paths[number - 1];
|
|
1079
|
+
if (!workspace) {
|
|
1080
|
+
await this.#send(chatId, `只有 ${menu.paths.length} 个工作区,回复 /workspacelist 重新查看。`);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async #showSessions({ chatId, key }, selector, page = 0) {
|
|
1088
|
+
try {
|
|
1089
|
+
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness);
|
|
1090
|
+
if (resolved.error) {
|
|
1091
|
+
await this.#send(chatId, resolved.error);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace);
|
|
1095
|
+
const sessions = Array.isArray(listed?.sessions) ? listed.sessions : [];
|
|
1096
|
+
const workspace = listed?.workspace ?? resolved.workspace;
|
|
1097
|
+
if (sessions.length === 0) {
|
|
1098
|
+
await this.#send(chatId, `工作区:${workspace}\n该工作区暂无会话。`);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
|
|
1102
|
+
const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0;
|
|
1103
|
+
this.#rememberMenu(key, {
|
|
1104
|
+
kind: 'sessions',
|
|
1105
|
+
sessions: sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE),
|
|
1106
|
+
});
|
|
1107
|
+
await this.#sendCard(chatId, sessionListCard(workspace, sessions, safePage, sessions.length), {
|
|
1108
|
+
key,
|
|
1109
|
+
// Keep the canonical selector result for later page callbacks. The
|
|
1110
|
+
// list response's workspace is display data and is not authoritative.
|
|
1111
|
+
sessionWorkspace: resolved.workspace,
|
|
1112
|
+
});
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
|
|
1115
|
+
await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。');
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async #showWorkspaces({ chatId, key }) {
|
|
1120
|
+
try {
|
|
1121
|
+
const { current, paths } = await workspacePathSnapshot(this.#harness);
|
|
1122
|
+
this.#rememberMenu(key, { kind: 'workspaces', paths });
|
|
1123
|
+
await this.#sendCard(chatId, workspaceListCard(paths, current), { key });
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message);
|
|
1126
|
+
await this.#send(chatId, '暂时无法获取工作区列表,请稍后重试。');
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async #bindSession(key, chatId, sessionId) {
|
|
1131
|
+
try {
|
|
1132
|
+
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
|
|
1133
|
+
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || '暂无标题';
|
|
1134
|
+
await this.#send(chatId, `已绑定会话「${title}」\nID:${bound?.sessionId ?? sessionId}`);
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
await this.#send(chatId, `绑定失败:${safeErrorText(error)}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async #switchWorkspace(key, chatId, workspace) {
|
|
1141
|
+
try {
|
|
1142
|
+
const current = await this.#harness.switchWorkspace(workspace);
|
|
1143
|
+
await this.#send(chatId, `工作区已切换为:${current}`);
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
await this.#send(chatId, `切换失败:${safeErrorText(error)}`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async #sendCard(chatId, cardJson, options = {}) {
|
|
1150
|
+
const response = await this.#client.im.v1.message.create({
|
|
1151
|
+
params: { receive_id_type: 'chat_id' },
|
|
1152
|
+
data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
|
|
1153
|
+
});
|
|
1154
|
+
if (response?.code && response.code !== 0) {
|
|
1155
|
+
throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
|
|
1156
|
+
}
|
|
1157
|
+
const messageId = nonEmptyString(response?.data?.message_id);
|
|
1158
|
+
if (options.key && messageId) {
|
|
1159
|
+
this.#cardKeys.set(messageId, {
|
|
1160
|
+
key: options.key,
|
|
1161
|
+
chatId,
|
|
1162
|
+
sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
|
|
1163
|
+
? options.sessionWorkspace
|
|
1164
|
+
: null,
|
|
1165
|
+
});
|
|
1166
|
+
if (this.#cardKeys.size > 200) {
|
|
1167
|
+
const oldest = this.#cardKeys.keys().next().value;
|
|
1168
|
+
if (oldest !== undefined) this.#cardKeys.delete(oldest);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return messageId;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
452
1174
|
#interactionAskOptions(event, key) {
|
|
453
1175
|
return {
|
|
454
1176
|
timeoutMs: this.#replyTimeoutMs,
|