@xmanrui/dsh-im 4.21.1 → 4.22.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 +17 -2
- package/README.md +17 -2
- package/docs/client-integration.md +81 -0
- package/lib/client.js +152 -45
- package/lib/index.js +281 -281
- package/package.json +2 -1
- package/plugin-src/client/channels/feishu/index.js +22 -7
- package/plugin-src/client/credential-binding.js +2 -0
- package/plugin-src/client/i18n.js +7 -0
- package/plugin-src/client/index.js +92 -26
- package/plugin-src/client/panel-error-boundary.js +21 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
- package/plugin-src/host/channels/feishu/production.mjs +3 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +2 -1
- package/plugin-src/host/channels/qq/production.mjs +3 -1
- package/plugin-src/host/channels/shared/production.mjs +3 -1
- package/plugin-src/host/channels/slack/production.mjs +3 -1
- package/plugin-src/host/channels/wecom/production.mjs +3 -1
- package/plugin-src/host/channels/wecom-app/production.mjs +3 -1
- package/plugin-src/host/channels/weixin/production.mjs +3 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
- package/scripts/verify-lan-management.mjs +53 -8
- package/src/channels/shared/bot-workspace-store.mjs +8 -11
- package/src/channels/shared/default-workspace.mjs +28 -0
- package/src/channels/shared/harness-client.mjs +48 -2
- package/src/channels/shared/harness-session-binding.mjs +13 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.22.0",
|
|
4
4
|
"description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -114,6 +114,7 @@
|
|
|
114
114
|
"src",
|
|
115
115
|
"PROACTIVE_DELIVERY.md",
|
|
116
116
|
"PROACTIVE_DELIVERY.en.md",
|
|
117
|
+
"docs/client-integration.md",
|
|
117
118
|
"LICENSE",
|
|
118
119
|
"README.md",
|
|
119
120
|
"README.en.md",
|
|
@@ -826,6 +826,7 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|
|
826
826
|
const [pageBusy, setPageBusy] = React.useState(false);
|
|
827
827
|
const [provisionBusy, setProvisionBusy] = React.useState(false);
|
|
828
828
|
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
|
829
|
+
const [credentialDomain, setCredentialDomain] = React.useState("feishu");
|
|
829
830
|
const [credentialBusy, setCredentialBusy] = React.useState(false);
|
|
830
831
|
const [credentialError, setCredentialError] = React.useState(null);
|
|
831
832
|
const [busyByBot, setBusyByBot] = React.useState({});
|
|
@@ -1041,13 +1042,13 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|
|
1041
1042
|
try {
|
|
1042
1043
|
const snapshot = normalizeBotsSnapshot(await invoke(
|
|
1043
1044
|
FEISHU_ENDPOINTS.bindCredentials,
|
|
1044
|
-
{ appId: identity, appSecret: secret },
|
|
1045
|
+
{ appId: identity, appSecret: secret, domain: credentialDomain },
|
|
1045
1046
|
));
|
|
1046
1047
|
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
|
1047
1048
|
mergeSnapshot(snapshot);
|
|
1048
1049
|
}
|
|
1049
1050
|
setCredentialOpen(false);
|
|
1050
|
-
announce("飞书机器人凭据已绑定。");
|
|
1051
|
+
announce(credentialDomain === "lark" ? "Lark 机器人凭据已绑定。" : "飞书机器人凭据已绑定。");
|
|
1051
1052
|
} catch (error) {
|
|
1052
1053
|
setCredentialError(presentError(error));
|
|
1053
1054
|
} finally {
|
|
@@ -1055,7 +1056,7 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|
|
1055
1056
|
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
|
|
1056
1057
|
setCredentialBusy(false);
|
|
1057
1058
|
}
|
|
1058
|
-
}, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
|
|
1059
|
+
}, [announce, credentialDomain, invoke, loadStatus, mergeSnapshot, workspaceFence]);
|
|
1059
1060
|
|
|
1060
1061
|
const cancelProvisioning = React.useCallback(async () => {
|
|
1061
1062
|
const activeProvision = model.provisioning;
|
|
@@ -1449,16 +1450,30 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|
|
1449
1450
|
|
|
1450
1451
|
const credentialContent = credentialOpen
|
|
1451
1452
|
? h(CredentialBindingPanel, {
|
|
1452
|
-
|
|
1453
|
+
key: credentialDomain,
|
|
1454
|
+
channel: credentialDomain === "lark" ? "Lark" : "飞书",
|
|
1453
1455
|
identityLabel: "App ID",
|
|
1454
|
-
identityPlaceholder: "填写飞书开放平台 App ID",
|
|
1456
|
+
identityPlaceholder: credentialDomain === "lark" ? "填写 Lark 开放平台 App ID" : "填写飞书开放平台 App ID",
|
|
1455
1457
|
secretLabel: "App Secret",
|
|
1456
|
-
secretPlaceholder: "填写飞书开放平台 App Secret",
|
|
1458
|
+
secretPlaceholder: credentialDomain === "lark" ? "填写 Lark 开放平台 App Secret" : "填写飞书开放平台 App Secret",
|
|
1457
1459
|
busy: credentialBusy,
|
|
1458
1460
|
error: credentialError,
|
|
1459
1461
|
onSubmit: bindCredentials,
|
|
1460
1462
|
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
|
1461
|
-
}
|
|
1463
|
+
}, h("label", { className: "dim-credentialField" },
|
|
1464
|
+
h("span", null, "应用平台"),
|
|
1465
|
+
h("select", {
|
|
1466
|
+
className: "dim-feishuGroupSelect",
|
|
1467
|
+
"aria-label": "应用平台",
|
|
1468
|
+
value: credentialDomain,
|
|
1469
|
+
disabled: credentialBusy,
|
|
1470
|
+
onChange: (event) => {
|
|
1471
|
+
setCredentialDomain(event.target.value);
|
|
1472
|
+
setCredentialError(null);
|
|
1473
|
+
},
|
|
1474
|
+
},
|
|
1475
|
+
h("option", { value: "feishu" }, "飞书"),
|
|
1476
|
+
h("option", { value: "lark" }, "Lark(国际版)"))))
|
|
1462
1477
|
: null;
|
|
1463
1478
|
|
|
1464
1479
|
const setCardRef = React.useCallback((botId, node) => {
|
|
@@ -47,6 +47,7 @@ export function CredentialBindingPanel({
|
|
|
47
47
|
error = null,
|
|
48
48
|
onSubmit,
|
|
49
49
|
onCancel,
|
|
50
|
+
children,
|
|
50
51
|
}) {
|
|
51
52
|
const [identity, setIdentity] = React.useState('');
|
|
52
53
|
const [secret, setSecret] = React.useState('');
|
|
@@ -66,6 +67,7 @@ export function CredentialBindingPanel({
|
|
|
66
67
|
'aria-labelledby': headingId,
|
|
67
68
|
},
|
|
68
69
|
h('h3', { id: headingId, className: 'dim-credentialTitle' }, `手动接入${channel}机器人`),
|
|
70
|
+
children,
|
|
69
71
|
h('form', {
|
|
70
72
|
className: `dim-credentialForm${hasIdentity ? '' : ' dim-credentialFormSingle'}`,
|
|
71
73
|
onSubmit: submit,
|
|
@@ -39,6 +39,7 @@ const EN = Object.freeze({
|
|
|
39
39
|
'macOS Messages 权限': 'macOS Messages permissions',
|
|
40
40
|
'IM机器人': 'IM bots',
|
|
41
41
|
'IM机器人设置': 'IM bot settings',
|
|
42
|
+
'IM 面板加载失败': 'Could not load the IM panel',
|
|
42
43
|
'更多机器人设置': 'More bot settings',
|
|
43
44
|
'机器人设置': 'Bot settings',
|
|
44
45
|
'机器人设置页签': 'Bot settings tabs',
|
|
@@ -698,6 +699,12 @@ const EN = Object.freeze({
|
|
|
698
699
|
'Slack 工作区': 'Slack workspace',
|
|
699
700
|
'Bot Token 与 App Token': 'Bot Token and App Token',
|
|
700
701
|
'填写 Bot Token': 'Enter Bot Token',
|
|
702
|
+
'应用平台': 'App platform',
|
|
703
|
+
'Lark(国际版)': 'Lark (international)',
|
|
704
|
+
'手动接入Lark机器人': 'Connect Lark bot manually',
|
|
705
|
+
'填写 Lark 开放平台 App ID': 'Enter the Lark Developer App ID',
|
|
706
|
+
'填写 Lark 开放平台 App Secret': 'Enter the Lark Developer App Secret',
|
|
707
|
+
'Lark 机器人凭据已绑定。': 'Lark bot credentials connected.',
|
|
701
708
|
'手动接入飞书机器人': 'Connect Feishu bot manually',
|
|
702
709
|
'手动接入钉钉机器人': 'Connect DingTalk bot manually',
|
|
703
710
|
'手动接入企业微信机器人': 'Connect WeCom bot manually',
|
|
@@ -74,6 +74,7 @@ import { installImStyles } from './styles.js';
|
|
|
74
74
|
import { installSessionChannelLogos } from './session-channel-logos.js';
|
|
75
75
|
import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
|
|
76
76
|
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
|
|
77
|
+
import { IMPanelErrorBoundary } from './panel-error-boundary.js';
|
|
77
78
|
|
|
78
79
|
export const name = 'im-settings';
|
|
79
80
|
export const inject = ['slots', 'connection', 'locale', 'workspaces'];
|
|
@@ -209,10 +210,15 @@ export function IMSettingsTab({
|
|
|
209
210
|
deliveryRpcCall,
|
|
210
211
|
globalSettingsRpcCall,
|
|
211
212
|
workspaceDirectoryPicker,
|
|
213
|
+
preferredSectionId,
|
|
212
214
|
browserLocation = globalThis.location,
|
|
213
215
|
navigateToRecoveryUrl = replacePageLocation,
|
|
214
216
|
}) {
|
|
215
|
-
const [selected, setSelected] = React.useState(
|
|
217
|
+
const [selected, setSelected] = React.useState(() => (
|
|
218
|
+
preferredSectionId === GLOBAL_SETTINGS_TAB_ID
|
|
219
|
+
|| CHANNELS.some((channel) => channel.id === preferredSectionId)
|
|
220
|
+
? preferredSectionId : 'weixin'
|
|
221
|
+
));
|
|
216
222
|
const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
|
|
217
223
|
const [runningVersion, setRunningVersion] = React.useState(IM_PLUGIN_VERSION);
|
|
218
224
|
const [deliverySettings, setDeliverySettings] = React.useState(null);
|
|
@@ -471,29 +477,89 @@ export function apply(ctx) {
|
|
|
471
477
|
pickDirectory: () => callWorkspaceDirectoryApi(ctx, 'pickDirectory'),
|
|
472
478
|
});
|
|
473
479
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
480
|
+
const panelDependencies = {
|
|
481
|
+
dingtalkRpcCall,
|
|
482
|
+
discordRpcCall,
|
|
483
|
+
feishuRpcCall,
|
|
484
|
+
qqRpcCall,
|
|
485
|
+
slackRpcCall,
|
|
486
|
+
telegramRpcCall,
|
|
487
|
+
wecomRpcCall,
|
|
488
|
+
wecomAppRpcCall,
|
|
489
|
+
weixinRpcCall,
|
|
490
|
+
whatsappRpcCall,
|
|
491
|
+
imessageRpcCall,
|
|
492
|
+
officeRpcCall,
|
|
493
|
+
updateRpcCall,
|
|
494
|
+
deliveryRpcCall,
|
|
495
|
+
globalSettingsRpcCall,
|
|
496
|
+
workspaceDirectoryPicker,
|
|
497
|
+
};
|
|
498
|
+
const subscribeLocale = (listener) => typeof ctx.on === 'function'
|
|
499
|
+
? ctx.on('locale/change', listener) : () => {};
|
|
500
|
+
const localeSnapshot = () => ctx.locale.getLocale?.()?.active ?? '';
|
|
501
|
+
|
|
502
|
+
// Stable for this plugin lifetime: creating an element must not create a
|
|
503
|
+
// new component type and reset the reader's selected tab or unsaved input.
|
|
504
|
+
function IMPanel({ preferredSectionId }) {
|
|
505
|
+
React.useSyncExternalStore(subscribeLocale, localeSnapshot, localeSnapshot);
|
|
506
|
+
return h(IMPanelErrorBoundary, null,
|
|
507
|
+
h(IMSettingsTab, { ...panelDependencies, preferredSectionId }));
|
|
508
|
+
}
|
|
509
|
+
const buildPanelElement = (props = {}) => h(IMPanel, {
|
|
510
|
+
preferredSectionId: props.preferredSectionId,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
ctx.effect(() => {
|
|
514
|
+
let disposed = false;
|
|
515
|
+
let stopSettings = null;
|
|
516
|
+
let registered = false;
|
|
517
|
+
const setSettingsVisible = (visible) => {
|
|
518
|
+
if (disposed) return;
|
|
519
|
+
if (typeof visible !== 'boolean') throw new TypeError('visible must be a boolean');
|
|
520
|
+
if (visible === (stopSettings !== null)) return;
|
|
521
|
+
if (!visible) {
|
|
522
|
+
const stop = stopSettings;
|
|
523
|
+
stopSettings = null;
|
|
524
|
+
stop();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// The existing slot controller owns late declarations, withdrawal and
|
|
528
|
+
// re-declaration. Cancelling it also cancels a pending registration.
|
|
529
|
+
stopSettings = ctx.slots.inject('settings.section', () => {
|
|
530
|
+
const unregister = ctx.slots.register({
|
|
531
|
+
name: 'settings.section',
|
|
532
|
+
id: 'xmanrui-dsh-im',
|
|
533
|
+
order: 21,
|
|
534
|
+
label: () => t('IM机器人'),
|
|
535
|
+
locale: IM_LOCALE_NAMESPACE,
|
|
536
|
+
inject: () => panelDependencies,
|
|
537
|
+
}, buildPanelElement);
|
|
538
|
+
registered = true;
|
|
539
|
+
return () => {
|
|
540
|
+
registered = false;
|
|
541
|
+
unregister();
|
|
542
|
+
};
|
|
543
|
+
});
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// Publish only after the default registration is established, so a
|
|
547
|
+
// consumer hiding it during service discovery cannot be overridden.
|
|
548
|
+
setSettingsVisible(true);
|
|
549
|
+
if (typeof ctx.provide === 'function') {
|
|
550
|
+
ctx.provide('dshImClient', Object.freeze({
|
|
551
|
+
version: 1,
|
|
552
|
+
render: (props) => disposed ? null : buildPanelElement(props),
|
|
553
|
+
setSettingsVisible,
|
|
554
|
+
settingsVisible: () => !disposed && registered,
|
|
555
|
+
}));
|
|
556
|
+
}
|
|
557
|
+
return () => {
|
|
558
|
+
disposed = true;
|
|
559
|
+
const stop = stopSettings;
|
|
560
|
+
stopSettings = null;
|
|
561
|
+
registered = false;
|
|
562
|
+
stop?.();
|
|
563
|
+
};
|
|
564
|
+
}, 'im-settings: client panel service');
|
|
499
565
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { h } from './i18n.js';
|
|
3
|
+
|
|
4
|
+
/** Keep a broken IM subtree local when a shell renders it outside a slot. */
|
|
5
|
+
export class IMPanelErrorBoundary extends React.Component {
|
|
6
|
+
state = { failed: false };
|
|
7
|
+
|
|
8
|
+
static getDerivedStateFromError() {
|
|
9
|
+
return { failed: true };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
render() {
|
|
13
|
+
if (!this.state.failed) return this.props.children;
|
|
14
|
+
return h('section', { className: 'dim-page', role: 'alert' },
|
|
15
|
+
h('p', null, 'IM 面板加载失败'),
|
|
16
|
+
h('button', {
|
|
17
|
+
type: 'button',
|
|
18
|
+
onClick: () => this.setState({ failed: false }),
|
|
19
|
+
}, '重试'));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createWorkspaceAwareController,
|
|
15
15
|
observeBotWorkspaceRemovals,
|
|
16
16
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
17
|
+
import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
|
|
17
18
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
18
19
|
import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
|
|
19
20
|
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
@@ -58,7 +59,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
58
59
|
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
|
59
60
|
const paths = pluginPaths(config);
|
|
60
61
|
const configStore = await new ConfigStore(paths.config).load();
|
|
61
|
-
const defaultWorkspace =
|
|
62
|
+
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
62
63
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
63
64
|
const workspaces = internals.workspaces
|
|
64
65
|
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
@@ -105,6 +106,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
105
106
|
const harness = new Harness({
|
|
106
107
|
...connection,
|
|
107
108
|
workspace: defaultWorkspace,
|
|
109
|
+
ungroupedWorkspace,
|
|
108
110
|
autostart: false,
|
|
109
111
|
dshBin: config.dshBin ?? 'dsh',
|
|
110
112
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
createWorkspaceAwareController,
|
|
27
27
|
observeBotWorkspaceRemovals,
|
|
28
28
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
29
|
+
import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
|
|
29
30
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
30
31
|
import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
|
|
31
32
|
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
@@ -117,7 +118,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
117
118
|
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
|
118
119
|
const paths = pluginPaths(config);
|
|
119
120
|
const configStore = await new ConfigStore(paths.config).load();
|
|
120
|
-
const defaultWorkspace =
|
|
121
|
+
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
121
122
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
122
123
|
const workspaces = internals.workspaces
|
|
123
124
|
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
@@ -201,6 +202,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
201
202
|
const harness = new Harness({
|
|
202
203
|
...connection,
|
|
203
204
|
workspace: defaultWorkspace,
|
|
205
|
+
ungroupedWorkspace,
|
|
204
206
|
// This plugin is already hosted by a running DSH process. Starting a
|
|
205
207
|
// second DSH would create a competing server and lifecycle.
|
|
206
208
|
autostart: false,
|
|
@@ -401,9 +401,10 @@ function validPayload(endpoint, payload) {
|
|
|
401
401
|
: 'Group message permission update requires a single valid botId.';
|
|
402
402
|
}
|
|
403
403
|
if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
|
404
|
-
return hasOnlyKeys(payload, new Set(['appId', 'appSecret']))
|
|
404
|
+
return hasOnlyKeys(payload, new Set(['appId', 'appSecret', 'domain']))
|
|
405
405
|
&& validCredential(payload.appId, 256)
|
|
406
406
|
&& validCredential(payload.appSecret, 1024)
|
|
407
|
+
&& (payload.domain === undefined || payload.domain === 'feishu' || payload.domain === 'lark')
|
|
407
408
|
? null
|
|
408
409
|
: 'Credential binding requires App ID and App Secret.';
|
|
409
410
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createWorkspaceAwareController,
|
|
15
15
|
observeBotWorkspaceRemovals,
|
|
16
16
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
17
|
+
import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
|
|
17
18
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
18
19
|
import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
|
|
19
20
|
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
@@ -55,7 +56,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
55
56
|
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
|
56
57
|
const paths = pluginPaths(config);
|
|
57
58
|
const configStore = await new ConfigStore(paths.config).load();
|
|
58
|
-
const defaultWorkspace =
|
|
59
|
+
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
59
60
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
60
61
|
const workspaces = internals.workspaces
|
|
61
62
|
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
@@ -96,6 +97,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
96
97
|
const harness = new Harness({
|
|
97
98
|
...connection,
|
|
98
99
|
workspace: defaultWorkspace,
|
|
100
|
+
ungroupedWorkspace,
|
|
99
101
|
autostart: false,
|
|
100
102
|
dshBin: config.dshBin ?? 'dsh',
|
|
101
103
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
createWorkspaceAwareController,
|
|
17
17
|
observeBotWorkspaceRemovals,
|
|
18
18
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
19
|
+
import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
|
|
19
20
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
20
21
|
import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
|
|
21
22
|
import {
|
|
@@ -65,7 +66,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|
|
65
66
|
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
|
66
67
|
const paths = pluginPaths(config, channel);
|
|
67
68
|
const configStore = await new ResolvedConfigStore(paths.config).load();
|
|
68
|
-
const defaultWorkspace =
|
|
69
|
+
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
69
70
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
70
71
|
const workspaces = internals.workspaces
|
|
71
72
|
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
@@ -105,6 +106,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|
|
105
106
|
const harness = new ResolvedHarness({
|
|
106
107
|
...connection,
|
|
107
108
|
workspace: defaultWorkspace,
|
|
109
|
+
ungroupedWorkspace,
|
|
108
110
|
autostart: false,
|
|
109
111
|
dshBin: config.dshBin ?? 'dsh',
|
|
110
112
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
createWorkspaceAwareController,
|
|
13
13
|
observeBotWorkspaceRemovals,
|
|
14
14
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
15
|
+
import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
|
|
15
16
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
16
17
|
import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
|
|
17
18
|
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
@@ -44,7 +45,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
44
45
|
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
|
45
46
|
const paths = pluginPaths(config, 'slack');
|
|
46
47
|
const configStore = await new ResolvedConfigStore(paths.config).load();
|
|
47
|
-
const defaultWorkspace =
|
|
48
|
+
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
48
49
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
49
50
|
const workspaces = internals.workspaces
|
|
50
51
|
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
@@ -84,6 +85,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
84
85
|
const harness = new ResolvedHarness({
|
|
85
86
|
...connection,
|
|
86
87
|
workspace: defaultWorkspace,
|
|
88
|
+
ungroupedWorkspace,
|
|
87
89
|
autostart: false,
|
|
88
90
|
dshBin: config.dshBin ?? 'dsh',
|
|
89
91
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createWorkspaceAwareController,
|
|
15
15
|
observeBotWorkspaceRemovals,
|
|
16
16
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
17
|
+
import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
|
|
17
18
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
18
19
|
import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
|
|
19
20
|
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
@@ -55,7 +56,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
55
56
|
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
|
56
57
|
const paths = pluginPaths(config);
|
|
57
58
|
const configStore = await new ConfigStore(paths.config).load();
|
|
58
|
-
const defaultWorkspace =
|
|
59
|
+
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
59
60
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
60
61
|
const workspaces = internals.workspaces
|
|
61
62
|
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
@@ -99,6 +100,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
99
100
|
const harness = new Harness({
|
|
100
101
|
...connection,
|
|
101
102
|
workspace: defaultWorkspace,
|
|
103
|
+
ungroupedWorkspace,
|
|
102
104
|
autostart: false,
|
|
103
105
|
dshBin: config.dshBin ?? 'dsh',
|
|
104
106
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createWorkspaceAwareController,
|
|
15
15
|
observeBotWorkspaceRemovals,
|
|
16
16
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
17
|
+
import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
|
|
17
18
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
18
19
|
import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
|
|
19
20
|
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
@@ -69,7 +70,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
69
70
|
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
|
70
71
|
const paths = pluginPaths(config);
|
|
71
72
|
const configStore = await new ConfigStore(paths.config).load();
|
|
72
|
-
const defaultWorkspace =
|
|
73
|
+
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
73
74
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
74
75
|
const workspaces = internals.workspaces
|
|
75
76
|
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
@@ -133,6 +134,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
133
134
|
const harness = new Harness({
|
|
134
135
|
...connection,
|
|
135
136
|
workspace: defaultWorkspace,
|
|
137
|
+
ungroupedWorkspace,
|
|
136
138
|
autostart: false,
|
|
137
139
|
dshBin: config.dshBin ?? 'dsh',
|
|
138
140
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
createWorkspaceAwareController,
|
|
19
19
|
observeBotWorkspaceRemovals,
|
|
20
20
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
21
|
+
import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
|
|
21
22
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
22
23
|
import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
|
|
23
24
|
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
@@ -90,7 +91,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
90
91
|
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
|
91
92
|
const paths = pluginPaths(config);
|
|
92
93
|
const configStore = await new ConfigStore(paths.config).load();
|
|
93
|
-
const defaultWorkspace =
|
|
94
|
+
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
94
95
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
95
96
|
const workspaces = diagnosticWorkspaces(internals.workspaces
|
|
96
97
|
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load());
|
|
@@ -131,6 +132,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
131
132
|
const harness = new Harness({
|
|
132
133
|
...connection,
|
|
133
134
|
workspace: defaultWorkspace,
|
|
135
|
+
ungroupedWorkspace,
|
|
134
136
|
autostart: false,
|
|
135
137
|
dshBin: config.dshBin ?? 'dsh',
|
|
136
138
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
createWorkspaceAwareController,
|
|
18
18
|
observeBotWorkspaceRemovals,
|
|
19
19
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
|
20
|
+
import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
|
|
20
21
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
|
21
22
|
import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
|
|
22
23
|
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
|
@@ -65,7 +66,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
65
66
|
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
|
|
66
67
|
const paths = pluginPaths(config);
|
|
67
68
|
const configStore = await new ConfigStore(paths.config).load();
|
|
68
|
-
const defaultWorkspace =
|
|
69
|
+
const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
|
|
69
70
|
const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
|
|
70
71
|
const workspaces = internals.workspaces
|
|
71
72
|
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
|
|
@@ -105,6 +106,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
105
106
|
const harness = new Harness({
|
|
106
107
|
...connection,
|
|
107
108
|
workspace: defaultWorkspace,
|
|
109
|
+
ungroupedWorkspace,
|
|
108
110
|
autostart: false,
|
|
109
111
|
dshBin: config.dshBin ?? 'dsh',
|
|
110
112
|
...(commandExecutor ? { commandExecutor } : {}),
|
|
@@ -5,6 +5,7 @@ import { access, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises
|
|
|
5
5
|
import { request as httpRequest } from 'node:http';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
9
|
|
|
9
10
|
const harnessRoot = process.argv[2];
|
|
10
11
|
if (!harnessRoot || process.argv.includes('--help')) {
|
|
@@ -18,6 +19,7 @@ await access(join(pluginRoot, 'lib/index.js'));
|
|
|
18
19
|
// DSH 0.1.5 CLI intentionally permits only loopback listening. Exercise its
|
|
19
20
|
// real HTTP carrier with a trusted LAN authority, while keeping TCP local.
|
|
20
21
|
const lanIp = '192.168.1.100';
|
|
22
|
+
const domain = 'dsh.example.test';
|
|
21
23
|
|
|
22
24
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-lan-test-'));
|
|
23
25
|
const home = join(directory, 'home');
|
|
@@ -43,12 +45,12 @@ function request(url, { method = 'GET', headers = {}, body } = {}) {
|
|
|
43
45
|
});
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
function start() {
|
|
48
|
+
function start(trustedHosts = [lanIp]) {
|
|
47
49
|
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => (
|
|
48
50
|
!/^DSH_/i.test(key) && !/(?:KEY|SECRET|TOKEN|PASSWORD|PROXY)/i.test(key)
|
|
49
51
|
)));
|
|
50
52
|
child = spawn(process.execPath, [cli, 'web', '--no-open', '--host', '127.0.0.1',
|
|
51
|
-
'--port', '0', '--trusted-host',
|
|
53
|
+
'--port', '0', ...trustedHosts.flatMap(host => ['--trusted-host', host])], {
|
|
52
54
|
cwd: directory,
|
|
53
55
|
env: { ...env, DSH_HOME: home, DSH_AGENTS_HOME: join(directory, '.agents'),
|
|
54
56
|
DSH_TELEMETRY_DISABLED: '1', SSH_CONNECTION: '', SSH_TTY: '' },
|
|
@@ -107,6 +109,18 @@ function rpc(browser, channel = 'feishu', method = 'connection.status', headers
|
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
async function readyStatus(browser, channel = 'feishu') {
|
|
113
|
+
const deadline = Date.now() + 10_000;
|
|
114
|
+
// HTTP readiness can precede the channel controllers finishing startup.
|
|
115
|
+
for (;;) {
|
|
116
|
+
const response = await rpc(browser, channel);
|
|
117
|
+
const result = response.status === 200 ? JSON.parse(response.body).result : null;
|
|
118
|
+
if (result?.ok !== false || result.error?.code !== `${channel}-initializing`
|
|
119
|
+
|| Date.now() >= deadline) return response;
|
|
120
|
+
await delay(100);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
110
124
|
function expectStatus(name, response, expected, businessOk = false) {
|
|
111
125
|
assert.equal(response.status, expected, `${name}: ${response.body.slice(0, 300)}`);
|
|
112
126
|
if (businessOk) {
|
|
@@ -146,9 +160,10 @@ try {
|
|
|
146
160
|
expectStatus('LAN authenticated web page', await request(new URL('/', lan.origin), {
|
|
147
161
|
headers: { cookie: lan.cookie },
|
|
148
162
|
}), 200);
|
|
149
|
-
|
|
150
|
-
'slack', 'telegram', 'discord', 'whatsapp', 'imessage', 'office']
|
|
151
|
-
|
|
163
|
+
const channels = ['feishu', 'weixin', 'dingtalk', 'wecom', 'wecom-app', 'qq',
|
|
164
|
+
'slack', 'telegram', 'discord', 'whatsapp', 'imessage', 'office'];
|
|
165
|
+
for (const channel of channels) {
|
|
166
|
+
expectStatus(`LAN default: ${channel}`, await readyStatus(lan, channel), 200, true);
|
|
152
167
|
}
|
|
153
168
|
const delivery = await rpc(lan, 'dsh-im-delivery', 'target.list', {}, { botId: 'bot_missing' });
|
|
154
169
|
expectStatus('LAN delivery reaches business handler', delivery, 200);
|
|
@@ -159,15 +174,45 @@ try {
|
|
|
159
174
|
expectStatus('LAN update remains local-only', await rpc(lan, 'dsh-im', 'update.status'), 403);
|
|
160
175
|
expectStatus('LAN TTL remains local-only', await rpc(lan, 'dsh-im-settings', 'settings.inbound-ttl.get'), 403);
|
|
161
176
|
const local = await login(launchUrl, '127.0.0.1');
|
|
162
|
-
|
|
177
|
+
const localhost = await login(launchUrl, 'localhost');
|
|
178
|
+
for (const browser of [local, localhost]) {
|
|
179
|
+
const hostname = new URL(browser.origin).hostname;
|
|
180
|
+
for (const channel of channels) {
|
|
181
|
+
expectStatus(`${hostname} default: ${channel}`, await readyStatus(browser, channel), 200, true);
|
|
182
|
+
}
|
|
183
|
+
// Emulate a browser/proxy dropping the Origin port; this is a header-level
|
|
184
|
+
// reproduction, not evidence that a particular browser emits that header.
|
|
185
|
+
expectStatus(`${hostname} port-less Origin rejected`, await rpc(browser, 'dingtalk',
|
|
186
|
+
'connection.status', { origin: `http://${hostname}` }), 403);
|
|
187
|
+
expectStatus(`${hostname} cross-site request rejected`, await rpc(browser, 'dingtalk',
|
|
188
|
+
'connection.status', { 'sec-fetch-site': 'cross-site' }), 403);
|
|
189
|
+
}
|
|
190
|
+
expectStatus('Loopback Host/Origin hostname mismatch rejected', await rpc(local, 'dingtalk',
|
|
191
|
+
'connection.status', { origin: localhost.origin }), 403);
|
|
192
|
+
expectStatus('Authenticated domain without --trusted-host rejected',
|
|
193
|
+
await rpc(await login(launchUrl, domain), 'dingtalk'), 403);
|
|
194
|
+
|
|
195
|
+
await stop();
|
|
196
|
+
const domainLaunchUrl = await start([lanIp, domain]);
|
|
197
|
+
const trustedDomain = await login(domainLaunchUrl, domain);
|
|
198
|
+
expectStatus('Trusted domain without login rejected',
|
|
199
|
+
await rpc({ origin: trustedDomain.origin }, 'dingtalk'), 401);
|
|
200
|
+
for (const channel of channels) {
|
|
201
|
+
expectStatus(`Domain with --trusted-host: ${channel}`, await readyStatus(trustedDomain, channel), 200, true);
|
|
202
|
+
}
|
|
203
|
+
const otherTrustedOrigin = new URL(trustedDomain.origin);
|
|
204
|
+
otherTrustedOrigin.hostname = lanIp;
|
|
205
|
+
expectStatus('Two trusted hosts still require matching Origin', await rpc(trustedDomain,
|
|
206
|
+
'dingtalk', 'connection.status', { origin: otherTrustedOrigin.origin }), 403);
|
|
163
207
|
|
|
164
208
|
await stop();
|
|
165
209
|
await writeFile(join(profile, 'cordis.patch.yml'), '- id: xmanrui-dsh-im\n config:\n rpcAuthority: loopback\n');
|
|
166
210
|
const restrictedUrl = await start();
|
|
167
211
|
expectStatus('Explicit loopback rejects LAN', await rpc(await login(restrictedUrl, lanIp)), 403);
|
|
168
|
-
expectStatus('Explicit loopback accepts
|
|
212
|
+
expectStatus('Explicit loopback accepts 127.0.0.1', await readyStatus(await login(restrictedUrl, '127.0.0.1')), 200, true);
|
|
213
|
+
expectStatus('Explicit loopback accepts localhost', await readyStatus(await login(restrictedUrl, 'localhost')), 200, true);
|
|
169
214
|
console.table(results);
|
|
170
|
-
console.log(`Passed ${results.length} real HTTP checks using LAN
|
|
215
|
+
console.log(`Passed ${results.length} real HTTP checks using loopback, LAN ${lanIp} and domain ${domain}.`);
|
|
171
216
|
console.log('TCP connections stayed on loopback. This checks the original CLI, authentication and built plugin, not a second-device browser.');
|
|
172
217
|
console.log('The temporary profile contains no bot credentials and is removed after the server stops.');
|
|
173
218
|
} finally {
|