@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.
- package/assets/logo-plugin-message-link-gradient.webp +0 -0
- package/lib/client.js +158 -62
- package/lib/index.js +305 -305
- package/package.json +1 -1
- package/plugin-src/client/bot-general-settings.js +79 -42
- package/plugin-src/client/channels/feishu/api.js +3 -1
- package/plugin-src/client/delivery-settings.js +2 -2
- package/plugin-src/client/i18n.js +8 -7
- package/plugin-src/client/styles.js +19 -0
- package/plugin-src/host/channels/feishu/production.mjs +1 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +18 -0
- package/scripts/verify-package.mjs +11 -6
- package/src/channels/feishu/bridge.mjs +22 -10
- package/src/channels/feishu/feishu-cards.mjs +8 -7
- package/src/channels/feishu/feishu-runtime.mjs +10 -1
- package/src/channels/feishu/multi-bot-controller.mjs +18 -0
- package/src/channels/feishu/plugin-config-store.mjs +2 -1
- package/src/channels/feishu/step-push-mode.mjs +21 -0
- package/src/channels/matrix/matrix-crypto.mjs +19 -5
- package/src/channels/shared/harness-approval.mjs +102 -2
- package/src/channels/shared/i18n-en/shared-c.mjs +4 -0
|
@@ -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
|
-
|
|
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,
|
|
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}',
|