@xmanrui/dsh-im 4.37.0 → 4.38.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.
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
5
- import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
5
+ import { normalizeFeishuStepPushMode, normalizeFeishuStepCardPanels } from './step-push-mode.mjs';
6
6
  import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
7
7
  import { normalizeSlashPanelConfig } from './slash-command-panel.mjs';
8
8
 
@@ -52,6 +52,7 @@ function normalizeBot(value, { legacy = false } = {}) {
52
52
  mentionTopicReply: value.mentionTopicReply !== false,
53
53
  stepPush: value.stepPush === true,
54
54
  stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
55
+ stepCardPanels: normalizeFeishuStepCardPanels(value.stepCardPanels),
55
56
  voice: normalizeFeishuVoiceConfig(value.voice),
56
57
  slashPanel: normalizeSlashPanelConfig(value.slashPanel),
57
58
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
@@ -7,6 +7,27 @@ export const FEISHU_STEP_PUSH_MODES = Object.freeze({
7
7
  /** New connections explicitly opt into the process-card presentation. */
8
8
  export const DEFAULT_FEISHU_STEP_PUSH_MODE = FEISHU_STEP_PUSH_MODES.STREAMING_CARD;
9
9
 
10
+ export const DEFAULT_FEISHU_STEP_CARD_PANELS = Object.freeze({
11
+ thinkingExpanded: false,
12
+ toolsExpanded: true,
13
+ });
14
+
15
+ export function normalizeFeishuStepCardPanels(value) {
16
+ return Object.freeze({
17
+ thinkingExpanded: typeof value?.thinkingExpanded === 'boolean'
18
+ ? value.thinkingExpanded : DEFAULT_FEISHU_STEP_CARD_PANELS.thinkingExpanded,
19
+ toolsExpanded: typeof value?.toolsExpanded === 'boolean'
20
+ ? value.toolsExpanded : DEFAULT_FEISHU_STEP_CARD_PANELS.toolsExpanded,
21
+ });
22
+ }
23
+
24
+ export function isFeishuStepCardPanels(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
26
+ && Object.keys(value).every((key) => key === 'thinkingExpanded' || key === 'toolsExpanded')
27
+ && typeof value.thinkingExpanded === 'boolean'
28
+ && typeof value.toolsExpanded === 'boolean';
29
+ }
30
+
10
31
  export function normalizeFeishuStepPushMode(value) {
11
32
  // Bots created before modes existed used posts when step push was enabled.
12
33
  if (value === FEISHU_STEP_PUSH_MODES.STREAMING_CARD) {
@@ -97,6 +97,7 @@ export class MatrixCryptoEngine {
97
97
  #seenIndex = new Map();
98
98
  #pendingEvents = new Map();
99
99
  #requestState = new Map();
100
+ #pendingTasks = new Set();
100
101
  #lastKeyMaintenanceAt = 0;
101
102
  #stats = {
102
103
  undecryptable: 0,
@@ -239,6 +240,12 @@ export class MatrixCryptoEngine {
239
240
  }
240
241
 
241
242
  async stop() {
243
+ this.#ready = false;
244
+ // A key request can enqueue persistence after shutdown starts. Drain both
245
+ // before releasing sessions or allowing the caller to remove the store.
246
+ while (this.#pendingTasks.size > 0) {
247
+ await Promise.allSettled([...this.#pendingTasks]);
248
+ }
242
249
  for (const entry of this.#inbound.values()) entry.session.free?.();
243
250
  for (const entry of this.#outbound.values()) entry.session.free?.();
244
251
  this.#inbound.clear();
@@ -247,7 +254,12 @@ export class MatrixCryptoEngine {
247
254
  this.#memberCache.clear();
248
255
  this.#pendingEvents.clear();
249
256
  this.#seenIndex.clear();
250
- this.#ready = false;
257
+ }
258
+
259
+ #trackTask(task) {
260
+ const tracked = task.finally(() => this.#pendingTasks.delete(tracked));
261
+ this.#pendingTasks.add(tracked);
262
+ return tracked;
251
263
  }
252
264
 
253
265
  async maintain() {
@@ -717,7 +729,9 @@ export class MatrixCryptoEngine {
717
729
  if (!entry) {
718
730
  this.#stats.undecryptable += 1;
719
731
  this.#pendingEvents.set(`${key}|${event?.event_id ?? 'x'}`, { roomId, event, at: this.#now() });
720
- void this.#requestRoomKey(roomId, event);
732
+ void this.#trackTask(this.#requestRoomKey(roomId, event).catch((error) => {
733
+ this.#logger.warn?.('[dsh-im:matrix] a room key request failed:', error?.message ?? error);
734
+ }));
721
735
  return null;
722
736
  }
723
737
  const seen = this.#seenIndexes(key);
@@ -908,7 +922,7 @@ export class MatrixCryptoEngine {
908
922
  } catch {
909
923
  return Promise.resolve();
910
924
  }
911
- return this.#store.apply({ groupOutbound }).catch(() => undefined);
925
+ return this.#trackTask(this.#store.apply({ groupOutbound }).catch(() => undefined));
912
926
  }
913
927
 
914
928
  #persistInbound(key) {
@@ -936,7 +950,7 @@ export class MatrixCryptoEngine {
936
950
  lastUsedAt: entry.lastUsedAt,
937
951
  },
938
952
  ].slice(-900);
939
- return this.#store.apply({ groupInbound }).catch(() => undefined);
953
+ return this.#trackTask(this.#store.apply({ groupInbound }).catch(() => undefined));
940
954
  }
941
955
 
942
956
  #persistRequestState() {
@@ -944,7 +958,7 @@ export class MatrixCryptoEngine {
944
958
  if (!snapshot) return Promise.resolve();
945
959
  const requestState = {};
946
960
  for (const [key, state] of [...this.#requestState].slice(-200)) requestState[key] = { ...state };
947
- return this.#store.apply({ requestState }).catch(() => undefined);
961
+ return this.#trackTask(this.#store.apply({ requestState }).catch(() => undefined));
948
962
  }
949
963
 
950
964
  // ---- key registration ---------------------------------------------------
@@ -19,6 +19,11 @@ const APPROVAL_UNAVAILABLE_TEXT = '机器人端无法处理这次审批,请到
19
19
  const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
20
20
  const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
21
21
  const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
22
+ const APPROVAL_TIMEOUT_TEXT = '审批已超时,已自动拒绝此次操作。';
23
+ const APPROVAL_TIMEOUT_UNCONFIRMED_TEXT = '审批已超时,正在自动拒绝,但暂未得到确认,稍后会自动重试。';
24
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 60 * 60_000;
25
+ const EXPIRY_RETRY_BASE_MS = 30_000;
26
+ const EXPIRY_RETRY_MAX_MS = 5 * 60_000;
22
27
  const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
23
28
  const MAX_RESOLVED_ROUTES = 2_048;
24
29
 
@@ -102,6 +107,7 @@ function approvalResult(pending, outcome) {
102
107
  }
103
108
 
104
109
  function approvalOutcomeText(outcome) {
110
+ if (outcome === 'expired') return t(APPROVAL_TIMEOUT_TEXT);
105
111
  if (outcome === 'unavailable') return t(APPROVAL_UNAVAILABLE_TEXT);
106
112
  if (outcome === 'allowed-once') return t('已批准,仅对本次操作有效。');
107
113
  if (outcome === 'rejected') return t('已拒绝此次操作。');
@@ -114,10 +120,14 @@ export class HarnessApprovalQueue {
114
120
  #byId = new Map();
115
121
  #routes = new Map();
116
122
  #resolvedRoutes = new Map();
123
+ #approvalTimeoutMs;
117
124
 
118
- constructor({ label = 'IM', logger = console } = {}) {
125
+ constructor({ label = 'IM', logger = console, approvalTimeoutMs = DEFAULT_APPROVAL_TIMEOUT_MS } = {}) {
119
126
  this.#label = label;
120
127
  this.#logger = logger;
128
+ this.#approvalTimeoutMs = Number.isFinite(approvalTimeoutMs) && approvalTimeoutMs > 0
129
+ ? approvalTimeoutMs
130
+ : DEFAULT_APPROVAL_TIMEOUT_MS;
121
131
  }
122
132
 
123
133
  hasPending(key) {
@@ -184,6 +194,10 @@ export class HarnessApprovalQueue {
184
194
  }
185
195
  await pending.activationTask?.catch(() => undefined);
186
196
  await pending.presentationTask?.catch(() => undefined);
197
+ if (!pending.inactive && pending.expiring) {
198
+ await send(t(APPROVAL_TIMEOUT_UNCONFIRMED_TEXT));
199
+ return;
200
+ }
187
201
  if (pending.inactive || pending.resolving) {
188
202
  await send(t(APPROVAL_RESOLVED_TEXT));
189
203
  return;
@@ -338,11 +352,22 @@ export class HarnessApprovalQueue {
338
352
  closedOutcome: null,
339
353
  resolutionNotified: false,
340
354
  activationTask: null,
355
+ timeoutTimer: null,
356
+ expired: false,
357
+ expiring: false,
358
+ expiryAttempts: 0,
359
+ expiryNoticeSent: false,
341
360
  };
342
361
  this.#byId.set(approvalId, pending);
343
362
  const route = this.#routes.get(key) ?? { items: [] };
344
363
  route.items.push(pending);
345
364
  this.#routes.set(key, route);
365
+ pending.timeoutTimer = setTimeout(() => {
366
+ void this.#expire(pending).catch((error) => {
367
+ this.#logger.warn?.(`[dsh-im:${this.#label}] failed to expire an approval:`, error);
368
+ });
369
+ }, this.#approvalTimeoutMs);
370
+ pending.timeoutTimer.unref?.();
346
371
  if (route.items[0] === pending) await this.#present(pending);
347
372
  return true;
348
373
  }
@@ -377,13 +402,15 @@ export class HarnessApprovalQueue {
377
402
  const shouldNotify = pending.presented || presentationTask;
378
403
  const send = pending.send;
379
404
  const next = this.#remove(pending);
405
+ // The host reports our own timeout rejection as a plain rejection.
406
+ const outcome = pending.expiring && resolution.outcome === 'rejected' ? 'expired' : resolution.outcome;
380
407
  await this.#transition(next, async () => {
381
408
  let delivered = pending.presented;
382
409
  if (presentationTask) {
383
410
  delivered = await presentationTask.then(() => true, () => false);
384
411
  }
385
412
  if (shouldNotify && delivered) {
386
- await this.#notifyResolved(pending, resolution.outcome, send);
413
+ await this.#notifyResolved(pending, outcome, send);
387
414
  }
388
415
  });
389
416
  return true;
@@ -453,6 +480,70 @@ export class HarnessApprovalQueue {
453
480
  }
454
481
  }
455
482
 
483
+ async #expire(pending) {
484
+ pending.timeoutTimer = null;
485
+ pending.expired = true;
486
+ // An in-flight decision wins; #submit expires the approval if it fails.
487
+ if (pending.inactive || pending.resolving || pending.submitting) return;
488
+ pending.resolving = true;
489
+ pending.expiring = true;
490
+ // Keep the route barrier: an earlier confirmation must finish before a
491
+ // later approval can be presented.
492
+ await pending.activationTask?.catch(() => undefined);
493
+ if (pending.inactive || this.#byId.get(pending.approvalId) !== pending) return;
494
+ await this.#releaseExpired(pending);
495
+ }
496
+
497
+ async #releaseExpired(pending) {
498
+ pending.timeoutTimer = null;
499
+ if (pending.inactive) return;
500
+ let outcome = 'expired';
501
+ try {
502
+ // Reject rather than withdraw: a withdrawal only retires the IM side and
503
+ // would leave a shared Web approval waiting without a bound.
504
+ await pending.interaction.respond(
505
+ approvalResult(pending, 'rejected'),
506
+ { signal: AbortSignal.timeout(5_000) },
507
+ );
508
+ } catch (error) {
509
+ if (error?.code !== 'interaction-not-pending') {
510
+ if (pending.inactive) return;
511
+ this.#logger.warn?.(`[dsh-im:${this.#label}] failed to release an expired approval:`, error);
512
+ this.#retryExpiry(pending);
513
+ if (!pending.expiryNoticeSent && pending.presented) {
514
+ pending.expiryNoticeSent = true;
515
+ await pending.send(t(APPROVAL_TIMEOUT_UNCONFIRMED_TEXT)).catch(() => undefined);
516
+ }
517
+ return;
518
+ }
519
+ outcome = 'resolved';
520
+ }
521
+ if (pending.inactive) return;
522
+ const presentationTask = pending.presentationTask;
523
+ const shouldNotify = pending.presented || presentationTask;
524
+ const send = pending.send;
525
+ const next = this.#remove(pending);
526
+ pending.closedOutcome = outcome;
527
+ await this.#transition(next, async () => {
528
+ let delivered = pending.presented;
529
+ if (presentationTask) {
530
+ delivered = await presentationTask.then(() => true, () => false);
531
+ }
532
+ if (shouldNotify && delivered) await this.#notifyResolved(pending, outcome, send);
533
+ });
534
+ }
535
+
536
+ #retryExpiry(pending) {
537
+ const delay = Math.min(EXPIRY_RETRY_BASE_MS * 2 ** pending.expiryAttempts, EXPIRY_RETRY_MAX_MS);
538
+ pending.expiryAttempts += 1;
539
+ pending.timeoutTimer = setTimeout(() => {
540
+ void this.#releaseExpired(pending).catch((error) => {
541
+ this.#logger.warn?.(`[dsh-im:${this.#label}] failed to expire an approval:`, error);
542
+ });
543
+ }, delay);
544
+ pending.timeoutTimer.unref?.();
545
+ }
546
+
456
547
  async #submit(pending, outcome) {
457
548
  pending.submitting = true;
458
549
  try {
@@ -478,6 +569,11 @@ export class HarnessApprovalQueue {
478
569
  if (pending.inactive) return;
479
570
  pending.submitting = false;
480
571
  this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error);
572
+ // The deadline passed while this decision was in flight.
573
+ if (pending.expired) {
574
+ await this.#expire(pending);
575
+ return;
576
+ }
481
577
  await pending.send(t('审批提交失败,请重新回复「批准」或「拒绝」。')).catch(() => undefined);
482
578
  return;
483
579
  }
@@ -536,6 +632,10 @@ export class HarnessApprovalQueue {
536
632
  #remove(pending) {
537
633
  if (pending.inactive) return null;
538
634
  pending.inactive = true;
635
+ if (pending.timeoutTimer) {
636
+ clearTimeout(pending.timeoutTimer);
637
+ pending.timeoutTimer = null;
638
+ }
539
639
  this.#rememberResolvedRoute(pending.key);
540
640
  this.#byId.delete(pending.approvalId);
541
641
  const route = this.#routes.get(pending.key);
@@ -56,6 +56,10 @@ export default {
56
56
  'Please finish the current question first, then reply exactly with 「批准」 (approve) or 「拒绝」 (reject).',
57
57
  '该审批已处理,无需再次回复。':
58
58
  'This approval has already been handled; no need to reply again.',
59
+ '审批已超时,已自动拒绝此次操作。':
60
+ 'This approval timed out and the operation was rejected automatically.',
61
+ '审批已超时,正在自动拒绝,但暂未得到确认,稍后会自动重试。':
62
+ 'This approval timed out. Automatic rejection has not been confirmed yet and will be retried shortly.',
59
63
  'DeepSeek Harness 需要你的审批:':
60
64
  'DeepSeek Harness needs your approval:',
61
65
  '工具:{tool}': 'Tool: {tool}',