@xmanrui/dsh-im 4.34.1 → 4.35.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/README.en.md +5 -1
- package/README.md +5 -1
- package/lib/client.js +306 -201
- package/lib/index.js +291 -290
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +1 -1
- package/plugin-src/client/bot-settings-tabs.js +114 -0
- package/plugin-src/client/delivery-settings.js +2 -16
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/client/styles.js +8 -3
- package/plugin-src/host/channels/feishu/production.mjs +2 -1
- package/plugin-src/host/channels/qq/production.mjs +2 -1
- package/plugin-src/host/channels/shared/access-policy-production.mjs +13 -0
- package/plugin-src/host/delivery-adapter.mjs +2 -2
- package/plugin-src/host/delivery-service.mjs +13 -4
- package/plugin-src/host/index.mjs +1 -0
- package/plugin-src/host/modern-harness-api.mjs +2 -1
- package/src/channels/feishu/bridge.mjs +34 -18
- package/src/channels/feishu/feishu-cards.mjs +58 -6
- package/src/channels/feishu/feishu-channel.mjs +11 -3
- package/src/channels/feishu/feishu-runtime.mjs +19 -4
- package/src/channels/feishu/multi-bot-controller.mjs +1 -1
- package/src/channels/qq/qq-bridge.mjs +8 -8
- package/src/channels/qq/qq-mention.mjs +37 -0
- package/src/channels/qq/qq-runtime.mjs +11 -3
- package/src/channels/shared/bot-workspace-store.mjs +1 -0
- package/src/channels/shared/connection-error.mjs +10 -4
- package/src/channels/shared/harness-client.mjs +9 -3
- package/src/channels/shared/i18n-en/shared-a.mjs +4 -0
- package/src/channels/shared/message-failure.mjs +26 -4
- package/src/channels/shared/text-harness-bridge.mjs +2 -2
- package/src/channels/shared/workspace-command.mjs +28 -16
package/locale/en.json
CHANGED
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.35.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": [
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { h } from './i18n.js';
|
|
4
|
+
|
|
5
|
+
// Scroll only the tab strip: scrollIntoView can also move the host settings page.
|
|
6
|
+
function revealTab(strip, tab) {
|
|
7
|
+
if (!strip || !tab) return;
|
|
8
|
+
const viewport = strip.getBoundingClientRect();
|
|
9
|
+
const bounds = tab.getBoundingClientRect();
|
|
10
|
+
if (bounds.left < viewport.left) strip.scrollLeft += bounds.left - viewport.left;
|
|
11
|
+
else if (bounds.right > viewport.right) strip.scrollLeft += bounds.right - viewport.right;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function BotSettingsTabs({ tabs, activeTabId, onChange }) {
|
|
15
|
+
const barRef = React.useRef(null);
|
|
16
|
+
const stripRef = React.useRef(null);
|
|
17
|
+
const listRef = React.useRef(null);
|
|
18
|
+
const [scroll, setScroll] = React.useState({ overflow: false, left: false, right: false });
|
|
19
|
+
|
|
20
|
+
const measure = React.useCallback(() => {
|
|
21
|
+
const bar = barRef.current;
|
|
22
|
+
const strip = stripRef.current;
|
|
23
|
+
const list = listRef.current;
|
|
24
|
+
if (!bar || !strip || !list) return;
|
|
25
|
+
const next = {
|
|
26
|
+
// Compare against the whole bar, so arrows disappear as soon as all tabs fit.
|
|
27
|
+
overflow: list.scrollWidth > bar.clientWidth + 1,
|
|
28
|
+
left: strip.scrollLeft > 1,
|
|
29
|
+
right: strip.scrollWidth - strip.clientWidth - strip.scrollLeft > 1,
|
|
30
|
+
};
|
|
31
|
+
setScroll((previous) => Object.keys(next).every((key) => next[key] === previous[key])
|
|
32
|
+
? previous : next);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
const strip = stripRef.current;
|
|
37
|
+
if (!strip) return undefined;
|
|
38
|
+
const refresh = () => {
|
|
39
|
+
revealTab(strip, strip.querySelector('[aria-selected="true"]'));
|
|
40
|
+
measure();
|
|
41
|
+
};
|
|
42
|
+
refresh();
|
|
43
|
+
const view = strip.ownerDocument.defaultView;
|
|
44
|
+
const observer = view.ResizeObserver ? new view.ResizeObserver(refresh) : null;
|
|
45
|
+
for (const node of [barRef.current, strip, listRef.current]) observer?.observe(node);
|
|
46
|
+
view.addEventListener('resize', refresh);
|
|
47
|
+
return () => {
|
|
48
|
+
observer?.disconnect();
|
|
49
|
+
view.removeEventListener('resize', refresh);
|
|
50
|
+
};
|
|
51
|
+
}, [tabs, activeTabId, scroll.overflow, measure]);
|
|
52
|
+
|
|
53
|
+
const scrollTabs = (direction) => {
|
|
54
|
+
const strip = stripRef.current;
|
|
55
|
+
if (!strip) return;
|
|
56
|
+
strip.scrollLeft += direction * Math.max(80, strip.clientWidth * 0.75);
|
|
57
|
+
measure();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleKeyDown = (event, index) => {
|
|
61
|
+
let next;
|
|
62
|
+
if (event.key === 'Home') next = 0;
|
|
63
|
+
else if (event.key === 'End') next = tabs.length - 1;
|
|
64
|
+
else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
65
|
+
next = (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
|
66
|
+
}
|
|
67
|
+
if (next === undefined) return;
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
onChange(tabs[next].id);
|
|
70
|
+
const target = stripRef.current?.querySelectorAll('[role="tab"]')[next];
|
|
71
|
+
target?.focus({ preventScroll: true });
|
|
72
|
+
revealTab(stripRef.current, target);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const arrow = (direction, label, enabled) => scroll.overflow ? h('button', {
|
|
76
|
+
type: 'button',
|
|
77
|
+
className: 'dim-botSettingsScroll',
|
|
78
|
+
'aria-label': label,
|
|
79
|
+
title: label,
|
|
80
|
+
disabled: !enabled,
|
|
81
|
+
onClick: () => scrollTabs(direction),
|
|
82
|
+
}, h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true' },
|
|
83
|
+
h('path', {
|
|
84
|
+
d: direction < 0 ? 'M10 3 5 8l5 5' : 'm6 3 5 5-5 5',
|
|
85
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 1.5,
|
|
86
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
87
|
+
}))) : null;
|
|
88
|
+
|
|
89
|
+
return h('div', { ref: barRef, className: 'dim-botSettingsTabsBar' },
|
|
90
|
+
arrow(-1, '向左滚动设置页签', scroll.left),
|
|
91
|
+
h('nav', {
|
|
92
|
+
ref: stripRef,
|
|
93
|
+
className: 'dim-botSettingsTabs',
|
|
94
|
+
role: 'tablist',
|
|
95
|
+
'aria-label': '机器人设置页签',
|
|
96
|
+
onScroll: measure,
|
|
97
|
+
}, h('div', { ref: listRef, className: 'dim-botSettingsTabsList', role: 'presentation' },
|
|
98
|
+
tabs.map((tab, index) => h('button', {
|
|
99
|
+
key: tab.id,
|
|
100
|
+
id: `dim-bot-settings-${tab.id}-tab`,
|
|
101
|
+
type: 'button',
|
|
102
|
+
role: 'tab',
|
|
103
|
+
className: 'dim-botSettingsTab',
|
|
104
|
+
'aria-selected': tab.id === activeTabId,
|
|
105
|
+
'aria-controls': `dim-bot-settings-${tab.id}-panel`,
|
|
106
|
+
tabIndex: tab.id === activeTabId ? 0 : -1,
|
|
107
|
+
onClick: (event) => {
|
|
108
|
+
onChange(tab.id);
|
|
109
|
+
revealTab(stripRef.current, event?.currentTarget);
|
|
110
|
+
},
|
|
111
|
+
onKeyDown: (event) => handleKeyDown(event, index),
|
|
112
|
+
}, tab.label)))),
|
|
113
|
+
arrow(1, '向右滚动设置页签', scroll.right));
|
|
114
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
|
|
3
3
|
import { AccessPolicySettingsPage } from './access-policy-settings.js';
|
|
4
|
+
import { BotSettingsTabs } from './bot-settings-tabs.js';
|
|
4
5
|
import { FeishuGroupSettingsPage } from './channels/feishu/group-settings.js';
|
|
5
6
|
import { FeishuVoiceSettingsPage } from './channels/feishu/voice-settings.js';
|
|
6
7
|
import { FeishuSlashPanelSettingsPage } from './channels/feishu/slash-panel-settings.js';
|
|
@@ -770,22 +771,7 @@ export function DeliveryTargetSettingsPage({
|
|
|
770
771
|
},
|
|
771
772
|
h('header', { className: 'dim-deliveryHeader' },
|
|
772
773
|
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表')),
|
|
773
|
-
h(
|
|
774
|
-
h('nav', {
|
|
775
|
-
className: 'dim-botSettingsTabs',
|
|
776
|
-
role: 'tablist',
|
|
777
|
-
'aria-label': '机器人设置页签',
|
|
778
|
-
}, settingsTabs.map((tab) => h('button', {
|
|
779
|
-
key: tab.id,
|
|
780
|
-
id: `dim-bot-settings-${tab.id}-tab`,
|
|
781
|
-
type: 'button',
|
|
782
|
-
role: 'tab',
|
|
783
|
-
className: 'dim-botSettingsTab',
|
|
784
|
-
'aria-selected': tab.id === activeTab.id,
|
|
785
|
-
'aria-controls': `dim-bot-settings-${tab.id}-panel`,
|
|
786
|
-
tabIndex: tab.id === activeTab.id ? 0 : -1,
|
|
787
|
-
onClick: () => setActiveTabId(tab.id),
|
|
788
|
-
}, tab.label)))),
|
|
774
|
+
h(BotSettingsTabs, { tabs: settingsTabs, activeTabId: activeTab.id, onChange: setActiveTabId }),
|
|
789
775
|
h('div', {
|
|
790
776
|
id: activePanelId,
|
|
791
777
|
className: 'dim-botSettingsTabPanel',
|
|
@@ -48,6 +48,8 @@ const EN = Object.freeze({
|
|
|
48
48
|
'IM 面板加载失败': 'Could not load the IM panel',
|
|
49
49
|
'更多机器人设置': 'More bot settings',
|
|
50
50
|
'更多设置': 'More settings',
|
|
51
|
+
'向左滚动设置页签': 'Scroll settings tabs left',
|
|
52
|
+
'向右滚动设置页签': 'Scroll settings tabs right',
|
|
51
53
|
'常用配置': 'Common settings',
|
|
52
54
|
'机器人设置': 'Bot settings',
|
|
53
55
|
'机器人设置页签': 'Bot settings tabs',
|
|
@@ -467,9 +467,14 @@ const CSS = String.raw`
|
|
|
467
467
|
.dim-moreSettingsChevron { width: 6px; height: 6px; margin-right: 2px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); }
|
|
468
468
|
.dim-deliveryPage { min-width: 0; display: grid; }
|
|
469
469
|
.dim-deliveryHeader { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
470
|
-
.dim-botSettingsTabsBar { min-width: 0; margin-top: 10px; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
|
|
471
|
-
.dim-botSettingsTabs { min-width: 0;
|
|
470
|
+
.dim-botSettingsTabsBar { min-width: 0; display: flex; align-items: center; gap: 4px; margin-top: 10px; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
|
|
471
|
+
.dim-botSettingsTabs { min-width: 0; flex: 1; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
|
|
472
472
|
.dim-botSettingsTabs::-webkit-scrollbar { display: none; }
|
|
473
|
+
.dim-botSettingsTabsList { width: max-content; min-width: 100%; display: flex; align-items: flex-end; gap: 24px; }
|
|
474
|
+
.dim-botSettingsScroll { flex: 0 0 30px; width: 30px; height: 32px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #646a73); cursor: pointer; }
|
|
475
|
+
.dim-botSettingsScroll:hover:not(:disabled) { color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
|
476
|
+
.dim-botSettingsScroll:disabled { opacity: .3; cursor: default; }
|
|
477
|
+
.dim-botSettingsScroll:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: -2px; }
|
|
473
478
|
.dim-botSettingsTab { position: relative; min-height: 38px; flex: none; display: inline-flex; align-items: center; justify-content: center; padding: 6px 2px 9px; border: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 13px; line-height: 20px; font-weight: 560; white-space: nowrap; cursor: pointer; transition: color .15s ease; }
|
|
474
479
|
.dim-botSettingsTab::after { content: ''; position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; border-radius: 2px 2px 0 0; background: transparent; transform: scaleX(.45); transition: background .15s ease, transform .15s ease; }
|
|
475
480
|
.dim-botSettingsTab:hover:not([aria-selected="true"]) { color: var(--dsw-alias-label-primary, #1f2329); }
|
|
@@ -735,7 +740,7 @@ const CSS = String.raw`
|
|
|
735
740
|
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
|
|
736
741
|
.dim-loopbackRecoveryAction { width: 100%; }
|
|
737
742
|
.dim-deliverySectionHeading { align-items: stretch; flex-direction: column; }
|
|
738
|
-
.dim-
|
|
743
|
+
.dim-botSettingsTabsList { gap: 18px; }
|
|
739
744
|
.dim-deliveryBotId { grid-template-columns: minmax(0, 1fr) max-content; }
|
|
740
745
|
.dim-deliveryBotId > span { grid-column: 1 / -1; }
|
|
741
746
|
.dim-targetActions .dim-deliveryButton { flex: 1 1 auto; }
|
|
@@ -35,6 +35,7 @@ import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
|
35
35
|
import {
|
|
36
36
|
accessPolicyProvider,
|
|
37
37
|
initialAccessPolicyFor,
|
|
38
|
+
newBotAccessPolicyFor,
|
|
38
39
|
} from '../shared/access-policy-production.mjs';
|
|
39
40
|
|
|
40
41
|
// The WebSocket agent built here is only used for the Feishu long connection,
|
|
@@ -230,7 +231,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
230
231
|
const id = botId ?? botConfig.id ?? botConfig.appId;
|
|
231
232
|
await workspaces.ensure(id, {
|
|
232
233
|
defaultAgentPreset: config.agentPreset,
|
|
233
|
-
initialAccessPolicy:
|
|
234
|
+
initialAccessPolicy: newBotAccessPolicyFor('feishu', botConfig),
|
|
234
235
|
});
|
|
235
236
|
const workspaceScope = createBotWorkspaceScope(harness, {
|
|
236
237
|
botId: id, workspaces, state, agentPresetCatalog,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import {
|
|
31
31
|
accessPolicyProvider,
|
|
32
32
|
initialAccessPolicyFor,
|
|
33
|
+
newBotAccessPolicyFor,
|
|
33
34
|
} from '../shared/access-policy-production.mjs';
|
|
34
35
|
|
|
35
36
|
function pluginPaths(config) {
|
|
@@ -117,7 +118,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
117
118
|
const state = await stateFor(botId);
|
|
118
119
|
await workspaces.ensure(botId, {
|
|
119
120
|
defaultAgentPreset: config.agentPreset,
|
|
120
|
-
initialAccessPolicy:
|
|
121
|
+
initialAccessPolicy: newBotAccessPolicyFor('qq', botConfig),
|
|
121
122
|
});
|
|
122
123
|
const workspaceScope = createBotWorkspaceScope(harness, {
|
|
123
124
|
botId, workspaces, state, agentPresetCatalog,
|
|
@@ -97,6 +97,19 @@ export function initialAccessPolicyFor(channel, config = {}) {
|
|
|
97
97
|
throw new TypeError(`Unsupported access-policy channel: ${channel}`);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Seed newly connected bots after startup has migrated existing bots with
|
|
102
|
+
* initialAccessPolicyFor(). Open Feishu/QQ messages and commands in both chat
|
|
103
|
+
* types; workspace ensure() preserves any policy already saved.
|
|
104
|
+
*/
|
|
105
|
+
export function newBotAccessPolicyFor(channel, config = {}) {
|
|
106
|
+
const key = String(channel ?? '').trim().toLowerCase();
|
|
107
|
+
if (key === 'feishu' || key === 'qq') {
|
|
108
|
+
return createAccessPolicy({ direct: openScope(), group: openScope() });
|
|
109
|
+
}
|
|
110
|
+
return initialAccessPolicyFor(channel, config);
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
export function privilegedSenderIdsFor(channel, config = {}) {
|
|
101
114
|
const key = String(channel ?? '').trim().toLowerCase();
|
|
102
115
|
if (key === 'weixin') return cleanIds(config.ownerUserId);
|
|
@@ -286,8 +286,8 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
|
|
|
286
286
|
if (typeof coreController.sendProactiveText !== 'function') {
|
|
287
287
|
throw new TypeError('delivery controller cannot send proactive text');
|
|
288
288
|
}
|
|
289
|
-
await coreController.sendProactiveText(botId, normalized, text, options);
|
|
290
|
-
return { sent: true };
|
|
289
|
+
const result = await coreController.sendProactiveText(botId, normalized, text, options);
|
|
290
|
+
return options.receipt === true ? result : { sent: true };
|
|
291
291
|
},
|
|
292
292
|
async setSessionSync(botId, targetId, enabled) {
|
|
293
293
|
if (typeof workspaces.setDeliveryTargetSessionSync !== 'function') {
|
|
@@ -30,6 +30,7 @@ const DELIVERY_ERROR_CODES = new Set([
|
|
|
30
30
|
'bot-not-connected',
|
|
31
31
|
'target-rejected',
|
|
32
32
|
'delivery-failed',
|
|
33
|
+
'send-result-unknown',
|
|
33
34
|
'session-sync-unavailable',
|
|
34
35
|
'cancelled',
|
|
35
36
|
]);
|
|
@@ -314,10 +315,10 @@ export class DeliveryService {
|
|
|
314
315
|
} catch (error) { throw publicOperationError(error); }
|
|
315
316
|
}
|
|
316
317
|
|
|
317
|
-
async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain' } = {}) {
|
|
318
|
+
async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain', receipt = false } = {}) {
|
|
318
319
|
const id = botIdOf(botId);
|
|
319
320
|
const key = targetIdOf(targetId);
|
|
320
|
-
if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format)
|
|
321
|
+
if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format) || typeof receipt !== 'boolean'
|
|
321
322
|
|| !/^[a-f0-9]{64}$/.test(expectedFingerprint ?? '') || !/^[a-f0-9]{64}$/.test(expectedTargetDigest ?? '')) {
|
|
322
323
|
throw deliveryError('bad-request');
|
|
323
324
|
}
|
|
@@ -341,14 +342,22 @@ export class DeliveryService {
|
|
|
341
342
|
throw deliveryError('capability-unavailable');
|
|
342
343
|
}
|
|
343
344
|
if (account.account?.fingerprint !== expectedFingerprint) throw deliveryError('account-changed');
|
|
345
|
+
if (receipt && (!account.capabilities?.includes('proactive-receipt-checked') || target.kind !== 'group'))
|
|
346
|
+
throw deliveryError('capability-unavailable');
|
|
344
347
|
const beforeSend = () => {
|
|
345
348
|
cancellation(signal);
|
|
346
349
|
this.#assertRegistered(registration);
|
|
347
350
|
};
|
|
348
351
|
beforeSend();
|
|
349
|
-
await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
|
|
352
|
+
const result = await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
|
|
353
|
+
...(receipt ? { receipt: true } : {}),
|
|
350
354
|
...(format === 'markdown' ? { format } : {}) });
|
|
351
|
-
return { sent: true };
|
|
355
|
+
if (!receipt) return { sent: true };
|
|
356
|
+
if (result?.sent !== true || result.receipt?.version !== 1
|
|
357
|
+
|| typeof result.receipt.messageId !== 'string' || !result.receipt.messageId || result.receipt.messageId.length > 512
|
|
358
|
+
|| result.receipt.conversationId !== target.route.chatId)
|
|
359
|
+
throw deliveryError('send-result-unknown');
|
|
360
|
+
return { sent: true, receipt: { version: 1, messageId: result.receipt.messageId, conversationId: result.receipt.conversationId } };
|
|
352
361
|
} catch (error) { throw publicOperationError(error); }
|
|
353
362
|
}
|
|
354
363
|
|
|
@@ -92,6 +92,7 @@ export function createImHostPlugin(internals = {}) {
|
|
|
92
92
|
if (typeof ctx?.provide === 'function') {
|
|
93
93
|
ctx.provide('dshIm', Object.freeze({
|
|
94
94
|
contractVersion: 1,
|
|
95
|
+
receiptVersion: 1,
|
|
95
96
|
describeBot: (botId) => deliveryService.describeBot(botId),
|
|
96
97
|
sendChecked: (botId, targetId, text, options) => deliveryService.sendChecked(botId, targetId, text, options),
|
|
97
98
|
send: (botId, targetId, text, options) => (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harness-client.mjs';
|
|
4
|
+
import { extractConnectionEvidence } from '../../src/channels/shared/connection-error.mjs';
|
|
4
5
|
|
|
5
6
|
const modernApis = new WeakMap();
|
|
6
7
|
|
|
@@ -25,7 +26,7 @@ function failureOf(error) {
|
|
|
25
26
|
return {
|
|
26
27
|
code: 'internal',
|
|
27
28
|
message: error instanceof Error ? error.message : String(error),
|
|
28
|
-
details:
|
|
29
|
+
details: extractConnectionEvidence(error).details,
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import QRCode from 'qrcode';
|
|
4
|
+
import { waitForFeishuOperation } from './feishu-channel.mjs';
|
|
4
5
|
import {
|
|
5
6
|
conversationKey,
|
|
6
7
|
conversationScope,
|
|
@@ -77,6 +78,7 @@ import {
|
|
|
77
78
|
import {
|
|
78
79
|
channelDeliveryFailure,
|
|
79
80
|
clearLastMessageFailure,
|
|
81
|
+
messageFailureDiagnostic,
|
|
80
82
|
messageFailureText,
|
|
81
83
|
setLastMessageFailure,
|
|
82
84
|
} from '../shared/message-failure.mjs';
|
|
@@ -720,6 +722,7 @@ export class FeishuHarnessBridge {
|
|
|
720
722
|
/** Conversation keys with an IM ask in flight (set BEFORE the turn starts). */
|
|
721
723
|
#imTurnKeys = new Set();
|
|
722
724
|
#cardDataTimeoutMs;
|
|
725
|
+
#requestTimeoutMs;
|
|
723
726
|
/** When true, approval/question interactions render as Feishu cards (buttons). */
|
|
724
727
|
#interactionCards = true;
|
|
725
728
|
|
|
@@ -744,6 +747,7 @@ export class FeishuHarnessBridge {
|
|
|
744
747
|
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
|
745
748
|
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
|
746
749
|
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
|
|
750
|
+
requestTimeoutMs = 15_000,
|
|
747
751
|
replyTimeoutMs = 600_000,
|
|
748
752
|
interactionCards = true,
|
|
749
753
|
sessionSyncTargetsFor = null,
|
|
@@ -766,7 +770,8 @@ export class FeishuHarnessBridge {
|
|
|
766
770
|
}
|
|
767
771
|
if (!Number.isFinite(repairPollIntervalMs) || repairPollIntervalMs <= 0
|
|
768
772
|
|| !Number.isFinite(repairLinkWaitMs) || repairLinkWaitMs <= 0
|
|
769
|
-
|| !Number.isFinite(cardDataTimeoutMs) || cardDataTimeoutMs <= 0
|
|
773
|
+
|| !Number.isFinite(cardDataTimeoutMs) || cardDataTimeoutMs <= 0
|
|
774
|
+
|| !Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
|
770
775
|
throw new TypeError('Feishu timing values must be positive numbers');
|
|
771
776
|
}
|
|
772
777
|
if (stepPushClock !== null
|
|
@@ -803,6 +808,7 @@ export class FeishuHarnessBridge {
|
|
|
803
808
|
this.#repairPollIntervalMs = repairPollIntervalMs;
|
|
804
809
|
this.#repairLinkWaitMs = repairLinkWaitMs;
|
|
805
810
|
this.#cardDataTimeoutMs = cardDataTimeoutMs;
|
|
811
|
+
this.#requestTimeoutMs = requestTimeoutMs;
|
|
806
812
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
807
813
|
this.#interactionCards = interactionCards === true;
|
|
808
814
|
this.#sessionSyncTargetsFor = typeof sessionSyncTargetsFor === 'function'
|
|
@@ -1493,7 +1499,7 @@ export class FeishuHarnessBridge {
|
|
|
1493
1499
|
const failure = setLastMessageFailure(this.#status, error, { userMessage, reason });
|
|
1494
1500
|
this.#logger?.[logLevel]?.(
|
|
1495
1501
|
`[dsh-feishu] ${logLabel} failed [${failure.referenceId}]:`,
|
|
1496
|
-
error
|
|
1502
|
+
messageFailureDiagnostic(error, failure),
|
|
1497
1503
|
);
|
|
1498
1504
|
return failure;
|
|
1499
1505
|
}
|
|
@@ -3035,14 +3041,14 @@ export class FeishuHarnessBridge {
|
|
|
3035
3041
|
|
|
3036
3042
|
async #showWorkspaces({ chatId, key, replyTo = null }, { updateMessageId = null } = {}) {
|
|
3037
3043
|
try {
|
|
3038
|
-
const { current, paths } = await workspacePathSnapshot(
|
|
3044
|
+
const { current, paths, titles } = await workspacePathSnapshot(
|
|
3039
3045
|
this.#harness,
|
|
3040
|
-
{ signal: this.#cardDataSignal() },
|
|
3046
|
+
{ signal: this.#cardDataSignal(), includeTitles: true },
|
|
3041
3047
|
);
|
|
3042
3048
|
this.#rememberMenu(key, { kind: 'workspaces', paths });
|
|
3043
3049
|
await this.#sendCard(
|
|
3044
3050
|
chatId,
|
|
3045
|
-
workspaceListCard(paths, current),
|
|
3051
|
+
workspaceListCard(paths, current, titles),
|
|
3046
3052
|
{ key, updateMessageId, replyTo },
|
|
3047
3053
|
);
|
|
3048
3054
|
} catch (error) {
|
|
@@ -3127,6 +3133,14 @@ export class FeishuHarnessBridge {
|
|
|
3127
3133
|
}
|
|
3128
3134
|
}
|
|
3129
3135
|
|
|
3136
|
+
#cardRequest(stage, operation) {
|
|
3137
|
+
return waitForFeishuOperation(operation, {
|
|
3138
|
+
signal: this.#signal,
|
|
3139
|
+
timeoutMs: this.#requestTimeoutMs,
|
|
3140
|
+
stage: `${stage} (${this.#requestTimeoutMs}ms)`,
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3130
3144
|
async #sendCard(chatId, cardJson, options = {}) {
|
|
3131
3145
|
const updateMessageId = nonEmptyString(options.updateMessageId);
|
|
3132
3146
|
const replyTo = nonEmptyString(options.replyTo);
|
|
@@ -3134,14 +3148,14 @@ export class FeishuHarnessBridge {
|
|
|
3134
3148
|
// Session-sync cards target the user's openId (the synced DM is a user,
|
|
3135
3149
|
// not a chat), delivered fresh without topic/thread handling.
|
|
3136
3150
|
if (options.receiveIdType === 'open_id') {
|
|
3137
|
-
const response = await this.#client.im.v1.message.create({
|
|
3151
|
+
const response = await this.#cardRequest('card create', () => this.#client.im.v1.message.create({
|
|
3138
3152
|
params: { receive_id_type: 'open_id' },
|
|
3139
3153
|
data: {
|
|
3140
3154
|
receive_id: chatId,
|
|
3141
3155
|
msg_type: 'interactive',
|
|
3142
3156
|
content: cardJson,
|
|
3143
3157
|
},
|
|
3144
|
-
});
|
|
3158
|
+
}));
|
|
3145
3159
|
if (response?.code && response.code !== 0) {
|
|
3146
3160
|
throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
|
|
3147
3161
|
}
|
|
@@ -3152,10 +3166,10 @@ export class FeishuHarnessBridge {
|
|
|
3152
3166
|
|
|
3153
3167
|
if (updateMessageId) {
|
|
3154
3168
|
try {
|
|
3155
|
-
const response = await this.#client.im.v1.message.patch({
|
|
3169
|
+
const response = await this.#cardRequest('card patch', () => this.#client.im.v1.message.patch({
|
|
3156
3170
|
path: { message_id: updateMessageId },
|
|
3157
3171
|
data: { content: cardJson },
|
|
3158
|
-
});
|
|
3172
|
+
}));
|
|
3159
3173
|
if (response?.code && response.code !== 0) {
|
|
3160
3174
|
throw new Error(`Feishu card update failed: ${response.msg || response.code}`);
|
|
3161
3175
|
}
|
|
@@ -3172,14 +3186,14 @@ export class FeishuHarnessBridge {
|
|
|
3172
3186
|
const content = cardJson;
|
|
3173
3187
|
if (replyTo) {
|
|
3174
3188
|
try {
|
|
3175
|
-
const response = await this.#client.im.v1.message.reply({
|
|
3189
|
+
const response = await this.#cardRequest('card reply', () => this.#client.im.v1.message.reply({
|
|
3176
3190
|
path: { message_id: replyTo },
|
|
3177
3191
|
data: {
|
|
3178
3192
|
msg_type: 'interactive',
|
|
3179
3193
|
content,
|
|
3180
3194
|
...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
|
|
3181
3195
|
},
|
|
3182
|
-
});
|
|
3196
|
+
}));
|
|
3183
3197
|
if (response?.code && response.code !== 0) {
|
|
3184
3198
|
throw new Error(`Feishu card reply failed: ${response.msg || response.code}`);
|
|
3185
3199
|
}
|
|
@@ -3195,10 +3209,10 @@ export class FeishuHarnessBridge {
|
|
|
3195
3209
|
this.#forgetTopicRoot(replyTo);
|
|
3196
3210
|
}
|
|
3197
3211
|
}
|
|
3198
|
-
const response = await this.#client.im.v1.message.create({
|
|
3212
|
+
const response = await this.#cardRequest('card create', () => this.#client.im.v1.message.create({
|
|
3199
3213
|
params: { receive_id_type: 'chat_id' },
|
|
3200
3214
|
data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
|
|
3201
|
-
});
|
|
3215
|
+
}));
|
|
3202
3216
|
if (response?.code && response.code !== 0) {
|
|
3203
3217
|
throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
|
|
3204
3218
|
}
|
|
@@ -3225,7 +3239,7 @@ export class FeishuHarnessBridge {
|
|
|
3225
3239
|
const dataSignal = this.#cardDataSignal();
|
|
3226
3240
|
// Independent sections start together. Each one degrades on its own so a
|
|
3227
3241
|
// slow preset/model RPC cannot force redundant session-list scans.
|
|
3228
|
-
const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal })
|
|
3242
|
+
const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal, includeTitles: true })
|
|
3229
3243
|
.catch(() => {
|
|
3230
3244
|
const current = typeof this.#harness.currentWorkspace === 'function'
|
|
3231
3245
|
? this.#harness.currentWorkspace()
|
|
@@ -3302,7 +3316,7 @@ export class FeishuHarnessBridge {
|
|
|
3302
3316
|
const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
|
|
3303
3317
|
this.#rememberMenu(key, { kind: 'menu', chatId });
|
|
3304
3318
|
const menuContext = {
|
|
3305
|
-
workspaces, currentWorkspace,
|
|
3319
|
+
workspaces, currentWorkspace, workspaceTitles: snapshot.titles,
|
|
3306
3320
|
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
|
3307
3321
|
sessions, archiveVisible, presetCatalog, modelCatalog,
|
|
3308
3322
|
newSessionAction: { id: randomUUID(), status: 'idle' },
|
|
@@ -4834,7 +4848,8 @@ export class FeishuHarnessBridge {
|
|
|
4834
4848
|
);
|
|
4835
4849
|
if (card.deliveryViaOpenId) {
|
|
4836
4850
|
this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
|
|
4837
|
-
error?.message ?? String(error)
|
|
4851
|
+
error?.message ?? String(error),
|
|
4852
|
+
{ sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn });
|
|
4838
4853
|
}
|
|
4839
4854
|
}
|
|
4840
4855
|
}
|
|
@@ -4885,6 +4900,7 @@ export class FeishuHarnessBridge {
|
|
|
4885
4900
|
this.#logger.warn?.(
|
|
4886
4901
|
'[dsh-feishu] step streaming card finish failed:',
|
|
4887
4902
|
error?.message ?? String(error),
|
|
4903
|
+
{ sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn },
|
|
4888
4904
|
);
|
|
4889
4905
|
return null;
|
|
4890
4906
|
}
|
|
@@ -4935,10 +4951,10 @@ export class FeishuHarnessBridge {
|
|
|
4935
4951
|
}
|
|
4936
4952
|
|
|
4937
4953
|
async #patchStepCard(messageId, cardJson) {
|
|
4938
|
-
const response = await this.#client.im.v1.message.patch({
|
|
4954
|
+
const response = await this.#cardRequest('step card patch', () => this.#client.im.v1.message.patch({
|
|
4939
4955
|
path: { message_id: messageId },
|
|
4940
4956
|
data: { content: cardJson },
|
|
4941
|
-
});
|
|
4957
|
+
}));
|
|
4942
4958
|
if (response?.code && response.code !== 0) {
|
|
4943
4959
|
throw new Error(`Feishu step card patch failed: ${response.msg || response.code}`);
|
|
4944
4960
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* without button callbacks.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { posix, win32 } from 'node:path';
|
|
17
18
|
import { t } from '../shared/i18n.mjs';
|
|
18
19
|
|
|
19
20
|
export const MENU_PAGE_SIZE = 10;
|
|
@@ -87,9 +88,56 @@ function initialIndex(options, currentValue) {
|
|
|
87
88
|
return idx >= 0 ? idx + 1 : 0;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function cleanTitle(value) {
|
|
92
|
+
return String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
90
95
|
function safeTitle(value) {
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
return cleanTitle(value) || t('暂无标题');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const WORKSPACE_LABEL_LENGTH = 22;
|
|
100
|
+
|
|
101
|
+
/** Display labels only: paths and their snapshot order remain the callback identity. */
|
|
102
|
+
function workspaceLabels(paths, titles = {}) {
|
|
103
|
+
const entries = paths.map((path) => {
|
|
104
|
+
const api = /^[a-z]:[\\/]|^\\\\/iu.test(path) ? win32 : posix;
|
|
105
|
+
const normalized = api.normalize(path);
|
|
106
|
+
const name = cleanTitle(api.basename(normalized) || api.parse(normalized).root);
|
|
107
|
+
const base = (typeof titles[path] === 'string' && cleanTitle(titles[path])) || name;
|
|
108
|
+
return {
|
|
109
|
+
api, base, label: base, parent: api.dirname(normalized),
|
|
110
|
+
context: base === name ? '' : api.basename(normalized),
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
const counts = () => {
|
|
114
|
+
const result = new Map();
|
|
115
|
+
for (const { label } of entries) result.set(label, (result.get(label) ?? 0) + 1);
|
|
116
|
+
return result;
|
|
117
|
+
};
|
|
118
|
+
// Grow only ambiguous path suffixes; stop at the root even if display text collides.
|
|
119
|
+
for (;;) {
|
|
120
|
+
const repeated = counts();
|
|
121
|
+
let changed = false;
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (repeated.get(entry.label) < 2 || entry.parent === null) continue;
|
|
124
|
+
const { api, parent } = entry;
|
|
125
|
+
entry.context = api.join(api.basename(parent) || parent, entry.context);
|
|
126
|
+
entry.label = `${entry.base} · ${cleanTitle(entry.context)}`;
|
|
127
|
+
const next = api.dirname(parent);
|
|
128
|
+
entry.parent = next === parent ? null : next;
|
|
129
|
+
changed = true;
|
|
130
|
+
}
|
|
131
|
+
if (!changed) break;
|
|
132
|
+
}
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const chars = Array.from(entry.label);
|
|
135
|
+
if (chars.length > WORKSPACE_LABEL_LENGTH) {
|
|
136
|
+
entry.label = `${chars.slice(0, 11).join('')}…${chars.slice(-10).join('')}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const repeated = counts();
|
|
140
|
+
return entries.map(({ label }) => ({ label, numbered: repeated.get(label) > 1 }));
|
|
93
141
|
}
|
|
94
142
|
|
|
95
143
|
function cardWith(headerText, elements) {
|
|
@@ -117,6 +165,7 @@ function backButton() {
|
|
|
117
165
|
* 4. 系统 → 状态 · 帮助
|
|
118
166
|
* `ctx` bundle:
|
|
119
167
|
* - workspaces: string[] (工作区下拉选项)
|
|
168
|
+
* - workspaceTitles: object (可选,按绝对路径索引的工作区名称)
|
|
120
169
|
* - currentWorkspace: string|null (当前工作区,下拉高亮)
|
|
121
170
|
* - currentSession: {id,title}|null (当前绑定会话,续写目标)
|
|
122
171
|
* - sessions: {id,title}[] (最近会话,供会话下拉切换)
|
|
@@ -130,6 +179,7 @@ function backButton() {
|
|
|
130
179
|
export function menuCard(ctx) {
|
|
131
180
|
const {
|
|
132
181
|
workspaces = [],
|
|
182
|
+
workspaceTitles = {},
|
|
133
183
|
currentWorkspace = null,
|
|
134
184
|
currentSession = null,
|
|
135
185
|
sessions = [],
|
|
@@ -188,8 +238,9 @@ export function menuCard(ctx) {
|
|
|
188
238
|
|
|
189
239
|
let workspaceDropdown = null;
|
|
190
240
|
if (hasWorkspaces) {
|
|
191
|
-
const
|
|
192
|
-
|
|
241
|
+
const labels = workspaceLabels(workspaces, workspaceTitles);
|
|
242
|
+
const wsOptions = workspaces.slice(0, 20).map((path, index) => ({
|
|
243
|
+
text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${labels[index].numbered ? `${index + 1}. ` : ''}${labels[index].label}` },
|
|
193
244
|
value: path,
|
|
194
245
|
}));
|
|
195
246
|
workspaceDropdown = {
|
|
@@ -726,7 +777,8 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
|
|
|
726
777
|
// ── Workspace list card (preserved, with back button) ─────────────────────
|
|
727
778
|
|
|
728
779
|
/** The workspace list card (switch-workspace buttons + reply fallback). */
|
|
729
|
-
export function workspaceListCard(paths, current) {
|
|
780
|
+
export function workspaceListCard(paths, current, titles = {}) {
|
|
781
|
+
const labels = workspaceLabels(paths, titles);
|
|
730
782
|
const elements = paths.length === 0
|
|
731
783
|
? [
|
|
732
784
|
{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) },
|
|
@@ -735,7 +787,7 @@ export function workspaceListCard(paths, current) {
|
|
|
735
787
|
: [
|
|
736
788
|
{ tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
|
|
737
789
|
...paths.map((path, index) => button(
|
|
738
|
-
`${index + 1}. ${
|
|
790
|
+
`${index + 1}. ${labels[index].label}${path === current ? t('(当前)') : ''}`,
|
|
739
791
|
`workspace:${path}`,
|
|
740
792
|
)),
|
|
741
793
|
{ tag: 'hr' },
|