@xmanrui/dsh-im 4.31.0 → 4.33.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 +8 -6
- package/README.md +8 -6
- package/docs/client-integration.md +4 -0
- package/lib/client.js +90 -86
- package/lib/index.js +289 -289
- package/locale/en.json +5 -0
- package/locale/zh.json +5 -0
- package/package.json +5 -73
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/client/index.js +3 -0
- package/plugin-src/client/plugin-page.js +51 -0
- package/plugin-src/client/styles.js +9 -0
- 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/session-sync-coordinator.mjs +10 -13
- package/src/channels/feishu/bridge.mjs +75 -7
- package/src/channels/feishu/feishu-cards.mjs +24 -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/shared/harness-client.mjs +5 -4
- package/src/channels/shared/i18n-en/feishu.mjs +7 -0
- package/src/channels/shared/semantic/artifact.mjs +88 -18
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.33.0",
|
|
4
4
|
"icon": "./assets/logo-plugin-message-link-gradient.webp",
|
|
5
|
-
"description": "
|
|
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": [
|
|
7
7
|
"deepseek-harness",
|
|
8
8
|
"dsh",
|
|
@@ -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": {
|
|
@@ -116,13 +46,15 @@
|
|
|
116
46
|
"exports": {
|
|
117
47
|
".": "./lib/index.js",
|
|
118
48
|
"./client": "./lib/client.js",
|
|
119
|
-
"./package.json": "./package.json"
|
|
49
|
+
"./package.json": "./package.json",
|
|
50
|
+
"./locale/*.json": "./locale/*.json"
|
|
120
51
|
},
|
|
121
52
|
"files": [
|
|
122
53
|
"assets",
|
|
123
54
|
"bin",
|
|
124
55
|
"cordis.patch.yml",
|
|
125
56
|
"lib",
|
|
57
|
+
"locale/*.json",
|
|
126
58
|
"plugin-src",
|
|
127
59
|
"scripts/verify-interface-language.mjs",
|
|
128
60
|
"scripts/verify-injected-context.mjs",
|
|
@@ -42,6 +42,8 @@ const EN = Object.freeze({
|
|
|
42
42
|
'无需安装第三方服务;请按指引授予 macOS Messages 访问权限。': 'No third-party service is required; follow the guide to grant macOS Messages access.',
|
|
43
43
|
'macOS Messages 权限': 'macOS Messages permissions',
|
|
44
44
|
'IM机器人': 'IM bots',
|
|
45
|
+
'打开 IM机器人': 'Open IM bots',
|
|
46
|
+
'返回插件详情': 'Back to plugin details',
|
|
45
47
|
'IM机器人设置': 'IM bot settings',
|
|
46
48
|
'IM 面板加载失败': 'Could not load the IM panel',
|
|
47
49
|
'更多机器人设置': 'More bot settings',
|
|
@@ -83,6 +83,7 @@ import { installSessionChannelLogos } from './session-channel-logos.js';
|
|
|
83
83
|
import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
|
|
84
84
|
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
|
|
85
85
|
import { IMPanelErrorBoundary } from './panel-error-boundary.js';
|
|
86
|
+
import { installImPluginPage } from './plugin-page.js';
|
|
86
87
|
|
|
87
88
|
export const name = 'im-settings';
|
|
88
89
|
export const inject = ['slots', 'connection', 'locale', 'workspaces'];
|
|
@@ -566,6 +567,8 @@ export function apply(ctx) {
|
|
|
566
567
|
preferredSectionId: props.preferredSectionId,
|
|
567
568
|
});
|
|
568
569
|
|
|
570
|
+
installImPluginPage(ctx, { packageName: manifest.name, renderPanel: buildPanelElement });
|
|
571
|
+
|
|
569
572
|
ctx.effect(() => {
|
|
570
573
|
let disposed = false;
|
|
571
574
|
let stopSettings = null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { IM_LOCALE_NAMESPACE } from './i18n.js';
|
|
3
|
+
|
|
4
|
+
export const IM_MAIN_PANEL_ID = 'xmanrui-dsh-im';
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
function OpenImAction({ subject, packageName, onOpen, t }) {
|
|
8
|
+
if (subject?.kind !== 'bundle' && subject?.kind !== 'row') return null;
|
|
9
|
+
if (subject.pkg?.name !== packageName) return null;
|
|
10
|
+
if (subject.kind === 'row' && subject.row?.moduleName !== packageName) return null;
|
|
11
|
+
return h('button', {
|
|
12
|
+
type: 'button', className: 'dim-pluginOpen', onClick: onOpen,
|
|
13
|
+
}, t('打开 IM机器人'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ImPluginPage({ renderPanel, onBack, t }) {
|
|
17
|
+
return h('div', { className: 'dim-pluginPage' },
|
|
18
|
+
h('div', { className: 'dim-pluginPageContent' },
|
|
19
|
+
h('button', { type: 'button', className: 'dim-pluginBack', onClick: onBack },
|
|
20
|
+
h('span', { 'aria-hidden': true }, '‹'), t('返回插件详情')),
|
|
21
|
+
renderPanel()));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Optional services and slots keep the existing settings entry working on
|
|
25
|
+
// older DSH versions. Reuse the same panel through public navigation APIs.
|
|
26
|
+
export function installImPluginPage(ctx, { packageName, renderPanel }) {
|
|
27
|
+
if (typeof ctx.inject !== 'function') return;
|
|
28
|
+
ctx.inject(['layout', 'pluginNavigation'], (pageCtx) =>
|
|
29
|
+
pageCtx.slots.inject('main', () => {
|
|
30
|
+
const unregisterPage = pageCtx.slots.register({
|
|
31
|
+
name: 'main', key: IM_MAIN_PANEL_ID, locale: IM_LOCALE_NAMESPACE,
|
|
32
|
+
inject: () => ({
|
|
33
|
+
renderPanel,
|
|
34
|
+
onBack: () => pageCtx.pluginNavigation.openBundle(packageName),
|
|
35
|
+
}),
|
|
36
|
+
}, ImPluginPage);
|
|
37
|
+
const stopActions = pageCtx.slots.inject('plugins.detail.actions', () =>
|
|
38
|
+
pageCtx.slots.register({
|
|
39
|
+
name: 'plugins.detail.actions', id: 'xmanrui-dsh-im-open',
|
|
40
|
+
order: 20, locale: IM_LOCALE_NAMESPACE,
|
|
41
|
+
inject: () => ({
|
|
42
|
+
packageName,
|
|
43
|
+
onOpen: () => pageCtx.layout.selectPanel(IM_MAIN_PANEL_ID),
|
|
44
|
+
}),
|
|
45
|
+
}, OpenImAction));
|
|
46
|
+
return () => {
|
|
47
|
+
stopActions();
|
|
48
|
+
unregisterPage();
|
|
49
|
+
};
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
export const IM_STYLE_ID = 'xmanrui-dsh-im-settings';
|
|
2
2
|
|
|
3
3
|
const CSS = String.raw`
|
|
4
|
+
.dim-pluginOpen { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 7px 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
|
|
5
|
+
.dim-pluginOpen:hover { background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
|
6
|
+
.dim-pluginOpen:focus-visible, .dim-pluginBack:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 3px; }
|
|
7
|
+
.dim-pluginPage { height: 100%; min-height: 0; overflow: auto; box-sizing: border-box; padding: 24px clamp(16px, 4vw, 48px) 32px; color: var(--dsw-alias-label-primary, #1f2329); }
|
|
8
|
+
.dim-pluginPageContent { max-width: 1080px; margin: 0 auto; }
|
|
9
|
+
.dim-pluginBack { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; margin: 0 0 24px; padding: 6px 8px; border: 0; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
|
|
10
|
+
.dim-pluginBack:hover { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
|
11
|
+
.dim-pluginBack > span { font-size: 22px; line-height: 20px; }
|
|
12
|
+
@media (pointer: coarse) { .dim-pluginOpen, .dim-pluginBack { min-height: 44px; } }
|
|
4
13
|
.dim-connectionDiagnostic { min-width: 0; width: 100%; color: var(--dsw-alias-label-secondary, #646a73); overflow-wrap: anywhere; font-size: 13px; line-height: 1.6; }
|
|
5
14
|
.dim-connectionDiagnostic[data-warning="true"] { color: var(--dsw-alias-state-warn-primary, #d97706); }
|
|
6
15
|
.dim-connectionDiagnostic p { margin: 4px 0; }
|
|
@@ -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
|
),
|
|
@@ -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,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import QRCode from 'qrcode';
|
|
2
3
|
import {
|
|
3
4
|
conversationKey,
|
|
@@ -2307,7 +2308,9 @@ export class FeishuHarnessBridge {
|
|
|
2307
2308
|
// A used card is recent even if it was first created long ago.
|
|
2308
2309
|
this.#cardKeys.delete(messageId);
|
|
2309
2310
|
this.#cardKeys.set(messageId, route);
|
|
2310
|
-
const
|
|
2311
|
+
const newSessionActionId = nonEmptyString(actionValue.newSessionActionId);
|
|
2312
|
+
if (resolvedAction === 'new' && (!route.menuContext || !newSessionActionId)) return Promise.resolve();
|
|
2313
|
+
const entry = { ...route, messageId, selections: multiValues, operatorOpenId, newSessionActionId };
|
|
2311
2314
|
const source = nonEmptyString(actionValue.source);
|
|
2312
2315
|
const formText = nonEmptyString(formValue.steer_text);
|
|
2313
2316
|
const eventId = nonEmptyString(event?.event_id)
|
|
@@ -2316,6 +2319,7 @@ export class FeishuHarnessBridge {
|
|
|
2316
2319
|
?? nonEmptyString(event?.header?.uuid);
|
|
2317
2320
|
const identity = JSON.stringify({
|
|
2318
2321
|
messageId,
|
|
2322
|
+
newSessionActionId,
|
|
2319
2323
|
resolvedAction,
|
|
2320
2324
|
option,
|
|
2321
2325
|
multiValues,
|
|
@@ -2517,6 +2521,8 @@ export class FeishuHarnessBridge {
|
|
|
2517
2521
|
sessionLimit = null,
|
|
2518
2522
|
selections = [],
|
|
2519
2523
|
actor = null,
|
|
2524
|
+
menuContext = null,
|
|
2525
|
+
newSessionActionId = null,
|
|
2520
2526
|
}) {
|
|
2521
2527
|
// Confirmations triggered by a card interaction stay anchored to the
|
|
2522
2528
|
// card's message so they land inside the same Feishu topic.
|
|
@@ -2666,6 +2672,10 @@ export class FeishuHarnessBridge {
|
|
|
2666
2672
|
return;
|
|
2667
2673
|
}
|
|
2668
2674
|
if (action === 'new') {
|
|
2675
|
+
if (menuContext || newSessionActionId) {
|
|
2676
|
+
await this.#handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId });
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2669
2679
|
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
|
|
2670
2680
|
await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
|
|
2671
2681
|
return;
|
|
@@ -2799,6 +2809,61 @@ export class FeishuHarnessBridge {
|
|
|
2799
2809
|
}
|
|
2800
2810
|
}
|
|
2801
2811
|
|
|
2812
|
+
async #handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId }) {
|
|
2813
|
+
// The generation belongs to this rendered menu, not the whole conversation.
|
|
2814
|
+
// Check inside the queue as well as consuming the action before any await:
|
|
2815
|
+
// distinct provider events and late callbacks must not clear a later session.
|
|
2816
|
+
const action = menuContext?.newSessionAction;
|
|
2817
|
+
if (!action || this.#cardKeys.get(messageId)?.menuContext !== menuContext
|
|
2818
|
+
|| action.id !== newSessionActionId
|
|
2819
|
+
|| !['idle', 'failed'].includes(action.status)) return;
|
|
2820
|
+
|
|
2821
|
+
action.status = 'processing';
|
|
2822
|
+
delete action.message;
|
|
2823
|
+
await this.#patchCardMessage(chatId, messageId, menuCard(menuContext));
|
|
2824
|
+
|
|
2825
|
+
const finish = async (status, message = null) => {
|
|
2826
|
+
if (menuContext.currentSession && !this.#state.sessionFor(key)) {
|
|
2827
|
+
menuContext.currentSession = null;
|
|
2828
|
+
// The old menu may show a session-specific model. After unbinding,
|
|
2829
|
+
// refresh the bot default just as opening a fresh menu would.
|
|
2830
|
+
try {
|
|
2831
|
+
menuContext.modelCatalog = await this.#harness.listModels({ signal: this.#cardDataSignal() });
|
|
2832
|
+
} catch {
|
|
2833
|
+
menuContext.modelCatalog = null;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
menuContext.newSessionAction = {
|
|
2837
|
+
id: status === 'failed' ? randomUUID() : action.id,
|
|
2838
|
+
status,
|
|
2839
|
+
message,
|
|
2840
|
+
};
|
|
2841
|
+
const patched = await this.#patchCardMessage(chatId, messageId, menuCard(menuContext));
|
|
2842
|
+
if (!patched) {
|
|
2843
|
+
await this.#send(chatId, t('新会话操作的卡片状态未能更新,请发送 /m 重新打开菜单。'), { replyTo: messageId })
|
|
2844
|
+
.catch((error) => this.#logger.warn?.('[dsh-feishu] menu state notice failed:', error?.message));
|
|
2845
|
+
}
|
|
2846
|
+
};
|
|
2847
|
+
// A task can start while Feishu is updating the processing card.
|
|
2848
|
+
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
|
|
2849
|
+
const message = t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。');
|
|
2850
|
+
await finish('failed', message);
|
|
2851
|
+
await this.#send(chatId, message, { replyTo: messageId });
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
try {
|
|
2855
|
+
await this.#state.clearSession(key);
|
|
2856
|
+
} catch (error) {
|
|
2857
|
+
// A persistence error can still leave the in-memory binding cleared.
|
|
2858
|
+
await finish('failed', safeErrorText(error));
|
|
2859
|
+
throw error;
|
|
2860
|
+
}
|
|
2861
|
+
await finish('success');
|
|
2862
|
+
// A receipt delivery failure must not turn a completed reset into a retry.
|
|
2863
|
+
await this.#send(chatId, t('已开启全新 Harness 会话。'), { replyTo: messageId })
|
|
2864
|
+
.catch((error) => this.#logger.warn?.('[dsh-feishu] new session receipt failed:', error?.message));
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2802
2867
|
#rememberMenu(key, menu) {
|
|
2803
2868
|
if (this.#menus.size >= MAX_TRACKED_MENUS) {
|
|
2804
2869
|
const oldest = this.#menus.keys().next().value;
|
|
@@ -3002,6 +3067,7 @@ export class FeishuHarnessBridge {
|
|
|
3002
3067
|
key: options.key,
|
|
3003
3068
|
chatId,
|
|
3004
3069
|
conversationWorkspace: options.conversationWorkspace,
|
|
3070
|
+
menuContext: options.menuContext ?? null,
|
|
3005
3071
|
sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
|
|
3006
3072
|
? options.sessionWorkspace
|
|
3007
3073
|
: null,
|
|
@@ -3213,14 +3279,16 @@ export class FeishuHarnessBridge {
|
|
|
3213
3279
|
}
|
|
3214
3280
|
const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
|
|
3215
3281
|
this.#rememberMenu(key, { kind: 'menu', chatId });
|
|
3282
|
+
const menuContext = {
|
|
3283
|
+
workspaces, currentWorkspace,
|
|
3284
|
+
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
|
3285
|
+
sessions, archiveVisible, presetCatalog, modelCatalog,
|
|
3286
|
+
newSessionAction: { id: randomUUID(), status: 'idle' },
|
|
3287
|
+
};
|
|
3216
3288
|
await this.#sendCard(
|
|
3217
3289
|
chatId,
|
|
3218
|
-
menuCard(
|
|
3219
|
-
|
|
3220
|
-
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
|
3221
|
-
sessions, archiveVisible, presetCatalog, modelCatalog,
|
|
3222
|
-
}),
|
|
3223
|
-
{ key, updateMessageId, replyTo, conversationWorkspace },
|
|
3290
|
+
menuCard(menuContext),
|
|
3291
|
+
{ key, updateMessageId, replyTo, conversationWorkspace, menuContext },
|
|
3224
3292
|
);
|
|
3225
3293
|
}
|
|
3226
3294
|
|
|
@@ -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
|
}
|