@xmanrui/dsh-im 4.32.0 → 4.34.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 +11 -0
- package/PROACTIVE_DELIVERY.md +11 -0
- package/README.en.md +11 -7
- package/README.md +11 -7
- package/lib/client.js +3 -71
- package/lib/index.js +298 -295
- package/package.json +1 -71
- package/plugin-src/host/delivery-adapter.mjs +8 -0
- package/plugin-src/host/delivery-service.mjs +75 -0
- package/plugin-src/host/host-language.mjs +21 -21
- package/plugin-src/host/index.mjs +3 -0
- package/plugin-src/host/modern-harness-api.mjs +22 -0
- package/plugin-src/host/session-sync-coordinator.mjs +10 -13
- package/src/channels/feishu/bridge.mjs +87 -7
- package/src/channels/feishu/feishu-cards.mjs +29 -1
- package/src/channels/feishu/message-utils.mjs +61 -64
- package/src/channels/feishu/multi-bot-controller.mjs +59 -5
- package/src/channels/feishu/resource-download.mjs +120 -0
- package/src/channels/feishu/slash-command-panel.mjs +2 -0
- package/src/channels/qq/qq-bridge.mjs +6 -1
- package/src/channels/shared/bot-workspace-store.mjs +5 -0
- package/src/channels/shared/command-list-snapshots.mjs +33 -0
- package/src/channels/shared/harness-client.mjs +11 -4
- package/src/channels/shared/i18n-en/feishu.mjs +7 -0
- package/src/channels/shared/i18n-en/permission.mjs +30 -0
- package/src/channels/shared/i18n-en.mjs +2 -0
- package/src/channels/shared/permission-command.mjs +149 -0
- package/src/channels/shared/preset-command.mjs +6 -51
- package/src/channels/shared/semantic/artifact.mjs +88 -18
- package/src/channels/telegram/telegram-runtime.mjs +12 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.34.0",
|
|
4
4
|
"icon": "./assets/logo-plugin-message-link-gradient.webp",
|
|
5
5
|
"description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
|
|
6
6
|
"keywords": [
|
|
@@ -26,76 +26,6 @@
|
|
|
26
26
|
"ai-office"
|
|
27
27
|
],
|
|
28
28
|
"author": "xmanrui (https://github.com/xmanrui)",
|
|
29
|
-
"contributors": [
|
|
30
|
-
{
|
|
31
|
-
"name": "C3H3-AI",
|
|
32
|
-
"url": "https://github.com/C3H3-AI"
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
"name": "divingleee",
|
|
36
|
-
"url": "https://github.com/divingleee"
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
"name": "Chan-0312",
|
|
40
|
-
"url": "https://github.com/Chan-0312"
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
"name": "BuvkB",
|
|
44
|
-
"url": "https://github.com/BuvkB"
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
"name": "masucc",
|
|
48
|
-
"url": "https://github.com/masucc"
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
"name": "cherryFloris",
|
|
52
|
-
"url": "https://github.com/cherryFloris"
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
"name": "geekyfoxlab",
|
|
56
|
-
"url": "https://github.com/geekyfoxlab"
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
"name": "grloper",
|
|
60
|
-
"url": "https://github.com/grloper"
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
"name": "baijian",
|
|
64
|
-
"url": "https://github.com/baijian"
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
"name": "lyzhu86",
|
|
68
|
-
"url": "https://github.com/lyzhu86"
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
"name": "Librazy",
|
|
72
|
-
"url": "https://github.com/Librazy"
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
"name": "Johnslee1207",
|
|
76
|
-
"url": "https://github.com/johnslee1207-commits"
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
"name": "Dong09",
|
|
80
|
-
"url": "https://github.com/Dong09"
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
"name": "wings1848",
|
|
84
|
-
"url": "https://github.com/wings1848"
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
"name": "Zhe (Phil) Yang",
|
|
88
|
-
"url": "https://github.com/yangzhe1991"
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
"name": "Biertolovisk",
|
|
92
|
-
"url": "https://github.com/rosalynthompson32-a11y"
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
"name": "Tianjun Cai",
|
|
96
|
-
"url": "https://github.com/Pegasus02"
|
|
97
|
-
}
|
|
98
|
-
],
|
|
99
29
|
"license": "MIT",
|
|
100
30
|
"type": "module",
|
|
101
31
|
"repository": {
|
|
@@ -233,6 +233,14 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
|
|
|
233
233
|
}
|
|
234
234
|
return Object.freeze({
|
|
235
235
|
channel,
|
|
236
|
+
describeAccount: (botId) => {
|
|
237
|
+
if (typeof coreController.describeDeliveryAccount !== 'function') {
|
|
238
|
+
const error = new Error('Verified account capability unavailable');
|
|
239
|
+
error.code = 'capability-unavailable';
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
return coreController.describeDeliveryAccount(botId);
|
|
243
|
+
},
|
|
236
244
|
ownsBot: (botId) => workspaces.has(botId),
|
|
237
245
|
listBots: () => workspaces.listBotIds(),
|
|
238
246
|
async listTargets(botId) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
|
|
2
3
|
|
|
3
4
|
const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
@@ -18,6 +19,10 @@ const ADAPTER_METHODS = Object.freeze([
|
|
|
18
19
|
|
|
19
20
|
const DELIVERY_ERROR_CODES = new Set([
|
|
20
21
|
'bad-request',
|
|
22
|
+
'account-unverified',
|
|
23
|
+
'account-changed',
|
|
24
|
+
'target-changed',
|
|
25
|
+
'capability-unavailable',
|
|
21
26
|
'unknown-bot',
|
|
22
27
|
'unknown-target',
|
|
23
28
|
'target-conflict',
|
|
@@ -290,6 +295,63 @@ export class DeliveryService {
|
|
|
290
295
|
}
|
|
291
296
|
}
|
|
292
297
|
|
|
298
|
+
#assertRegistered(registration) {
|
|
299
|
+
if (this.#adapters.get(registration.adapter.channel) !== registration) {
|
|
300
|
+
throw deliveryError('capability-unavailable');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async describeBot(botId) {
|
|
305
|
+
const id = botIdOf(botId);
|
|
306
|
+
const registration = await this.#checkedRegistrationFor(id);
|
|
307
|
+
const { adapter } = registration;
|
|
308
|
+
if (typeof adapter.describeAccount !== 'function') throw deliveryError('capability-unavailable');
|
|
309
|
+
this.#assertRegistered(registration);
|
|
310
|
+
try {
|
|
311
|
+
const account = await adapter.describeAccount(id);
|
|
312
|
+
this.#assertRegistered(registration);
|
|
313
|
+
return account;
|
|
314
|
+
} catch (error) { throw publicOperationError(error); }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain' } = {}) {
|
|
318
|
+
const id = botIdOf(botId);
|
|
319
|
+
const key = targetIdOf(targetId);
|
|
320
|
+
if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format)
|
|
321
|
+
|| !/^[a-f0-9]{64}$/.test(expectedFingerprint ?? '') || !/^[a-f0-9]{64}$/.test(expectedTargetDigest ?? '')) {
|
|
322
|
+
throw deliveryError('bad-request');
|
|
323
|
+
}
|
|
324
|
+
cancellation(signal);
|
|
325
|
+
const registration = await this.#checkedRegistrationFor(id);
|
|
326
|
+
const { adapter } = registration;
|
|
327
|
+
if (typeof adapter.describeAccount !== 'function') throw deliveryError('capability-unavailable');
|
|
328
|
+
this.#assertRegistered(registration);
|
|
329
|
+
try {
|
|
330
|
+
const candidates = await adapter.listTargets(id);
|
|
331
|
+
const saved = candidates.find((target) => target?.targetId === key);
|
|
332
|
+
if (!saved) throw deliveryError('unknown-target');
|
|
333
|
+
const target = normalizeDeliveryTarget(adapter.channel, {
|
|
334
|
+
targetId: saved.targetId, kind: saved.kind, route: structuredClone(saved.route),
|
|
335
|
+
});
|
|
336
|
+
const digest = createHash('sha256').update(JSON.stringify({ kind: target.kind,
|
|
337
|
+
route: Object.fromEntries(Object.entries(target.route).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) })).digest('hex');
|
|
338
|
+
if (digest !== expectedTargetDigest) throw deliveryError('target-changed');
|
|
339
|
+
const account = await adapter.describeAccount(id);
|
|
340
|
+
if (account?.version !== 1 || !account.capabilities?.includes('proactive-text-checked')) {
|
|
341
|
+
throw deliveryError('capability-unavailable');
|
|
342
|
+
}
|
|
343
|
+
if (account.account?.fingerprint !== expectedFingerprint) throw deliveryError('account-changed');
|
|
344
|
+
const beforeSend = () => {
|
|
345
|
+
cancellation(signal);
|
|
346
|
+
this.#assertRegistered(registration);
|
|
347
|
+
};
|
|
348
|
+
beforeSend();
|
|
349
|
+
await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
|
|
350
|
+
...(format === 'markdown' ? { format } : {}) });
|
|
351
|
+
return { sent: true };
|
|
352
|
+
} catch (error) { throw publicOperationError(error); }
|
|
353
|
+
}
|
|
354
|
+
|
|
293
355
|
async send(botId, targetIdOrDraft, text, { signal, format = 'plain' } = {}) {
|
|
294
356
|
const id = botIdOf(botId);
|
|
295
357
|
const targetKey = typeof targetIdOrDraft === 'string'
|
|
@@ -328,6 +390,19 @@ export class DeliveryService {
|
|
|
328
390
|
}
|
|
329
391
|
}
|
|
330
392
|
|
|
393
|
+
async #checkedRegistrationFor(botId) {
|
|
394
|
+
for (const registration of this.#adapters.values()) {
|
|
395
|
+
let ownsBot;
|
|
396
|
+
try { ownsBot = await registration.adapter.ownsBot(botId); }
|
|
397
|
+
catch (error) { throw publicOperationError(error); }
|
|
398
|
+
if (ownsBot) {
|
|
399
|
+
this.#assertRegistered(registration);
|
|
400
|
+
return registration;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
throw deliveryError('unknown-bot', 'Unknown bot');
|
|
404
|
+
}
|
|
405
|
+
|
|
331
406
|
async #adapterFor(botId) {
|
|
332
407
|
for (const { adapter } of this.#adapters.values()) {
|
|
333
408
|
let ownsBot;
|
|
@@ -125,33 +125,33 @@ export function installHostLanguage(ctx, config = {}, internals = {}) {
|
|
|
125
125
|
config: config.language ?? process.env.DSH_IM_LANGUAGE,
|
|
126
126
|
logger,
|
|
127
127
|
});
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
)
|
|
136
|
-
|
|
128
|
+
// Only the injected context may access settings. Waiting for its current
|
|
129
|
+
// lifecycle work does not wait for an absent provider to arrive.
|
|
130
|
+
let settingsBinding;
|
|
131
|
+
const readPreference = (settings) => typeof settings?.describe === 'function'
|
|
132
|
+
? settings.describe().find((row) => row.ns === DSH_LOCALE_NAMESPACE)
|
|
133
|
+
?.value?.[DSH_LOCALE_PREFERENCE_FIELD]
|
|
134
|
+
: settings?.get?.(DSH_LOCALE_NAMESPACE)?.[DSH_LOCALE_PREFERENCE_FIELD];
|
|
135
|
+
if (typeof ctx?.inject === 'function') {
|
|
136
|
+
settingsBinding = ctx.inject(['settings'], (settingsCtx) => {
|
|
137
|
+
controller.observeSettings(() => readPreference(settingsCtx.settings));
|
|
138
|
+
for (const event of ['settings/updated', 'settings/document-updated']) {
|
|
139
|
+
settingsCtx.on(event, (namespace) => {
|
|
140
|
+
if (namespace === DSH_LOCALE_NAMESPACE) controller.apply();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// Do not retain a reader bound to a withdrawn service/context.
|
|
144
|
+
return () => { controller.observeSettings(); };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const ready = Promise.resolve(settingsBinding)
|
|
137
148
|
.then(() => store.load?.())
|
|
138
149
|
.then(() => controller.apply(), (error) => {
|
|
139
150
|
logger?.error?.(
|
|
140
|
-
'[dsh-im] could not
|
|
151
|
+
'[dsh-im] could not initialize the DSH interface language; falling back to the available language',
|
|
141
152
|
error,
|
|
142
153
|
);
|
|
143
154
|
return controller.snapshot();
|
|
144
155
|
});
|
|
145
|
-
if (typeof ctx?.inject === 'function') {
|
|
146
|
-
ctx.inject(['settings'], (settingsCtx) => {
|
|
147
|
-
controller.observeSettings(
|
|
148
|
-
() => settingsCtx.settings?.get?.(DSH_LOCALE_NAMESPACE)?.[DSH_LOCALE_PREFERENCE_FIELD],
|
|
149
|
-
);
|
|
150
|
-
settingsCtx.on('settings/updated', (namespace) => {
|
|
151
|
-
if (namespace !== DSH_LOCALE_NAMESPACE) return;
|
|
152
|
-
controller.apply();
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
156
|
return Object.freeze({ ...controller, ready });
|
|
157
157
|
}
|
|
@@ -91,6 +91,9 @@ export function createImHostPlugin(internals = {}) {
|
|
|
91
91
|
const deliveryService = makeDeliveryService({ unavailableSessionSyncChannels });
|
|
92
92
|
if (typeof ctx?.provide === 'function') {
|
|
93
93
|
ctx.provide('dshIm', Object.freeze({
|
|
94
|
+
contractVersion: 1,
|
|
95
|
+
describeBot: (botId) => deliveryService.describeBot(botId),
|
|
96
|
+
sendChecked: (botId, targetId, text, options) => deliveryService.sendChecked(botId, targetId, text, options),
|
|
94
97
|
send: (botId, targetId, text, options) => (
|
|
95
98
|
deliveryService.send(botId, targetId, text, options)
|
|
96
99
|
),
|
|
@@ -240,6 +240,9 @@ class ModernHarnessApi {
|
|
|
240
240
|
models: (request, signal) => rpcResult(request, () => this.#sessionModels(
|
|
241
241
|
request.payload.sessionId, signal,
|
|
242
242
|
)),
|
|
243
|
+
permissions: (request, signal) => rpcResult(request, () => this.#sessionPermissions(
|
|
244
|
+
request.payload.sessionId, signal,
|
|
245
|
+
)),
|
|
243
246
|
selectModel: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
244
247
|
'session', 'selectModel', { request: request.payload }, signal,
|
|
245
248
|
)),
|
|
@@ -414,6 +417,25 @@ class ModernHarnessApi {
|
|
|
414
417
|
return this.#invoke('session', 'modelCatalog', {}, signal);
|
|
415
418
|
}
|
|
416
419
|
|
|
420
|
+
async #sessionPermissions(sessionId, signal) {
|
|
421
|
+
const [catalog, projection] = await Promise.all([
|
|
422
|
+
this.#invoke('permissionPresets', 'catalog', {}, signal),
|
|
423
|
+
this.#invoke('session', 'projections', { request: { sessionId } }, signal),
|
|
424
|
+
]);
|
|
425
|
+
if (projection === null) {
|
|
426
|
+
const error = new Error('Session not found');
|
|
427
|
+
error.failure = { code: 'session-not-found', message: error.message, details: {} };
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
const currentValue = projection?.values?.permissions?.currentValue;
|
|
431
|
+
if (typeof currentValue !== 'string' || !Array.isArray(catalog?.options)) {
|
|
432
|
+
const error = new Error('Host permission presets are unavailable');
|
|
433
|
+
error.failure = { code: 'permissions-unavailable', message: error.message, details: {} };
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
return { currentValue, options: catalog.options };
|
|
437
|
+
}
|
|
438
|
+
|
|
417
439
|
async #sessionModels(sessionId, signal) {
|
|
418
440
|
const [catalog, list] = await Promise.all([
|
|
419
441
|
this.#modelCatalog(signal),
|
|
@@ -23,6 +23,7 @@ function sessionIdOf(session) {
|
|
|
23
23
|
|
|
24
24
|
function userInputOrigin(scope, event) {
|
|
25
25
|
const source = event?.data?.source;
|
|
26
|
+
if (source?.kind === 'schedule') return 'dsh';
|
|
26
27
|
if (source?.kind !== 'user' || typeof source.rpcId !== 'string' || !source.rpcId) {
|
|
27
28
|
return 'other';
|
|
28
29
|
}
|
|
@@ -112,12 +113,9 @@ export function createSessionSyncCoordinator({ deliveryService, logger = console
|
|
|
112
113
|
if (event.surfaceOp !== 'append') return;
|
|
113
114
|
if (state.origin === 'unknown') state.origin = origin;
|
|
114
115
|
if (state.origin !== 'dsh' || origin !== 'dsh') return;
|
|
115
|
-
|
|
116
|
-
// turn: the card also quotes the question, but keeping the echo here
|
|
117
|
-
// guarantees recipients are established so a failed mirror can still
|
|
118
|
-
// fall back to the final-answer text delivery.
|
|
116
|
+
const scheduled = event.data?.source?.kind === 'schedule';
|
|
119
117
|
const text = textFromHarnessContent(event.data?.content);
|
|
120
|
-
if (!text) return;
|
|
118
|
+
if (!text && !scheduled) return;
|
|
121
119
|
|
|
122
120
|
let targets;
|
|
123
121
|
try {
|
|
@@ -126,17 +124,16 @@ export function createSessionSyncCoordinator({ deliveryService, logger = console
|
|
|
126
124
|
logFailure('lookup', null, error);
|
|
127
125
|
targets = [];
|
|
128
126
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
'user delivery'
|
|
134
|
-
);
|
|
127
|
+
// Scheduled framing is internal: select its recipients without echoing it.
|
|
128
|
+
// Direct DSH input still requires a successful echo before the final answer.
|
|
129
|
+
const recipients = scheduled
|
|
130
|
+
? new Map(targets.filter(validRecipient).map((target) => [recipientKey(target), target]))
|
|
131
|
+
: await deliver(sessionId, targets, `${DSH_USER_PREFIX}${text}`, 'user delivery');
|
|
135
132
|
if (state.recipients === null) {
|
|
136
|
-
state.recipients =
|
|
133
|
+
state.recipients = recipients;
|
|
137
134
|
} else {
|
|
138
135
|
for (const key of [...state.recipients.keys()]) {
|
|
139
|
-
if (!
|
|
136
|
+
if (!recipients.has(key)) state.recipients.delete(key);
|
|
140
137
|
}
|
|
141
138
|
}
|
|
142
139
|
return;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
1
3
|
import QRCode from 'qrcode';
|
|
2
4
|
import {
|
|
3
5
|
conversationKey,
|
|
@@ -297,6 +299,7 @@ function printableText(value) {
|
|
|
297
299
|
}
|
|
298
300
|
|
|
299
301
|
function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
|
|
302
|
+
if (isPermissionCommand(text)) return true;
|
|
300
303
|
if (hasImages || hasFiles || typeof text !== 'string') return false;
|
|
301
304
|
const command = text.trim();
|
|
302
305
|
return MENU_COMMAND.test(command)
|
|
@@ -1233,6 +1236,7 @@ export class FeishuHarnessBridge {
|
|
|
1233
1236
|
return processing;
|
|
1234
1237
|
}
|
|
1235
1238
|
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
|
|
1239
|
+
: isPermissionCommand(commandText) ? runPermissionCommand
|
|
1236
1240
|
: hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
|
1237
1241
|
? runControlCommand
|
|
1238
1242
|
: (isModelCommand(commandText)
|
|
@@ -1689,6 +1693,15 @@ export class FeishuHarnessBridge {
|
|
|
1689
1693
|
await this.#handleRepairCommand(event, commandText);
|
|
1690
1694
|
return;
|
|
1691
1695
|
}
|
|
1696
|
+
if (isPermissionCommand(text)) {
|
|
1697
|
+
const result = await runPermissionCommand(text, this.#harness, this.#state, key, {
|
|
1698
|
+
signal: this.#signal, hasImages, hasFiles,
|
|
1699
|
+
});
|
|
1700
|
+
for (const reply of result.messages) {
|
|
1701
|
+
await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
|
|
1702
|
+
}
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1692
1705
|
if (commandText === '/help') {
|
|
1693
1706
|
await this.#send(event.message.chat_id, menuHelpText(), { replyTo: event.message.message_id });
|
|
1694
1707
|
return;
|
|
@@ -2307,7 +2320,9 @@ export class FeishuHarnessBridge {
|
|
|
2307
2320
|
// A used card is recent even if it was first created long ago.
|
|
2308
2321
|
this.#cardKeys.delete(messageId);
|
|
2309
2322
|
this.#cardKeys.set(messageId, route);
|
|
2310
|
-
const
|
|
2323
|
+
const newSessionActionId = nonEmptyString(actionValue.newSessionActionId);
|
|
2324
|
+
if (resolvedAction === 'new' && (!route.menuContext || !newSessionActionId)) return Promise.resolve();
|
|
2325
|
+
const entry = { ...route, messageId, selections: multiValues, operatorOpenId, newSessionActionId };
|
|
2311
2326
|
const source = nonEmptyString(actionValue.source);
|
|
2312
2327
|
const formText = nonEmptyString(formValue.steer_text);
|
|
2313
2328
|
const eventId = nonEmptyString(event?.event_id)
|
|
@@ -2316,6 +2331,7 @@ export class FeishuHarnessBridge {
|
|
|
2316
2331
|
?? nonEmptyString(event?.header?.uuid);
|
|
2317
2332
|
const identity = JSON.stringify({
|
|
2318
2333
|
messageId,
|
|
2334
|
+
newSessionActionId,
|
|
2319
2335
|
resolvedAction,
|
|
2320
2336
|
option,
|
|
2321
2337
|
multiValues,
|
|
@@ -2517,6 +2533,8 @@ export class FeishuHarnessBridge {
|
|
|
2517
2533
|
sessionLimit = null,
|
|
2518
2534
|
selections = [],
|
|
2519
2535
|
actor = null,
|
|
2536
|
+
menuContext = null,
|
|
2537
|
+
newSessionActionId = null,
|
|
2520
2538
|
}) {
|
|
2521
2539
|
// Confirmations triggered by a card interaction stay anchored to the
|
|
2522
2540
|
// card's message so they land inside the same Feishu topic.
|
|
@@ -2666,6 +2684,10 @@ export class FeishuHarnessBridge {
|
|
|
2666
2684
|
return;
|
|
2667
2685
|
}
|
|
2668
2686
|
if (action === 'new') {
|
|
2687
|
+
if (menuContext || newSessionActionId) {
|
|
2688
|
+
await this.#handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId });
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2669
2691
|
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
|
|
2670
2692
|
await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
|
|
2671
2693
|
return;
|
|
@@ -2799,6 +2821,61 @@ export class FeishuHarnessBridge {
|
|
|
2799
2821
|
}
|
|
2800
2822
|
}
|
|
2801
2823
|
|
|
2824
|
+
async #handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId }) {
|
|
2825
|
+
// The generation belongs to this rendered menu, not the whole conversation.
|
|
2826
|
+
// Check inside the queue as well as consuming the action before any await:
|
|
2827
|
+
// distinct provider events and late callbacks must not clear a later session.
|
|
2828
|
+
const action = menuContext?.newSessionAction;
|
|
2829
|
+
if (!action || this.#cardKeys.get(messageId)?.menuContext !== menuContext
|
|
2830
|
+
|| action.id !== newSessionActionId
|
|
2831
|
+
|| !['idle', 'failed'].includes(action.status)) return;
|
|
2832
|
+
|
|
2833
|
+
action.status = 'processing';
|
|
2834
|
+
delete action.message;
|
|
2835
|
+
await this.#patchCardMessage(chatId, messageId, menuCard(menuContext));
|
|
2836
|
+
|
|
2837
|
+
const finish = async (status, message = null) => {
|
|
2838
|
+
if (menuContext.currentSession && !this.#state.sessionFor(key)) {
|
|
2839
|
+
menuContext.currentSession = null;
|
|
2840
|
+
// The old menu may show a session-specific model. After unbinding,
|
|
2841
|
+
// refresh the bot default just as opening a fresh menu would.
|
|
2842
|
+
try {
|
|
2843
|
+
menuContext.modelCatalog = await this.#harness.listModels({ signal: this.#cardDataSignal() });
|
|
2844
|
+
} catch {
|
|
2845
|
+
menuContext.modelCatalog = null;
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
menuContext.newSessionAction = {
|
|
2849
|
+
id: status === 'failed' ? randomUUID() : action.id,
|
|
2850
|
+
status,
|
|
2851
|
+
message,
|
|
2852
|
+
};
|
|
2853
|
+
const patched = await this.#patchCardMessage(chatId, messageId, menuCard(menuContext));
|
|
2854
|
+
if (!patched) {
|
|
2855
|
+
await this.#send(chatId, t('新会话操作的卡片状态未能更新,请发送 /m 重新打开菜单。'), { replyTo: messageId })
|
|
2856
|
+
.catch((error) => this.#logger.warn?.('[dsh-feishu] menu state notice failed:', error?.message));
|
|
2857
|
+
}
|
|
2858
|
+
};
|
|
2859
|
+
// A task can start while Feishu is updating the processing card.
|
|
2860
|
+
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
|
|
2861
|
+
const message = t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。');
|
|
2862
|
+
await finish('failed', message);
|
|
2863
|
+
await this.#send(chatId, message, { replyTo: messageId });
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
try {
|
|
2867
|
+
await this.#state.clearSession(key);
|
|
2868
|
+
} catch (error) {
|
|
2869
|
+
// A persistence error can still leave the in-memory binding cleared.
|
|
2870
|
+
await finish('failed', safeErrorText(error));
|
|
2871
|
+
throw error;
|
|
2872
|
+
}
|
|
2873
|
+
await finish('success');
|
|
2874
|
+
// A receipt delivery failure must not turn a completed reset into a retry.
|
|
2875
|
+
await this.#send(chatId, t('已开启全新 Harness 会话。'), { replyTo: messageId })
|
|
2876
|
+
.catch((error) => this.#logger.warn?.('[dsh-feishu] new session receipt failed:', error?.message));
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2802
2879
|
#rememberMenu(key, menu) {
|
|
2803
2880
|
if (this.#menus.size >= MAX_TRACKED_MENUS) {
|
|
2804
2881
|
const oldest = this.#menus.keys().next().value;
|
|
@@ -3002,6 +3079,7 @@ export class FeishuHarnessBridge {
|
|
|
3002
3079
|
key: options.key,
|
|
3003
3080
|
chatId,
|
|
3004
3081
|
conversationWorkspace: options.conversationWorkspace,
|
|
3082
|
+
menuContext: options.menuContext ?? null,
|
|
3005
3083
|
sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
|
|
3006
3084
|
? options.sessionWorkspace
|
|
3007
3085
|
: null,
|
|
@@ -3213,14 +3291,16 @@ export class FeishuHarnessBridge {
|
|
|
3213
3291
|
}
|
|
3214
3292
|
const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
|
|
3215
3293
|
this.#rememberMenu(key, { kind: 'menu', chatId });
|
|
3294
|
+
const menuContext = {
|
|
3295
|
+
workspaces, currentWorkspace,
|
|
3296
|
+
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
|
3297
|
+
sessions, archiveVisible, presetCatalog, modelCatalog,
|
|
3298
|
+
newSessionAction: { id: randomUUID(), status: 'idle' },
|
|
3299
|
+
};
|
|
3216
3300
|
await this.#sendCard(
|
|
3217
3301
|
chatId,
|
|
3218
|
-
menuCard(
|
|
3219
|
-
|
|
3220
|
-
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
|
3221
|
-
sessions, archiveVisible, presetCatalog, modelCatalog,
|
|
3222
|
-
}),
|
|
3223
|
-
{ key, updateMessageId, replyTo, conversationWorkspace },
|
|
3302
|
+
menuCard(menuContext),
|
|
3303
|
+
{ key, updateMessageId, replyTo, conversationWorkspace, menuContext },
|
|
3224
3304
|
);
|
|
3225
3305
|
}
|
|
3226
3306
|
|
|
@@ -123,6 +123,7 @@ function backButton() {
|
|
|
123
123
|
* - archiveVisible: boolean (归档显隐开关当前值)
|
|
124
124
|
* - presetCatalog: object|null (预设目录, {items,defaultId,_currentId})
|
|
125
125
|
* - modelCatalog: object|null (模型目录, {groups,current})
|
|
126
|
+
* - newSessionAction: {id,status,message?}|null (新会话按钮的执行状态)
|
|
126
127
|
* Number fallback: 1=续写 2=新会话 3=会话列表 4=状态
|
|
127
128
|
* 5=补全权限 6=帮助.
|
|
128
129
|
*/
|
|
@@ -135,6 +136,7 @@ export function menuCard(ctx) {
|
|
|
135
136
|
archiveVisible = false,
|
|
136
137
|
presetCatalog = null,
|
|
137
138
|
modelCatalog = null,
|
|
139
|
+
newSessionAction = null,
|
|
138
140
|
} = ctx || {};
|
|
139
141
|
|
|
140
142
|
// currentSession 提供 id/title;为兼容旧调用,也接受 currentSessionTitle
|
|
@@ -146,6 +148,19 @@ export function menuCard(ctx) {
|
|
|
146
148
|
const hasWorkspaces = Array.isArray(workspaces) && workspaces.length > 0;
|
|
147
149
|
const elements = [];
|
|
148
150
|
|
|
151
|
+
let newSessionLabel = t('🆕 新会话');
|
|
152
|
+
if (newSessionAction?.status === 'processing') {
|
|
153
|
+
newSessionLabel = t('正在开启…');
|
|
154
|
+
elements.push({ tag: 'div', text: plainText(t('正在开启新会话…')) });
|
|
155
|
+
} else if (newSessionAction?.status === 'success') {
|
|
156
|
+
newSessionLabel = t('已执行:新会话');
|
|
157
|
+
elements.push({ tag: 'div', text: plainText(t('已执行:新会话。发送消息即可开始。')) });
|
|
158
|
+
} else if (newSessionAction?.status === 'failed') {
|
|
159
|
+
elements.push({ tag: 'div', text: plainText(t('新会话操作失败:{message}', {
|
|
160
|
+
message: newSessionAction.message || t('卡片操作失败,请稍后重试。'),
|
|
161
|
+
})) });
|
|
162
|
+
}
|
|
163
|
+
|
|
149
164
|
// ── 设置区 ──────────────────────────────────────────────────
|
|
150
165
|
elements.push({ tag: 'div', text: markdown(t('**设置**')) });
|
|
151
166
|
|
|
@@ -270,7 +285,15 @@ export function menuCard(ctx) {
|
|
|
270
285
|
});
|
|
271
286
|
|
|
272
287
|
// 新会话 + 全部会话按钮
|
|
273
|
-
|
|
288
|
+
const sessionButtons = buttonPair(newSessionLabel, 'new', t('📋 会话/关注'), 'sessions');
|
|
289
|
+
const newSessionButton = sessionButtons.columns[0].elements[0];
|
|
290
|
+
if (newSessionAction?.id) {
|
|
291
|
+
newSessionButton.behaviors[0].value.newSessionActionId = newSessionAction.id;
|
|
292
|
+
}
|
|
293
|
+
if (newSessionAction?.status === 'processing' || newSessionAction?.status === 'success') {
|
|
294
|
+
newSessionButton.disabled = true;
|
|
295
|
+
}
|
|
296
|
+
elements.push(sessionButtons);
|
|
274
297
|
if (!hasSessions && !hasWorkspaces) {
|
|
275
298
|
elements.push(button(t('🗂 工作区列表'), 'workspaces'));
|
|
276
299
|
}
|
|
@@ -512,6 +535,9 @@ export function menuHelpText() {
|
|
|
512
535
|
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
|
513
536
|
'纯数字 ID:/preset id:<ID>',
|
|
514
537
|
'/preset --default 跟随 Host 默认',
|
|
538
|
+
'/permissionlist 按序号列出权限档位',
|
|
539
|
+
'/permission [序号或完整ID] 查看或切换当前会话权限',
|
|
540
|
+
'纯数字 ID:/permission id:<ID>',
|
|
515
541
|
'/models 列出模型',
|
|
516
542
|
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
|
|
517
543
|
'/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
|
|
@@ -596,6 +622,8 @@ export function helpCard(extraTextLines = []) {
|
|
|
596
622
|
{ tag: 'hr' },
|
|
597
623
|
{ tag: 'div', text: markdown([
|
|
598
624
|
t(HELP_TEXT_COMMANDS),
|
|
625
|
+
t('`/permissionlist` — 按序号列出权限档位'),
|
|
626
|
+
t('`/permission [序号/ID]` — 查看或切换当前会话权限'),
|
|
599
627
|
t('/ws、/wsl、/workspaces 工作区命令别名'),
|
|
600
628
|
t('`/version` — 查看插件版本'),
|
|
601
629
|
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|