@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.
@@ -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 });