@xmanrui/dsh-im 4.9.1 → 4.10.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/PROACTIVE_DELIVERY.en.md +8 -0
- package/PROACTIVE_DELIVERY.md +8 -0
- package/lib/client.js +610 -56
- package/lib/index.js +273 -272
- package/package.json +12 -1
- package/plugin-src/client/channels/feishu/api.js +2 -0
- package/plugin-src/client/channels/feishu/index.js +54 -0
- package/plugin-src/client/global-settings.js +341 -0
- package/plugin-src/client/i18n.js +29 -0
- package/plugin-src/client/index.js +47 -6
- package/plugin-src/client/styles.js +49 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
- package/plugin-src/host/channels/feishu/production.mjs +14 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
- package/plugin-src/host/channels/qq/production.mjs +12 -0
- package/plugin-src/host/channels/shared/production.mjs +12 -0
- package/plugin-src/host/channels/slack/production.mjs +12 -0
- package/plugin-src/host/channels/wecom/production.mjs +12 -0
- package/plugin-src/host/channels/weixin/production.mjs +12 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
- package/plugin-src/host/delivery-adapter.mjs +1 -0
- package/plugin-src/host/delivery-service.mjs +15 -0
- package/plugin-src/host/harness-session-coordinator.mjs +26 -5
- package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
- package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
- package/plugin-src/host/inbound-ttl-service.mjs +179 -0
- package/plugin-src/host/index.mjs +8 -0
- package/plugin-src/host/modern-harness-api.mjs +58 -2
- package/src/channels/feishu/bridge.mjs +164 -4
- package/src/channels/feishu/feishu-channel.mjs +45 -10
- package/src/channels/feishu/feishu-runtime.mjs +9 -0
- package/src/channels/feishu/message-utils.mjs +13 -0
- package/src/channels/feishu/multi-bot-controller.mjs +16 -0
- package/src/channels/feishu/plugin-config-store.mjs +1 -0
- package/src/channels/feishu/state-store.mjs +25 -0
- package/src/channels/shared/bot-workspace-store.mjs +6 -0
- package/src/channels/shared/inbound-file.mjs +117 -2
- package/src/channels/shared/inbound-ttl-store.mjs +110 -0
- package/src/channels/shared/inbound-ttl.mjs +22 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { sweepInboundAttachments } from '../../src/channels/shared/inbound-file.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 30 * 60_000;
|
|
7
|
+
|
|
8
|
+
// A tracked directory is shielded from sweeps while its turn may still be
|
|
9
|
+
// running. Aborted or stalled turns never call cleanup(), so protection has
|
|
10
|
+
// an upper bound — far above the default reply timeout — after which the
|
|
11
|
+
// directory falls back under TTL control instead of leaking forever.
|
|
12
|
+
const TRACKED_PROTECTION_MS = 24 * 60 * 60_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Host-process coordinator for the global inbound attachment TTL. The staged
|
|
16
|
+
* registry marks directories owned by in-flight turns so a slow turn can
|
|
17
|
+
* outlive any TTL window without the sweeper deleting its files.
|
|
18
|
+
*/
|
|
19
|
+
export function createInboundTtlService({
|
|
20
|
+
store,
|
|
21
|
+
logger = console,
|
|
22
|
+
intervalMs = DEFAULT_SWEEP_INTERVAL_MS,
|
|
23
|
+
} = {}) {
|
|
24
|
+
if (!store || typeof store.getTtlHours !== 'function') {
|
|
25
|
+
throw new TypeError('createInboundTtlService requires a store exposing getTtlHours()');
|
|
26
|
+
}
|
|
27
|
+
const tracked = new Map();
|
|
28
|
+
const providers = new Set();
|
|
29
|
+
let timer = null;
|
|
30
|
+
|
|
31
|
+
const runSweep = () => {
|
|
32
|
+
sweepNow().catch((error) => {
|
|
33
|
+
logger.error?.('[dsh-im] inbound attachment sweep failed', error);
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function registerWorkspaceProvider(provider) {
|
|
38
|
+
if (typeof provider !== 'function') {
|
|
39
|
+
throw new TypeError('inbound TTL workspace provider must be a function');
|
|
40
|
+
}
|
|
41
|
+
const wasEmpty = providers.size === 0;
|
|
42
|
+
providers.add(provider);
|
|
43
|
+
// The startup sweep races channel activation and usually runs before any
|
|
44
|
+
// provider exists; re-sweep on the first registration so crash leftovers
|
|
45
|
+
// do not wait a full interval.
|
|
46
|
+
if (wasEmpty && timer) runSweep();
|
|
47
|
+
return () => {
|
|
48
|
+
providers.delete(provider);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stagingRetention() {
|
|
53
|
+
// TTL 0 deletes at turn end; every other mode keeps files for the sweeper.
|
|
54
|
+
return store.getTtlHours() === 0 ? 'turn' : 'persistent';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function canonicalPath(directory) {
|
|
58
|
+
try {
|
|
59
|
+
return realpathSync(directory);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function trackDirectory(directory, { now = () => Date.now() } = {}) {
|
|
66
|
+
if (typeof directory !== 'string' || !directory) return;
|
|
67
|
+
const trackedAt = now();
|
|
68
|
+
tracked.set(directory, trackedAt);
|
|
69
|
+
// The harness-reported cwd and workspaceFor() may normalize symlinks
|
|
70
|
+
// differently; track both spellings so either side finds the entry.
|
|
71
|
+
const canonical = canonicalPath(directory);
|
|
72
|
+
if (canonical && canonical !== directory) tracked.set(canonical, trackedAt);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function releaseDirectory(directory) {
|
|
76
|
+
tracked.delete(directory);
|
|
77
|
+
const canonical = canonicalPath(directory);
|
|
78
|
+
if (canonical && canonical !== directory) tracked.delete(canonical);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isTrackedNow(directory) {
|
|
82
|
+
const trackedAt = tracked.get(directory);
|
|
83
|
+
if (trackedAt === undefined) return false;
|
|
84
|
+
if (Date.now() - trackedAt >= TRACKED_PROTECTION_MS) {
|
|
85
|
+
tracked.delete(directory);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function trackStaged(staged) {
|
|
92
|
+
if (!staged || typeof staged !== 'object') return staged;
|
|
93
|
+
const { directory } = staged;
|
|
94
|
+
if (typeof directory !== 'string' || !directory) return staged;
|
|
95
|
+
trackDirectory(directory);
|
|
96
|
+
const cleanup = typeof staged.cleanup === 'function' ? staged.cleanup : null;
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
...staged,
|
|
99
|
+
async cleanup() {
|
|
100
|
+
try {
|
|
101
|
+
await cleanup?.call(staged);
|
|
102
|
+
} finally {
|
|
103
|
+
releaseDirectory(directory);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function sweepNow() {
|
|
110
|
+
const ttlHours = store.getTtlHours();
|
|
111
|
+
// Keep-forever never deletes, so no workspace is even inspected.
|
|
112
|
+
if (ttlHours === -1) return { deletedDirectories: 0, sweptWorkspaces: 0 };
|
|
113
|
+
const workspaces = new Set();
|
|
114
|
+
for (const provider of providers) {
|
|
115
|
+
try {
|
|
116
|
+
const provided = await provider();
|
|
117
|
+
for (const workspace of Array.isArray(provided) ? provided : []) {
|
|
118
|
+
if (typeof workspace === 'string' && workspace) workspaces.add(resolve(workspace));
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.warn?.('[dsh-im] inbound TTL workspace provider failed', error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const isTracked = (directory) => isTrackedNow(directory)
|
|
125
|
+
|| (canonicalPath(directory) !== null && isTrackedNow(canonicalPath(directory)));
|
|
126
|
+
let deletedDirectories = 0;
|
|
127
|
+
let sweptWorkspaces = 0;
|
|
128
|
+
for (const workspace of workspaces) {
|
|
129
|
+
try {
|
|
130
|
+
const result = await sweepInboundAttachments(workspace, ttlHours, { isTracked });
|
|
131
|
+
deletedDirectories += result.deleted;
|
|
132
|
+
sweptWorkspaces += 1;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logger.warn?.(`[dsh-im] inbound attachment sweep failed under ${workspace}`, error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (deletedDirectories > 0) {
|
|
138
|
+
logger.info?.(
|
|
139
|
+
`[dsh-im] inbound attachment sweep deleted ${deletedDirectories} directories`
|
|
140
|
+
+ ` across ${sweptWorkspaces} workspaces (ttl ${ttlHours}h)`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return { deletedDirectories, sweptWorkspaces };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function start() {
|
|
147
|
+
if (timer) return;
|
|
148
|
+
runSweep();
|
|
149
|
+
timer = setInterval(runSweep, intervalMs);
|
|
150
|
+
timer.unref?.();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A freshly saved TTL postpones the next scheduled sweep by a full
|
|
154
|
+
// interval so the new value is never enforced by a sweep firing moments
|
|
155
|
+
// after the change, and is not starved of one either.
|
|
156
|
+
function resetSweepSchedule() {
|
|
157
|
+
if (!timer) return;
|
|
158
|
+
clearInterval(timer);
|
|
159
|
+
timer = setInterval(runSweep, intervalMs);
|
|
160
|
+
timer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stop() {
|
|
164
|
+
if (!timer) return;
|
|
165
|
+
clearInterval(timer);
|
|
166
|
+
timer = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Object.freeze({
|
|
170
|
+
registerWorkspaceProvider,
|
|
171
|
+
stagingRetention,
|
|
172
|
+
trackDirectory,
|
|
173
|
+
trackStaged,
|
|
174
|
+
sweepNow,
|
|
175
|
+
start,
|
|
176
|
+
resetSweepSchedule,
|
|
177
|
+
stop,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -13,6 +13,7 @@ import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
|
|
|
13
13
|
import { installDeliveryRpc } from './delivery-rpc.mjs';
|
|
14
14
|
import { installDeliveryHttp } from './delivery-http.mjs';
|
|
15
15
|
import { createDeliveryService } from './delivery-service.mjs';
|
|
16
|
+
import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
|
|
16
17
|
import { installUpdateRpc } from './update-rpc.mjs';
|
|
17
18
|
|
|
18
19
|
export const name = 'dsh-im-host';
|
|
@@ -32,6 +33,7 @@ function channelConfig(config, name, deliveryService) {
|
|
|
32
33
|
|
|
33
34
|
export function createImHostPlugin(internals = {}) {
|
|
34
35
|
const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
|
|
36
|
+
const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
|
|
35
37
|
const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
|
|
36
38
|
const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
|
|
37
39
|
const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
|
|
@@ -68,6 +70,7 @@ export function createImHostPlugin(internals = {}) {
|
|
|
68
70
|
deliveryService.send(botId, targetId, text, options)
|
|
69
71
|
),
|
|
70
72
|
listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
|
|
73
|
+
listBots: () => deliveryService.listBots(),
|
|
71
74
|
}));
|
|
72
75
|
}
|
|
73
76
|
const activate = async (readyCtx) => {
|
|
@@ -109,6 +112,11 @@ export function createImHostPlugin(internals = {}) {
|
|
|
109
112
|
} catch (error) {
|
|
110
113
|
logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
|
|
111
114
|
}
|
|
115
|
+
try {
|
|
116
|
+
startInboundTtl(ctx, { config });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
logger.error?.('[dsh-im] failed to activate inbound TTL settings; continuing with channels', error);
|
|
119
|
+
}
|
|
112
120
|
try {
|
|
113
121
|
startDelivery(ctx, deliveryService, { authority: config.rpcAuthority });
|
|
114
122
|
} catch (error) {
|
|
@@ -200,6 +200,7 @@ class ModernHarnessApi {
|
|
|
200
200
|
#pendingQuestions = new Map();
|
|
201
201
|
#pendingApprovals = new Map();
|
|
202
202
|
#sessionCursors = new Map();
|
|
203
|
+
#assistantStreams = new Map();
|
|
203
204
|
#disposers = [];
|
|
204
205
|
#disposed = false;
|
|
205
206
|
|
|
@@ -261,6 +262,13 @@ class ModernHarnessApi {
|
|
|
261
262
|
if (Number.isSafeInteger(event.seq)) this.#rememberCursor(sessionId, event.seq);
|
|
262
263
|
this.#broadcast({ type: 'session/event', sessionId, event });
|
|
263
264
|
}, { global: true }));
|
|
265
|
+
this.#disposers.push(ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
266
|
+
this.#acceptAssistantStream(agent, frame);
|
|
267
|
+
}, { global: true }));
|
|
268
|
+
this.#disposers.push(ctx.on('agent/disposed', ({ agent }) => {
|
|
269
|
+
const sessionId = agent?.session?.id;
|
|
270
|
+
if (typeof sessionId === 'string') this.#assistantStreams.delete(sessionId);
|
|
271
|
+
}, { global: true }));
|
|
264
272
|
this.#disposers.push(ctx.on(
|
|
265
273
|
'approval/request',
|
|
266
274
|
(request, next) => this.#requestApproval(request, next),
|
|
@@ -328,7 +336,7 @@ class ModernHarnessApi {
|
|
|
328
336
|
cursor = this.#sessionCursors.get(sessionId) ?? snapshot.cursor;
|
|
329
337
|
if (beforeSeq === undefined && cursor === snapshot.cursor) {
|
|
330
338
|
return {
|
|
331
|
-
events: historyEntries(snapshot.records),
|
|
339
|
+
events: this.#withAssistantStream(sessionId, historyEntries(snapshot.records)),
|
|
332
340
|
hasMore: snapshot.hasMore === true,
|
|
333
341
|
...(snapshot.projections === undefined ? {} : { projections: snapshot.projections }),
|
|
334
342
|
};
|
|
@@ -342,7 +350,54 @@ class ModernHarnessApi {
|
|
|
342
350
|
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
343
351
|
},
|
|
344
352
|
}, signal);
|
|
345
|
-
return {
|
|
353
|
+
return {
|
|
354
|
+
events: beforeSeq === undefined
|
|
355
|
+
? this.#withAssistantStream(sessionId, historyEntries(page.records))
|
|
356
|
+
: historyEntries(page.records),
|
|
357
|
+
hasMore: page.hasMore === true,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#acceptAssistantStream(agent, frame) {
|
|
362
|
+
const sessionId = agent?.session?.id;
|
|
363
|
+
if (typeof sessionId !== 'string' || !frame || typeof frame !== 'object') return;
|
|
364
|
+
if (frame.type === 'start') {
|
|
365
|
+
const nextSeq = agent?.session?.seq;
|
|
366
|
+
const startedAfterSeq = Number.isSafeInteger(nextSeq) && nextSeq >= 0
|
|
367
|
+
? nextSeq - 1
|
|
368
|
+
: this.#sessionCursors.get(sessionId) ?? -1;
|
|
369
|
+
this.#assistantStreams.set(sessionId, {
|
|
370
|
+
attemptId: frame.attemptId,
|
|
371
|
+
turn: frame.turn,
|
|
372
|
+
step: frame.step,
|
|
373
|
+
startedAfterSeq,
|
|
374
|
+
entries: [],
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const stream = this.#assistantStreams.get(sessionId);
|
|
379
|
+
if (!stream || stream.attemptId !== frame.attemptId) return;
|
|
380
|
+
if (frame.type === 'end') {
|
|
381
|
+
this.#assistantStreams.delete(sessionId);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (frame.type !== 'chunk' || frame.index !== stream.entries.length) return;
|
|
385
|
+
stream.entries.push({
|
|
386
|
+
type: 'transient',
|
|
387
|
+
event: {
|
|
388
|
+
type: 'assistant/chunk',
|
|
389
|
+
// Match DSH's Web client ordering: live chunks sit in the numeric gap
|
|
390
|
+
// before the durable assistant/message that replaces them.
|
|
391
|
+
seq: stream.startedAfterSeq + 1 - 1 / (frame.index + 2),
|
|
392
|
+
time: frame.time,
|
|
393
|
+
data: { turn: stream.turn, step: stream.step, chunk: frame.chunk },
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#withAssistantStream(sessionId, entries) {
|
|
399
|
+
const transient = this.#assistantStreams.get(sessionId)?.entries ?? [];
|
|
400
|
+
return transient.length === 0 ? entries : [...entries, ...transient];
|
|
346
401
|
}
|
|
347
402
|
|
|
348
403
|
#rememberCursor(sessionId, cursor) {
|
|
@@ -556,6 +611,7 @@ class ModernHarnessApi {
|
|
|
556
611
|
if (this.#disposed) return;
|
|
557
612
|
this.#disposed = true;
|
|
558
613
|
for (const subscription of [...this.#mux]) subscription.close();
|
|
614
|
+
this.#assistantStreams.clear();
|
|
559
615
|
for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
|
|
560
616
|
for (const pending of [...this.#pendingQuestions.values()]) pending.settle(
|
|
561
617
|
'cancelled',
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
extractText,
|
|
6
6
|
isAllowedSender,
|
|
7
7
|
isBotSender,
|
|
8
|
+
isTopicGroupKey,
|
|
9
|
+
managedGroupKey,
|
|
8
10
|
splitText,
|
|
9
11
|
} from './message-utils.mjs';
|
|
10
12
|
import {
|
|
@@ -101,6 +103,10 @@ import {
|
|
|
101
103
|
// Lazily evaluated: t() must run after setImHostLanguage, not at import time.
|
|
102
104
|
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
|
|
103
105
|
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
106
|
+
/** Managed-topic root candidates expire without a returned thread_id. */
|
|
107
|
+
const ROOT_CANDIDATE_TTL_MS = 30 * 60_000;
|
|
108
|
+
/** Bound main-feed roots awaiting a topic so silent drops cannot grow them. */
|
|
109
|
+
const MAX_ROOT_CANDIDATES = 512;
|
|
104
110
|
|
|
105
111
|
const MENU_COMMAND = /^\/m(?:enu)?$/i;
|
|
106
112
|
const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
|
|
@@ -480,6 +486,14 @@ export class FeishuHarnessBridge {
|
|
|
480
486
|
#appId;
|
|
481
487
|
#botOpenId;
|
|
482
488
|
#groupResponseMode;
|
|
489
|
+
/** When true, group replies that belong to a Feishu topic ask reply_in_thread. */
|
|
490
|
+
#groupTopicReply = false;
|
|
491
|
+
/** Group chats this bot has seen; only these may use topic replies. */
|
|
492
|
+
#groupChatIds = new Set();
|
|
493
|
+
/** Anchor message id → whether its replies should stay in a Feishu topic. */
|
|
494
|
+
#anchorTopicReply = new Map();
|
|
495
|
+
/** Main-feed roots this bot intends to auto-open as topics, awaiting thread_id. */
|
|
496
|
+
#rootCandidates = new Map();
|
|
483
497
|
#repair;
|
|
484
498
|
#repairAttempt = null;
|
|
485
499
|
#repairMonitorVersion = 0;
|
|
@@ -516,6 +530,7 @@ export class FeishuHarnessBridge {
|
|
|
516
530
|
appId,
|
|
517
531
|
botOpenId,
|
|
518
532
|
groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
|
|
533
|
+
groupTopicReply = false,
|
|
519
534
|
repair,
|
|
520
535
|
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
|
521
536
|
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
|
@@ -555,6 +570,7 @@ export class FeishuHarnessBridge {
|
|
|
555
570
|
this.#appId = nonEmptyString(appId);
|
|
556
571
|
this.#botOpenId = nonEmptyString(botOpenId);
|
|
557
572
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
573
|
+
this.#groupTopicReply = groupTopicReply === true;
|
|
558
574
|
this.#repair = repair ?? null;
|
|
559
575
|
this.#repairPollIntervalMs = repairPollIntervalMs;
|
|
560
576
|
this.#repairLinkWaitMs = repairLinkWaitMs;
|
|
@@ -576,6 +592,10 @@ export class FeishuHarnessBridge {
|
|
|
576
592
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
|
577
593
|
}
|
|
578
594
|
|
|
595
|
+
setGroupTopicReply(value) {
|
|
596
|
+
this.#groupTopicReply = value === true;
|
|
597
|
+
}
|
|
598
|
+
|
|
579
599
|
#isAddressed(event) {
|
|
580
600
|
if (event?.message?.chat_type === 'p2p') return true;
|
|
581
601
|
const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
|
|
@@ -584,6 +604,106 @@ export class FeishuHarnessBridge {
|
|
|
584
604
|
|| mention?.open_id === this.#botOpenId);
|
|
585
605
|
}
|
|
586
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Conversation key for one inbound event. When group-topic replies are on,
|
|
609
|
+
* a main-feed question addressed to the bot opens a fresh managed topic
|
|
610
|
+
* session instead of joining the shared group session, and messages inside
|
|
611
|
+
* a topic we already opened resolve back to that same managed session.
|
|
612
|
+
*/
|
|
613
|
+
#resolveKey(event) {
|
|
614
|
+
const chatType = event?.message?.chat_type;
|
|
615
|
+
const chatId = nonEmptyString(event?.message?.chat_id);
|
|
616
|
+
if (chatType !== 'group') return conversationKey(event);
|
|
617
|
+
const messageId = nonEmptyString(event?.message?.message_id);
|
|
618
|
+
const threadId = nonEmptyString(event?.message?.thread_id);
|
|
619
|
+
if (threadId) {
|
|
620
|
+
const root = this.#state?.topicRootFor?.(threadId) ?? null;
|
|
621
|
+
return root && chatId
|
|
622
|
+
? managedGroupKey(chatId, root.rootMessageId)
|
|
623
|
+
: `group:${chatId}:thread:${threadId}`;
|
|
624
|
+
}
|
|
625
|
+
if (this.#groupTopicReply && chatId && messageId && this.#isAddressed(event)) {
|
|
626
|
+
return managedGroupKey(chatId, messageId);
|
|
627
|
+
}
|
|
628
|
+
return `group:${chatId}`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Record whether replies anchored on `messageId` should stay in a topic.
|
|
633
|
+
* `asRoot` marks a main-feed question this bot will auto-open as a topic.
|
|
634
|
+
*/
|
|
635
|
+
#rememberTopicReply(messageId, key) {
|
|
636
|
+
if (!nonEmptyString(messageId)) return;
|
|
637
|
+
this.#anchorTopicReply.set(messageId, this.#groupTopicReply && isTopicGroupKey(key));
|
|
638
|
+
if (this.#anchorTopicReply.size > 2048) {
|
|
639
|
+
const oldest = this.#anchorTopicReply.keys().next().value;
|
|
640
|
+
if (oldest !== undefined) this.#anchorTopicReply.delete(oldest);
|
|
641
|
+
}
|
|
642
|
+
if (typeof key === 'string' && key.startsWith('group:')) {
|
|
643
|
+
const chatId = key.split(':')[1];
|
|
644
|
+
if (chatId) this.#groupChatIds.add(chatId);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Whether a reply anchored on `replyTo` must ask Feishu to keep it in a topic. */
|
|
649
|
+
#replyInThreadFor(replyTo) {
|
|
650
|
+
return this.#groupTopicReply
|
|
651
|
+
&& nonEmptyString(replyTo)
|
|
652
|
+
&& this.#anchorTopicReply.get(replyTo) === true;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Drop expired root candidates so a silently dropped reply cannot leak memory. */
|
|
656
|
+
#pruneRootCandidates(now = Date.now()) {
|
|
657
|
+
for (const [messageId, expiresAt] of this.#rootCandidates) {
|
|
658
|
+
if (expiresAt <= now) this.#rootCandidates.delete(messageId);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Track a main-feed root this bot will auto-open as a topic, bounded + TTL'd. */
|
|
663
|
+
#rememberTopicRoot(messageId) {
|
|
664
|
+
if (!nonEmptyString(messageId)) return;
|
|
665
|
+
const now = Date.now();
|
|
666
|
+
this.#pruneRootCandidates(now);
|
|
667
|
+
this.#rootCandidates.delete(messageId);
|
|
668
|
+
this.#rootCandidates.set(messageId, now + ROOT_CANDIDATE_TTL_MS);
|
|
669
|
+
while (this.#rootCandidates.size > MAX_ROOT_CANDIDATES) {
|
|
670
|
+
const oldest = this.#rootCandidates.keys().next().value;
|
|
671
|
+
if (oldest === undefined) break;
|
|
672
|
+
this.#rootCandidates.delete(oldest);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Forget a root candidate once its topic opened or the reply degraded. */
|
|
677
|
+
#forgetTopicRoot(messageId) {
|
|
678
|
+
if (!nonEmptyString(messageId)) return;
|
|
679
|
+
this.#rootCandidates.delete(messageId);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Persist thread_id once Feishu auto-opened a topic from a managed root. */
|
|
683
|
+
async #registerTopicFromReply(replyTo, chatId, response) {
|
|
684
|
+
const threadId = nonEmptyString(response?.data?.thread_id);
|
|
685
|
+
if (threadId) {
|
|
686
|
+
await this.#registerTopicThreadId(threadId, replyTo, chatId);
|
|
687
|
+
} else {
|
|
688
|
+
// No thread_id means the topic did not open; drop the candidate so a
|
|
689
|
+
// later addressed question opens a fresh session, not a stale slot.
|
|
690
|
+
this.#forgetTopicRoot(replyTo);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Persist thread_id when the reply anchor is a root this bot auto-opened. */
|
|
695
|
+
async #registerTopicThreadId(threadId, rootMessageId, chatId) {
|
|
696
|
+
this.#pruneRootCandidates();
|
|
697
|
+
if (!threadId || !this.#rootCandidates.has(rootMessageId)) return;
|
|
698
|
+
this.#rootCandidates.delete(rootMessageId);
|
|
699
|
+
if (!chatId || typeof this.#state?.setTopic !== 'function') return;
|
|
700
|
+
try {
|
|
701
|
+
await this.#state.setTopic(threadId, { rootMessageId, chatId });
|
|
702
|
+
} catch (error) {
|
|
703
|
+
this.#logger.warn?.('[dsh-feishu] failed to record managed topic:', error?.message ?? String(error));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
587
707
|
accept(event) {
|
|
588
708
|
if (this.#signal?.aborted) return Promise.resolve();
|
|
589
709
|
const messageId = nonEmptyString(event?.message?.message_id);
|
|
@@ -606,12 +726,13 @@ export class FeishuHarnessBridge {
|
|
|
606
726
|
|
|
607
727
|
let key;
|
|
608
728
|
try {
|
|
609
|
-
key =
|
|
729
|
+
key = this.#resolveKey(event);
|
|
610
730
|
} catch {
|
|
611
731
|
this.#status.messagesRejected += 1;
|
|
612
732
|
this.#status.lastRejectedAt = new Date().toISOString();
|
|
613
733
|
return Promise.resolve();
|
|
614
734
|
}
|
|
735
|
+
this.#rememberTopicReply(messageId, key);
|
|
615
736
|
|
|
616
737
|
const commandMessage = extractInboundMessage(event, this.#client);
|
|
617
738
|
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
|
@@ -635,6 +756,15 @@ export class FeishuHarnessBridge {
|
|
|
635
756
|
this.#acceptedMessageIds.set(messageId, null);
|
|
636
757
|
return this.#finishAccessDecision(event, messageId, access);
|
|
637
758
|
}
|
|
759
|
+
// Register a managed-topic root only after access is allowed, so a
|
|
760
|
+
// rejected or ignored question never reserves a candidate slot.
|
|
761
|
+
if (this.#groupTopicReply
|
|
762
|
+
&& event?.message?.chat_type === 'group'
|
|
763
|
+
&& !nonEmptyString(event?.message?.thread_id)
|
|
764
|
+
&& isTopicGroupKey(key)
|
|
765
|
+
&& key.includes(':managed:')) {
|
|
766
|
+
this.#rememberTopicRoot(messageId);
|
|
767
|
+
}
|
|
638
768
|
if (event.message.chat_type === 'p2p') {
|
|
639
769
|
const chatId = nonEmptyString(event.message.chat_id);
|
|
640
770
|
if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
|
|
@@ -1910,6 +2040,7 @@ export class FeishuHarnessBridge {
|
|
|
1910
2040
|
}) {
|
|
1911
2041
|
// Confirmations triggered by a card interaction stay anchored to the
|
|
1912
2042
|
// card's message so they land inside the same Feishu topic.
|
|
2043
|
+
this.#rememberTopicReply(messageId, key);
|
|
1913
2044
|
const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
|
|
1914
2045
|
// Approval card buttons: approve:<approvalId> / reject:<approvalId>
|
|
1915
2046
|
if (action.startsWith('approve:') || action.startsWith('reject:')) {
|
|
@@ -2383,18 +2514,25 @@ export class FeishuHarnessBridge {
|
|
|
2383
2514
|
try {
|
|
2384
2515
|
const response = await this.#client.im.v1.message.reply({
|
|
2385
2516
|
path: { message_id: replyTo },
|
|
2386
|
-
data: {
|
|
2517
|
+
data: {
|
|
2518
|
+
msg_type: 'interactive',
|
|
2519
|
+
content,
|
|
2520
|
+
...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
|
|
2521
|
+
},
|
|
2387
2522
|
});
|
|
2388
2523
|
if (response?.code && response.code !== 0) {
|
|
2389
2524
|
throw new Error(`Feishu card reply failed: ${response.msg || response.code}`);
|
|
2390
2525
|
}
|
|
2391
2526
|
const repliedMessageId = nonEmptyString(response?.data?.message_id);
|
|
2527
|
+
await this.#registerTopicFromReply(replyTo, chatId, response);
|
|
2392
2528
|
if (repliedMessageId) {
|
|
2393
2529
|
this.#rememberCardRoute(repliedMessageId, chatId, options);
|
|
2394
2530
|
return repliedMessageId;
|
|
2395
2531
|
}
|
|
2396
2532
|
} catch (error) {
|
|
2397
2533
|
this.#logger.warn?.('[dsh-feishu] threaded card reply failed; sending a plain card:', error?.message ?? String(error));
|
|
2534
|
+
// The topic never opened; a managed root candidate must not linger.
|
|
2535
|
+
this.#forgetTopicRoot(replyTo);
|
|
2398
2536
|
}
|
|
2399
2537
|
}
|
|
2400
2538
|
const response = await this.#client.im.v1.message.create({
|
|
@@ -3206,6 +3344,10 @@ export class FeishuHarnessBridge {
|
|
|
3206
3344
|
|| (validLastSeq(entry.lastSeq) && entry.lastSeq >= event.seq)
|
|
3207
3345
|
|| (validEventSeq(failedSeq) && event.seq > failedSeq)) continue;
|
|
3208
3346
|
try {
|
|
3347
|
+
// Re-derive topic intent at delivery time: the push may outlive the
|
|
3348
|
+
// bounded in-memory anchor cache, but the conversation key still tells
|
|
3349
|
+
// us whether the completion belongs to a Feishu topic.
|
|
3350
|
+
this.#rememberTopicReply(entry.replyToMessageId ?? null, key);
|
|
3209
3351
|
await this.#sendCard(
|
|
3210
3352
|
entry.chatId,
|
|
3211
3353
|
completionCard(sessionId, entry.title, reason),
|
|
@@ -3346,12 +3488,18 @@ export class FeishuHarnessBridge {
|
|
|
3346
3488
|
? (file) => this.#channel.sendImage(chatId, file, {
|
|
3347
3489
|
replyTo,
|
|
3348
3490
|
signal: this.#signal,
|
|
3491
|
+
...(this.#replyInThreadFor(replyTo)
|
|
3492
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
|
|
3493
|
+
: {}),
|
|
3349
3494
|
})
|
|
3350
3495
|
: undefined,
|
|
3351
3496
|
sendFile: typeof this.#channel?.sendFile === 'function'
|
|
3352
3497
|
? (file) => this.#channel.sendFile(chatId, file, {
|
|
3353
3498
|
replyTo,
|
|
3354
3499
|
signal: this.#signal,
|
|
3500
|
+
...(this.#replyInThreadFor(replyTo)
|
|
3501
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
|
|
3502
|
+
: {}),
|
|
3355
3503
|
})
|
|
3356
3504
|
: undefined,
|
|
3357
3505
|
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
|
@@ -3499,7 +3647,12 @@ export class FeishuHarnessBridge {
|
|
|
3499
3647
|
completedArtifacts = completed.artifacts ?? [];
|
|
3500
3648
|
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
|
|
3501
3649
|
},
|
|
3502
|
-
}, {
|
|
3650
|
+
}, {
|
|
3651
|
+
replyTo: messageId,
|
|
3652
|
+
...(this.#replyInThreadFor(messageId)
|
|
3653
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, messageId, chatId) }
|
|
3654
|
+
: {}),
|
|
3655
|
+
});
|
|
3503
3656
|
} catch (error) {
|
|
3504
3657
|
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
|
3505
3658
|
if (completedAnswer || completedArtifacts.length > 0) {
|
|
@@ -4007,11 +4160,16 @@ export class FeishuHarnessBridge {
|
|
|
4007
4160
|
try {
|
|
4008
4161
|
const response = await this.#client.im.v1.message.reply({
|
|
4009
4162
|
path: { message_id: replyTo },
|
|
4010
|
-
data: {
|
|
4163
|
+
data: {
|
|
4164
|
+
msg_type: 'text',
|
|
4165
|
+
content,
|
|
4166
|
+
...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
|
|
4167
|
+
},
|
|
4011
4168
|
});
|
|
4012
4169
|
if (response?.code && response.code !== 0) {
|
|
4013
4170
|
throw new Error(`Feishu reply failed: ${response.msg || response.code}`);
|
|
4014
4171
|
}
|
|
4172
|
+
await this.#registerTopicFromReply(replyTo, chatId, response);
|
|
4015
4173
|
return nonEmptyString(response?.data?.message_id);
|
|
4016
4174
|
} catch (error) {
|
|
4017
4175
|
// The referenced message may be gone (recalled/deleted); keep the
|
|
@@ -4020,6 +4178,8 @@ export class FeishuHarnessBridge {
|
|
|
4020
4178
|
'[dsh-feishu] threaded reply failed; falling back to a plain message:',
|
|
4021
4179
|
error?.message ?? String(error),
|
|
4022
4180
|
);
|
|
4181
|
+
// The topic never opened; a managed root candidate must not linger.
|
|
4182
|
+
this.#forgetTopicRoot(replyTo);
|
|
4023
4183
|
}
|
|
4024
4184
|
}
|
|
4025
4185
|
const response = await this.#client.im.v1.message.create({
|