@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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu interactive-card builders for the dsh-im menu / session-list /
|
|
3
|
+
* workspace-list UX. All builders return the JSON string the
|
|
4
|
+
* `im.message.create` API expects as `content` for `msg_type: interactive`
|
|
5
|
+
* (card schema 2.0; callback buttons live inside a column_set/column layout).
|
|
6
|
+
*
|
|
7
|
+
* Buttons carry a small `{ action }` callback behavior that
|
|
8
|
+
* `card.action.trigger` events echo back (when the app subscribes that
|
|
9
|
+
* callback); every button also carries a numeric label so the number-reply
|
|
10
|
+
* fallback stays usable without button callbacks.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const MENU_PAGE_SIZE = 10;
|
|
14
|
+
|
|
15
|
+
function plainText(content) {
|
|
16
|
+
return { tag: 'plain_text', content: String(content) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function markdown(content) {
|
|
20
|
+
return { tag: 'lark_md', content: String(content) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function button(content, actionValue) {
|
|
24
|
+
return {
|
|
25
|
+
tag: 'column_set',
|
|
26
|
+
flex_mode: 'none',
|
|
27
|
+
columns: [{
|
|
28
|
+
tag: 'column',
|
|
29
|
+
width: 'weighted',
|
|
30
|
+
weight: 1,
|
|
31
|
+
elements: [{
|
|
32
|
+
tag: 'button',
|
|
33
|
+
text: plainText(content),
|
|
34
|
+
type: 'default',
|
|
35
|
+
width: 'fill',
|
|
36
|
+
behaviors: [{ type: 'callback', value: { action: actionValue } }],
|
|
37
|
+
}],
|
|
38
|
+
}],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeTitle(value) {
|
|
43
|
+
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
44
|
+
return title || '暂无标题';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cardWith(headerText, elements) {
|
|
48
|
+
return JSON.stringify({
|
|
49
|
+
schema: '2.0',
|
|
50
|
+
header: { title: plainText(headerText), template: 'blue' },
|
|
51
|
+
body: { elements },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The main command menu (buttons + number-reply fallback). */
|
|
56
|
+
export function menuCard() {
|
|
57
|
+
return cardWith('🤖 助手菜单', [
|
|
58
|
+
{ tag: 'div', text: markdown('**点击按钮或直接回复数字**') },
|
|
59
|
+
button('1 · 会话列表', 'sessions'),
|
|
60
|
+
button('2 · 工作区', 'workspaces'),
|
|
61
|
+
button('3 · 新会话', 'new'),
|
|
62
|
+
button('4 · 状态', 'status'),
|
|
63
|
+
button('5 · 帮助', 'help'),
|
|
64
|
+
// Repair must remain number-driven. Apps that need this command do not
|
|
65
|
+
// have card.action.trigger yet, so rendering it as a callback button would
|
|
66
|
+
// send the user straight back to Feishu's broken callback setup popup.
|
|
67
|
+
{ tag: 'div', text: markdown('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One-shot callback probe used only after an existing app was re-authorized. */
|
|
72
|
+
export function cardActionProbeCard(nonce) {
|
|
73
|
+
if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
|
|
74
|
+
throw new TypeError('A safe card-action probe nonce is required');
|
|
75
|
+
}
|
|
76
|
+
return cardWith('🧪 验证卡片按钮', [
|
|
77
|
+
{
|
|
78
|
+
tag: 'div',
|
|
79
|
+
text: markdown('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。'),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
tag: 'column_set',
|
|
83
|
+
flex_mode: 'none',
|
|
84
|
+
columns: [{
|
|
85
|
+
tag: 'column',
|
|
86
|
+
width: 'weighted',
|
|
87
|
+
weight: 1,
|
|
88
|
+
elements: [{
|
|
89
|
+
tag: 'button',
|
|
90
|
+
text: plainText('完成验证'),
|
|
91
|
+
type: 'primary',
|
|
92
|
+
width: 'fill',
|
|
93
|
+
behaviors: [{
|
|
94
|
+
type: 'callback',
|
|
95
|
+
value: { action: 'repair_verify', nonce },
|
|
96
|
+
}],
|
|
97
|
+
}],
|
|
98
|
+
}],
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One page of the workspace's sessions. Each row is a bind button; the
|
|
105
|
+
* number label equals the reply-number for the same action (fallback).
|
|
106
|
+
*/
|
|
107
|
+
export function sessionListCard(workspace, sessions, page, total) {
|
|
108
|
+
const start = page * MENU_PAGE_SIZE;
|
|
109
|
+
const slice = sessions.slice(start, start + MENU_PAGE_SIZE);
|
|
110
|
+
const pageCount = Math.max(1, Math.ceil(total / MENU_PAGE_SIZE));
|
|
111
|
+
const elements = [
|
|
112
|
+
{ tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
|
|
113
|
+
...slice.map((session, offset) => button(
|
|
114
|
+
`${offset + 1}. ${safeTitle(session.title)}`,
|
|
115
|
+
`use:${session.sessionId}`,
|
|
116
|
+
)),
|
|
117
|
+
];
|
|
118
|
+
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
|
|
119
|
+
if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
|
|
120
|
+
elements.push({ tag: 'div', text: markdown('回复数字(1~N)同样可以绑定本页会话。') });
|
|
121
|
+
return cardWith('📂 会话列表', elements);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The workspace list card (switch-workspace buttons + reply fallback). */
|
|
125
|
+
export function workspaceListCard(paths, current) {
|
|
126
|
+
const elements = paths.length === 0
|
|
127
|
+
? [{ tag: 'div', text: markdown('当前 Host 上没有已登记的工作区。') }]
|
|
128
|
+
: [
|
|
129
|
+
{ tag: 'div', text: markdown(`回复数字切换工作区,或点击按钮:`) },
|
|
130
|
+
...paths.map((path, index) => button(
|
|
131
|
+
`${index + 1}. ${path}${path === current ? '(当前)' : ''}`,
|
|
132
|
+
`workspace:${path}`,
|
|
133
|
+
)),
|
|
134
|
+
];
|
|
135
|
+
return cardWith('🗂 工作区', elements);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The card-menu help text (number-driven, no command memorization). */
|
|
139
|
+
export function menuHelpText() {
|
|
140
|
+
return [
|
|
141
|
+
'🤖 助手菜单(回复数字即可,无需记命令)',
|
|
142
|
+
'',
|
|
143
|
+
'1 · /sessionlist 列出会话(回复数字绑定)',
|
|
144
|
+
'2 · /workspacelist 列出工作区(回复数字切换)',
|
|
145
|
+
'3 · /new 开启新会话',
|
|
146
|
+
'4 · /status 连接状态',
|
|
147
|
+
'5 · /help 本帮助',
|
|
148
|
+
'6 · /repair 修复卡片按钮(请回复数字 6)',
|
|
149
|
+
'',
|
|
150
|
+
'直接发送文字/图片即继续当前会话。',
|
|
151
|
+
'/session ID 或序号 绑定已有会话',
|
|
152
|
+
'/compact 压缩上下文',
|
|
153
|
+
'/workspace 绝对路径 切换工作区',
|
|
154
|
+
].join('\n');
|
|
155
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { FeishuHarnessBridge } from './bridge.mjs';
|
|
3
|
+
import { cardActionProbeCard } from './feishu-cards.mjs';
|
|
2
4
|
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
|
3
5
|
import {
|
|
4
6
|
connectionTestTargetUnavailable,
|
|
@@ -6,6 +8,25 @@ import {
|
|
|
6
8
|
} from '../shared/connection-test.mjs';
|
|
7
9
|
|
|
8
10
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
11
|
+
const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
|
|
12
|
+
const CALLBACK_PROBE_TIMEOUT_NOTICE = '⚠️ 修复验证超时:未收到测试卡按钮的 card.action.trigger,不能确认按钮已修复。请不要重复授权;先检查飞书开放平台的卡片回调配置,确认后再发送 /repair。';
|
|
13
|
+
const CALLBACK_PROBE_SEND_FAILURE_NOTICE = '⚠️ 修复验证失败:无法发送专用测试卡,不能确认 card.action.trigger 已恢复。请不要重复授权;先检查机器人消息权限和连接状态。';
|
|
14
|
+
const CALLBACK_PROBE_ABORT_NOTICE = '⚠️ 修复验证中断:Runtime 已停止,未完成 card.action.trigger 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。';
|
|
15
|
+
|
|
16
|
+
function nonEmptyString(value) {
|
|
17
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function strictCardOperatorOpenId(event) {
|
|
21
|
+
return nonEmptyString(event?.operator?.open_id)
|
|
22
|
+
?? nonEmptyString(event?.operator?.operator_id?.open_id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function probeError(code, message) {
|
|
26
|
+
const error = new Error(message);
|
|
27
|
+
error.code = code;
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
9
30
|
|
|
10
31
|
function httpInstanceWithTimeout(httpInstance, timeoutMs) {
|
|
11
32
|
if (!httpInstance || typeof httpInstance.request !== 'function') return undefined;
|
|
@@ -41,9 +62,12 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
|
|
41
62
|
streamUpdates: 0,
|
|
42
63
|
streamFallbacks: 0,
|
|
43
64
|
streamErrors: 0,
|
|
65
|
+
cardActionsReceived: 0,
|
|
66
|
+
cardActionProbesVerified: 0,
|
|
44
67
|
lastMessageAt: null,
|
|
45
68
|
lastReplyAt: null,
|
|
46
69
|
lastRejectedAt: null,
|
|
70
|
+
lastCardActionAt: null,
|
|
47
71
|
lastError: null,
|
|
48
72
|
agentPreset: 'standard',
|
|
49
73
|
authorizationMode: 'sender-open-id-allowlist',
|
|
@@ -59,6 +83,7 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
|
|
59
83
|
*/
|
|
60
84
|
export class FeishuRuntime {
|
|
61
85
|
#lark;
|
|
86
|
+
#botId;
|
|
62
87
|
#appId;
|
|
63
88
|
#appSecret;
|
|
64
89
|
#domain;
|
|
@@ -69,15 +94,18 @@ export class FeishuRuntime {
|
|
|
69
94
|
#connectTimeoutMs;
|
|
70
95
|
#requestTimeoutMs;
|
|
71
96
|
#logger;
|
|
97
|
+
#repair;
|
|
72
98
|
#client = null;
|
|
73
99
|
#bridge = null;
|
|
74
100
|
#wsClient = null;
|
|
75
101
|
#starting = null;
|
|
76
102
|
#abortController = null;
|
|
103
|
+
#pendingCardActionProbes = new Map();
|
|
77
104
|
#status;
|
|
78
105
|
|
|
79
106
|
constructor({
|
|
80
107
|
lark,
|
|
108
|
+
botId,
|
|
81
109
|
appId,
|
|
82
110
|
appSecret,
|
|
83
111
|
domain = 'feishu',
|
|
@@ -85,6 +113,7 @@ export class FeishuRuntime {
|
|
|
85
113
|
ownerOpenIds,
|
|
86
114
|
harness,
|
|
87
115
|
state,
|
|
116
|
+
repair,
|
|
88
117
|
replyTimeoutMs = 600000,
|
|
89
118
|
connectTimeoutMs = 15000,
|
|
90
119
|
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
@@ -97,17 +126,22 @@ export class FeishuRuntime {
|
|
|
97
126
|
if (normalizedOwners.length === 0) throw new Error('FeishuRuntime requires at least one owner open_id');
|
|
98
127
|
if (!harness) throw new Error('FeishuRuntime requires a Harness client');
|
|
99
128
|
if (!state) throw new Error('FeishuRuntime requires a state store');
|
|
129
|
+
if (repair !== undefined && repair !== null && !nonEmptyString(botId)) {
|
|
130
|
+
throw new TypeError('FeishuRuntime repair capability requires a botId');
|
|
131
|
+
}
|
|
100
132
|
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
|
101
133
|
throw new TypeError('FeishuRuntime requestTimeoutMs must be a positive number');
|
|
102
134
|
}
|
|
103
135
|
|
|
104
136
|
this.#lark = lark;
|
|
137
|
+
this.#botId = nonEmptyString(botId);
|
|
105
138
|
this.#appId = appId;
|
|
106
139
|
this.#appSecret = appSecret;
|
|
107
140
|
this.#domain = domain;
|
|
108
141
|
this.#ownerOpenIds = normalizedOwners;
|
|
109
142
|
this.#harness = harness;
|
|
110
143
|
this.#state = state;
|
|
144
|
+
this.#repair = repair ?? null;
|
|
111
145
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
112
146
|
this.#connectTimeoutMs = connectTimeoutMs;
|
|
113
147
|
this.#requestTimeoutMs = requestTimeoutMs;
|
|
@@ -166,6 +200,10 @@ export class FeishuRuntime {
|
|
|
166
200
|
state: this.#state,
|
|
167
201
|
status: this.#status,
|
|
168
202
|
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
|
|
203
|
+
botId: this.#botId,
|
|
204
|
+
appId: this.#appId,
|
|
205
|
+
repair: this.#repair,
|
|
206
|
+
repairOwnerOpenIds: new Set(this.#ownerOpenIds.filter((value) => value !== '*')),
|
|
169
207
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
170
208
|
signal,
|
|
171
209
|
logger: this.#logger,
|
|
@@ -178,6 +216,15 @@ export class FeishuRuntime {
|
|
|
178
216
|
},
|
|
179
217
|
'im.message.reaction.created_v1': () => ({}),
|
|
180
218
|
'im.message.reaction.deleted_v1': () => ({}),
|
|
219
|
+
// Interactive-card button callbacks (only delivered when the app
|
|
220
|
+
// subscribes card.action.trigger; the number-reply fallback covers
|
|
221
|
+
// apps that do not).
|
|
222
|
+
'card.action.trigger': (event) => {
|
|
223
|
+
this.#status.cardActionsReceived += 1;
|
|
224
|
+
this.#status.lastCardActionAt = new Date().toISOString();
|
|
225
|
+
if (!this.#consumeCardActionProbe(event)) this.#bridge.onCardAction(event);
|
|
226
|
+
return {};
|
|
227
|
+
},
|
|
181
228
|
});
|
|
182
229
|
|
|
183
230
|
let settleReady;
|
|
@@ -244,6 +291,147 @@ export class FeishuRuntime {
|
|
|
244
291
|
}
|
|
245
292
|
}
|
|
246
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Send a one-shot callback card and resolve only after Feishu delivers the
|
|
296
|
+
* exact message/nonce/operator tuple over card.action.trigger. The controller
|
|
297
|
+
* uses this as the final proof for both browser- and chat-initiated repairs.
|
|
298
|
+
*/
|
|
299
|
+
async beginCardActionProbe({ expectedOperatorOpenId, timeoutMs = 90_000 } = {}) {
|
|
300
|
+
if (!this.#status.ready || !this.#client) {
|
|
301
|
+
throw probeError('card_action_probe_unavailable', '飞书机器人尚未连接');
|
|
302
|
+
}
|
|
303
|
+
const operatorOpenId = nonEmptyString(expectedOperatorOpenId);
|
|
304
|
+
if (!operatorOpenId || operatorOpenId === '*') {
|
|
305
|
+
throw new TypeError('A precise Feishu operator open_id is required');
|
|
306
|
+
}
|
|
307
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10 * 60_000) {
|
|
308
|
+
throw new TypeError('Card-action probe timeout must be between 1 and 600000ms');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const nonce = randomUUID().replaceAll('-', '');
|
|
312
|
+
let response;
|
|
313
|
+
try {
|
|
314
|
+
response = await this.#client.im.v1.message.create({
|
|
315
|
+
params: { receive_id_type: 'open_id' },
|
|
316
|
+
data: {
|
|
317
|
+
receive_id: operatorOpenId,
|
|
318
|
+
msg_type: 'interactive',
|
|
319
|
+
content: cardActionProbeCard(nonce),
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
} catch {
|
|
323
|
+
void this.#sendCardActionProbeNotice(
|
|
324
|
+
operatorOpenId,
|
|
325
|
+
CALLBACK_PROBE_SEND_FAILURE_NOTICE,
|
|
326
|
+
'failure',
|
|
327
|
+
);
|
|
328
|
+
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
|
|
329
|
+
}
|
|
330
|
+
if (response?.code && response.code !== 0) {
|
|
331
|
+
void this.#sendCardActionProbeNotice(
|
|
332
|
+
operatorOpenId,
|
|
333
|
+
CALLBACK_PROBE_SEND_FAILURE_NOTICE,
|
|
334
|
+
'failure',
|
|
335
|
+
);
|
|
336
|
+
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
|
|
337
|
+
}
|
|
338
|
+
const messageId = nonEmptyString(response?.data?.message_id)
|
|
339
|
+
?? nonEmptyString(response?.message_id);
|
|
340
|
+
if (!messageId) {
|
|
341
|
+
void this.#sendCardActionProbeNotice(
|
|
342
|
+
operatorOpenId,
|
|
343
|
+
CALLBACK_PROBE_SEND_FAILURE_NOTICE,
|
|
344
|
+
'failure',
|
|
345
|
+
);
|
|
346
|
+
throw probeError('card_action_probe_send_failed', '飞书未返回测试卡片的消息 ID');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return new Promise((resolve, reject) => {
|
|
350
|
+
const timeout = setTimeout(() => {
|
|
351
|
+
const current = this.#pendingCardActionProbes.get(messageId);
|
|
352
|
+
if (!current || current.nonce !== nonce) return;
|
|
353
|
+
this.#pendingCardActionProbes.delete(messageId);
|
|
354
|
+
void this.#sendCardActionProbeNotice(
|
|
355
|
+
operatorOpenId,
|
|
356
|
+
CALLBACK_PROBE_TIMEOUT_NOTICE,
|
|
357
|
+
'timeout',
|
|
358
|
+
);
|
|
359
|
+
reject(probeError(
|
|
360
|
+
'card_action_probe_timeout',
|
|
361
|
+
'在规定时间内未收到飞书卡片按钮回调',
|
|
362
|
+
));
|
|
363
|
+
}, timeoutMs);
|
|
364
|
+
timeout.unref?.();
|
|
365
|
+
this.#pendingCardActionProbes.set(messageId, {
|
|
366
|
+
messageId,
|
|
367
|
+
nonce,
|
|
368
|
+
expectedOperatorOpenId: operatorOpenId,
|
|
369
|
+
timeout,
|
|
370
|
+
resolve,
|
|
371
|
+
reject,
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#consumeCardActionProbe(event) {
|
|
377
|
+
const messageId = nonEmptyString(event?.context?.open_message_id);
|
|
378
|
+
if (!messageId) return false;
|
|
379
|
+
const probe = this.#pendingCardActionProbes.get(messageId);
|
|
380
|
+
if (!probe) return false;
|
|
381
|
+
const value = event?.action?.value;
|
|
382
|
+
const operatorOpenId = strictCardOperatorOpenId(event);
|
|
383
|
+
if (value?.action !== 'repair_verify'
|
|
384
|
+
|| value?.nonce !== probe.nonce
|
|
385
|
+
|| operatorOpenId !== probe.expectedOperatorOpenId) {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
clearTimeout(probe.timeout);
|
|
389
|
+
this.#pendingCardActionProbes.delete(messageId);
|
|
390
|
+
this.#status.cardActionProbesVerified += 1;
|
|
391
|
+
// Start the terminal notification before resolving the controller-facing
|
|
392
|
+
// probe. A repair may rotate the App Secret and immediately replace this
|
|
393
|
+
// runtime after resolution; initiating the send here keeps chat and web
|
|
394
|
+
// repair flows equally observable. Notification failure never invalidates
|
|
395
|
+
// the callback proof itself.
|
|
396
|
+
void this.#sendCardActionProbeNotice(
|
|
397
|
+
operatorOpenId,
|
|
398
|
+
CALLBACK_PROBE_SUCCESS_NOTICE,
|
|
399
|
+
'success',
|
|
400
|
+
).finally(() => {
|
|
401
|
+
probe.resolve({
|
|
402
|
+
verified: true,
|
|
403
|
+
messageId,
|
|
404
|
+
operatorOpenId,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
#sendCardActionProbeNotice(operatorOpenId, text, outcome) {
|
|
411
|
+
const client = this.#client;
|
|
412
|
+
if (!client) {
|
|
413
|
+
this.#logger.warn?.(`[dsh-feishu] unable to send the callback repair ${outcome} notice`);
|
|
414
|
+
return Promise.resolve(false);
|
|
415
|
+
}
|
|
416
|
+
return Promise.resolve().then(async () => {
|
|
417
|
+
const response = await client.im.v1.message.create({
|
|
418
|
+
params: { receive_id_type: 'open_id' },
|
|
419
|
+
data: {
|
|
420
|
+
receive_id: operatorOpenId,
|
|
421
|
+
msg_type: 'text',
|
|
422
|
+
content: JSON.stringify({ text }),
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
if (response?.code && response.code !== 0) {
|
|
426
|
+
throw new Error('Feishu callback repair notice failed');
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
}).catch(() => {
|
|
430
|
+
this.#logger.warn?.(`[dsh-feishu] unable to send the callback repair ${outcome} notice`);
|
|
431
|
+
return false;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
247
435
|
async sendConnectionTest(text) {
|
|
248
436
|
if (!this.#status.ready || !this.#client) {
|
|
249
437
|
const error = new Error('飞书机器人尚未连接');
|
|
@@ -290,6 +478,16 @@ export class FeishuRuntime {
|
|
|
290
478
|
const abortController = this.#abortController;
|
|
291
479
|
this.#abortController = null;
|
|
292
480
|
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
|
481
|
+
for (const probe of this.#pendingCardActionProbes.values()) {
|
|
482
|
+
clearTimeout(probe.timeout);
|
|
483
|
+
void this.#sendCardActionProbeNotice(
|
|
484
|
+
probe.expectedOperatorOpenId,
|
|
485
|
+
CALLBACK_PROBE_ABORT_NOTICE,
|
|
486
|
+
'abort',
|
|
487
|
+
);
|
|
488
|
+
probe.reject(probeError('abort', '飞书运行时已停止'));
|
|
489
|
+
}
|
|
490
|
+
this.#pendingCardActionProbes.clear();
|
|
293
491
|
this.#status.ready = false;
|
|
294
492
|
if (this.#wsClient) {
|
|
295
493
|
this.#wsClient.close({ force: true });
|