@xmanrui/dsh-im 4.21.0 → 4.21.2
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 +10 -0
- package/README.md +10 -0
- package/lib/client.js +46 -9
- package/lib/index.js +281 -280
- package/package.json +1 -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 +6 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +2 -1
- package/plugin-src/host/index.mjs +7 -0
- package/plugin-src/host/injected-context.mjs +104 -0
- package/scripts/verify-lan-management.mjs +53 -8
- package/src/channels/dingtalk/dingtalk-bridge.mjs +104 -27
- package/src/channels/feishu/bridge.mjs +35 -2
- package/src/channels/qq/qq-bridge.mjs +27 -2
- package/src/channels/shared/context-enhancement.mjs +40 -3
- package/src/channels/shared/control-command.mjs +8 -1
- package/src/channels/shared/harness-client.mjs +7 -0
- package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/im-source-guidance.mjs +65 -0
- package/src/channels/shared/injected-context.mjs +362 -0
- package/src/channels/shared/semantic/artifact.mjs +3 -3
- package/src/channels/shared/semantic/reply-reference.mjs +2 -1
- package/src/channels/shared/text-harness-bridge.mjs +23 -2
- package/src/channels/shared/workspace-session.mjs +9 -0
- package/src/channels/wecom/wecom-bridge.mjs +11 -1
- package/src/channels/wecom-app/wecom-app-bridge.mjs +11 -1
- package/src/channels/weixin/weixin-api.mjs +52 -13
- package/src/channels/weixin/weixin-bridge.mjs +13 -1
package/package.json
CHANGED
|
@@ -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,
|
|
@@ -698,6 +698,12 @@ const EN = Object.freeze({
|
|
|
698
698
|
'Slack 工作区': 'Slack workspace',
|
|
699
699
|
'Bot Token 与 App Token': 'Bot Token and App Token',
|
|
700
700
|
'填写 Bot Token': 'Enter Bot Token',
|
|
701
|
+
'应用平台': 'App platform',
|
|
702
|
+
'Lark(国际版)': 'Lark (international)',
|
|
703
|
+
'手动接入Lark机器人': 'Connect Lark bot manually',
|
|
704
|
+
'填写 Lark 开放平台 App ID': 'Enter the Lark Developer App ID',
|
|
705
|
+
'填写 Lark 开放平台 App Secret': 'Enter the Lark Developer App Secret',
|
|
706
|
+
'Lark 机器人凭据已绑定。': 'Lark bot credentials connected.',
|
|
701
707
|
'手动接入飞书机器人': 'Connect Feishu bot manually',
|
|
702
708
|
'手动接入钉钉机器人': 'Connect DingTalk bot manually',
|
|
703
709
|
'手动接入企业微信机器人': 'Connect WeCom bot manually',
|
|
@@ -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
|
}
|
|
@@ -17,6 +17,7 @@ import { installDeliveryRpc } from './delivery-rpc.mjs';
|
|
|
17
17
|
import { installDeliveryHttp } from './delivery-http.mjs';
|
|
18
18
|
import { createDeliveryService } from './delivery-service.mjs';
|
|
19
19
|
import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
|
|
20
|
+
import { installInjectedContext } from './injected-context.mjs';
|
|
20
21
|
import { installSessionSyncCoordinator } from './session-sync-coordinator.mjs';
|
|
21
22
|
import { installSessionTitlePrefix } from './session-title-prefix.mjs';
|
|
22
23
|
import { installUpdateRpc } from './update-rpc.mjs';
|
|
@@ -41,6 +42,7 @@ export function createImHostPlugin(internals = {}) {
|
|
|
41
42
|
const startHostLanguageRpc = internals.installHostLanguageRpc ?? installHostLanguageRpc;
|
|
42
43
|
const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
|
|
43
44
|
const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
|
|
45
|
+
const startInjectedContext = internals.installInjectedContext ?? installInjectedContext;
|
|
44
46
|
const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
|
|
45
47
|
const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
|
|
46
48
|
const startSessionSync = internals.installSessionSyncCoordinator
|
|
@@ -139,6 +141,11 @@ export function createImHostPlugin(internals = {}) {
|
|
|
139
141
|
const logger = typeof ctx?.logger === 'function'
|
|
140
142
|
? ctx.logger(name)
|
|
141
143
|
: (ctx?.logger ?? console);
|
|
144
|
+
try {
|
|
145
|
+
startInjectedContext(ctx, { logger });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger.error?.('[dsh-im] failed to activate injected-context pairing; prompts keep the inline prefix', error);
|
|
148
|
+
}
|
|
142
149
|
if (ctx?.connection?.fetch) {
|
|
143
150
|
if (hostLanguage) {
|
|
144
151
|
try {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { getImHostLanguage } from '../../src/channels/shared/i18n.mjs';
|
|
2
|
+
import { rewriteInjectedContextMessages } from '../../src/channels/shared/injected-context.mjs';
|
|
3
|
+
import {
|
|
4
|
+
IM_SOURCE_GUIDANCE_CONTEXT,
|
|
5
|
+
IM_SOURCE_GUIDANCE_ORDER,
|
|
6
|
+
imSourceGuidance,
|
|
7
|
+
} from '../../src/channels/shared/im-source-guidance.mjs';
|
|
8
|
+
|
|
9
|
+
/** Row label for a quoted reply, in the mirrored Host interface language. */
|
|
10
|
+
const REPLY_LABELS = Object.freeze({ zh: '\u5f15\u7528', en: 'Quoted' });
|
|
11
|
+
|
|
12
|
+
/** Row label for a source block that names no readable field of its own. */
|
|
13
|
+
const SOURCE_LABELS = Object.freeze({ zh: '\u6765\u6e90', en: 'Source' });
|
|
14
|
+
|
|
15
|
+
/** Read the current reply-row label; an unknown language falls back to English. */
|
|
16
|
+
function replyLabel() {
|
|
17
|
+
try {
|
|
18
|
+
return getImHostLanguage() === 'en' ? REPLY_LABELS.en : REPLY_LABELS.zh;
|
|
19
|
+
} catch {
|
|
20
|
+
return REPLY_LABELS.en;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read the current nameless-source-row label; unknown languages fall back. */
|
|
25
|
+
function sourceLabel() {
|
|
26
|
+
try {
|
|
27
|
+
return getImHostLanguage() === 'en' ? SOURCE_LABELS.en : SOURCE_LABELS.zh;
|
|
28
|
+
} catch {
|
|
29
|
+
return SOURCE_LABELS.en;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pair every injected context block with the user message that carried it, and
|
|
35
|
+
* materialize the source guidance as session-level prompt context.
|
|
36
|
+
*
|
|
37
|
+
* The prompt RPC cannot carry a message source, so a channel writes its source
|
|
38
|
+
* block, guidance and quoted reply into the prompt text. `agent/pre-step` is
|
|
39
|
+
* the one point where the claimed messages are already known and nothing is
|
|
40
|
+
* committed yet: splitting the blocks there records each as its own
|
|
41
|
+
* plugin-sourced message next to exactly the user message it belongs to,
|
|
42
|
+
* whatever the inbox interleaved meanwhile. A Host without the Agent loop never
|
|
43
|
+
* dispatches the event, and the prompt then keeps its inline blocks.
|
|
44
|
+
*
|
|
45
|
+
* Guidance is not per message but per conversation, so it is registered as
|
|
46
|
+
* dynamic prompt context instead: the Host appends one durable snapshot per
|
|
47
|
+
* Session and only re-renders it when a channel publishes different guidance.
|
|
48
|
+
* Deployment policy still decides whether such snapshots exist at all, which is
|
|
49
|
+
* why the message-side copy is kept until a channel has published a matching
|
|
50
|
+
* value for that Session.
|
|
51
|
+
*
|
|
52
|
+
* @param ctx - owning Host context.
|
|
53
|
+
* @param options.logger - Host logger for a rewrite that could not be trusted.
|
|
54
|
+
* @param options.registry - guidance registry; tests inject their own.
|
|
55
|
+
* @returns the Cordis disposer, or null when the context cannot listen.
|
|
56
|
+
*/
|
|
57
|
+
export function installInjectedContext(ctx, { logger, registry = imSourceGuidance } = {}) {
|
|
58
|
+
if (typeof ctx?.on !== 'function') return null;
|
|
59
|
+
|
|
60
|
+
const startGuidanceContext = (promptCtx) => {
|
|
61
|
+
if (typeof promptCtx?.systemPrompt?.context !== 'function') return null;
|
|
62
|
+
return promptCtx.systemPrompt.context({
|
|
63
|
+
name: IM_SOURCE_GUIDANCE_CONTEXT,
|
|
64
|
+
order: IM_SOURCE_GUIDANCE_ORDER,
|
|
65
|
+
text: (assembly) => registry.get(assembly?.agent?.session?.id) ?? '',
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
if (typeof ctx.inject === 'function') {
|
|
69
|
+
// Same gating the outbound artifact tool uses: register once the service
|
|
70
|
+
// exists instead of requiring it at load time.
|
|
71
|
+
ctx.inject(['systemPrompt'], startGuidanceContext);
|
|
72
|
+
} else {
|
|
73
|
+
startGuidanceContext(ctx);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const disposeDisposed = ctx.on('agent/disposed', ({ agent }) => {
|
|
77
|
+
const sessionId = agent?.session?.id;
|
|
78
|
+
if (typeof sessionId === 'string') registry.forget(sessionId);
|
|
79
|
+
}, { global: true });
|
|
80
|
+
|
|
81
|
+
const listener = async ({ agent }, next) => {
|
|
82
|
+
const decision = await next();
|
|
83
|
+
if (decision === null || typeof decision !== 'object' || decision.kind !== 'enter') {
|
|
84
|
+
return decision;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const rewritten = rewriteInjectedContextMessages(decision.messages, {
|
|
88
|
+
labels: { reply: replyLabel(), source: sourceLabel() },
|
|
89
|
+
ownedGuidance: registry.get(agent?.session?.id),
|
|
90
|
+
});
|
|
91
|
+
return rewritten === null ? decision : { ...decision, messages: rewritten };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// A failed split must never cost the user their message: the blocks then
|
|
94
|
+
// stay inline, which is the same fallback a Host without Agents uses.
|
|
95
|
+
logger?.warn?.('[dsh-im] unable to split the injected context blocks:', error?.message ?? error);
|
|
96
|
+
return decision;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const disposePreStep = ctx.on('agent/pre-step', listener, { global: true });
|
|
100
|
+
return () => {
|
|
101
|
+
disposePreStep?.();
|
|
102
|
+
disposeDisposed?.();
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -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 {
|
|
@@ -30,7 +30,11 @@ import {
|
|
|
30
30
|
} from '../shared/preset-command.mjs';
|
|
31
31
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
32
32
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
captureContextEnhancement,
|
|
35
|
+
captureContextEnhancementSource,
|
|
36
|
+
enhanceContextContent,
|
|
37
|
+
} from '../shared/context-enhancement.mjs';
|
|
34
38
|
import {
|
|
35
39
|
BatchInputManager,
|
|
36
40
|
batchInputBusyMessage,
|
|
@@ -43,6 +47,7 @@ import {
|
|
|
43
47
|
imagePromptUserMessage,
|
|
44
48
|
} from '../shared/image-prompt.mjs';
|
|
45
49
|
import {
|
|
50
|
+
InboundFileError,
|
|
46
51
|
hasInboundFiles,
|
|
47
52
|
inboundFileUserMessage,
|
|
48
53
|
prefetchInboundFiles,
|
|
@@ -195,6 +200,13 @@ function downloadCodeFor(value) {
|
|
|
195
200
|
return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
|
|
196
201
|
}
|
|
197
202
|
|
|
203
|
+
function dingtalkImageEntries(msgtype, content) {
|
|
204
|
+
if (msgtype === 'picture') return [content];
|
|
205
|
+
if (msgtype !== 'richtext') return [];
|
|
206
|
+
return richTextEntries(content)
|
|
207
|
+
.filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture');
|
|
208
|
+
}
|
|
209
|
+
|
|
198
210
|
function dingtalkTimestampMs(value) {
|
|
199
211
|
const number = typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
200
212
|
if (!Number.isFinite(number) || number < 0) return null;
|
|
@@ -206,24 +218,36 @@ function usefulReplyText(value) {
|
|
|
206
218
|
return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
|
|
207
219
|
}
|
|
208
220
|
|
|
209
|
-
function
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (!replied || typeof replied !== 'object') {
|
|
214
|
-
return { unavailableReason: 'not-delivered' };
|
|
215
|
-
}
|
|
216
|
-
|
|
221
|
+
function dingtalkReplyMessage(message) {
|
|
222
|
+
if (message?.text?.isReplyMsg !== true) return null;
|
|
223
|
+
const replied = message.text.repliedMsg;
|
|
224
|
+
if (!replied || typeof replied !== 'object') return null;
|
|
217
225
|
const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
|
|
218
226
|
const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
|
|
219
|
-
|
|
227
|
+
// Quoted richText entries use msgType instead of the direct callback's type.
|
|
228
|
+
const content = msgtype === 'richtext' ? {
|
|
229
|
+
...repliedContent,
|
|
230
|
+
richText: richTextEntries(repliedContent).map((entry) => ({
|
|
231
|
+
...entry, type: entry?.type ?? entry?.msgType ?? entry?.msgtype,
|
|
232
|
+
})),
|
|
233
|
+
} : repliedContent;
|
|
234
|
+
return {
|
|
220
235
|
msgtype,
|
|
236
|
+
robotCode: message.robotCode,
|
|
221
237
|
text: {
|
|
222
238
|
content: nonEmptyString(repliedContent.text)
|
|
223
239
|
?? (typeof replied.content === 'string' ? replied.content : ''),
|
|
224
240
|
},
|
|
225
|
-
content
|
|
241
|
+
content,
|
|
226
242
|
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function dingtalkReplyReference(message, options) {
|
|
246
|
+
if (message?.text?.isReplyMsg !== true) return null;
|
|
247
|
+
const pseudoMessage = dingtalkReplyMessage(message);
|
|
248
|
+
if (!pseudoMessage) return { unavailableReason: 'not-delivered' };
|
|
249
|
+
const replied = message.text.repliedMsg;
|
|
250
|
+
const { msgtype, content: repliedContent } = pseudoMessage;
|
|
227
251
|
const normalized = dingtalkInboundMessage(pseudoMessage, options);
|
|
228
252
|
let attachments = [];
|
|
229
253
|
if (msgtype === 'picture') {
|
|
@@ -232,8 +256,7 @@ function dingtalkReplyReference(message, options) {
|
|
|
232
256
|
const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
|
|
233
257
|
attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
|
|
234
258
|
} else if (msgtype === 'richtext') {
|
|
235
|
-
attachments =
|
|
236
|
-
.filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
|
|
259
|
+
attachments = dingtalkImageEntries(msgtype, repliedContent)
|
|
237
260
|
.map(() => ({ kind: 'image' }));
|
|
238
261
|
} else if (msgtype === 'voice' || msgtype === 'audio') {
|
|
239
262
|
attachments = [{ kind: 'audio' }];
|
|
@@ -278,6 +301,43 @@ function dingtalkReplyReference(message, options) {
|
|
|
278
301
|
};
|
|
279
302
|
}
|
|
280
303
|
|
|
304
|
+
// Resolve only the immediate quote, after command/interaction routing has finished.
|
|
305
|
+
function prepareDingtalkReplyAttachments(message, promptMessage, options) {
|
|
306
|
+
const quoted = dingtalkReplyMessage(message);
|
|
307
|
+
if (!quoted) return promptMessage;
|
|
308
|
+
if (['audio', 'voice', 'video'].includes(quoted.msgtype)) quoted.msgtype = 'file';
|
|
309
|
+
const imageCodes = dingtalkImageEntries(quoted.msgtype, quoted.content).map(downloadCodeFor);
|
|
310
|
+
const fileCodes = quoted.msgtype === 'file' ? [downloadCodeFor(quoted.content)] : [];
|
|
311
|
+
if ([...imageCodes, ...fileCodes].some((code) => !code)) {
|
|
312
|
+
throw new InboundFileError(
|
|
313
|
+
'dingtalk-quoted-attachment-unavailable',
|
|
314
|
+
'DingTalk did not deliver a download reference for the quoted attachment.',
|
|
315
|
+
t('钉钉未提供引用附件的下载信息,无法读取原附件。请直接重新发送附件后再提问。'),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (imageCodes.length === 0 && fileCodes.length === 0) return promptMessage;
|
|
319
|
+
const currentType = String(message?.msgtype ?? '').toLowerCase();
|
|
320
|
+
const currentContent = parsedMessageContent(message);
|
|
321
|
+
const seen = new Set([
|
|
322
|
+
...dingtalkImageEntries(currentType, currentContent).map(downloadCodeFor),
|
|
323
|
+
...(currentType === 'file' ? [downloadCodeFor(currentContent)] : []),
|
|
324
|
+
].filter(Boolean));
|
|
325
|
+
const take = (code) => {
|
|
326
|
+
if (seen.has(code)) return false;
|
|
327
|
+
seen.add(code);
|
|
328
|
+
return true;
|
|
329
|
+
};
|
|
330
|
+
const normalized = dingtalkInboundMessage(quoted, options);
|
|
331
|
+
const images = normalized.images.filter((_, index) => take(imageCodes[index]));
|
|
332
|
+
const files = normalized.files.filter((_, index) => take(fileCodes[index]));
|
|
333
|
+
const prefetched = prefetchInboundFiles({ files }, { signal: options.signal });
|
|
334
|
+
return {
|
|
335
|
+
...promptMessage,
|
|
336
|
+
images: [...promptMessage.images, ...images],
|
|
337
|
+
files: [...promptMessage.files, ...prefetched.files],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
281
341
|
/** Normalize DingTalk picture and richText callbacks into lazy image references. */
|
|
282
342
|
export function dingtalkInboundMessage(message, {
|
|
283
343
|
api,
|
|
@@ -291,17 +351,7 @@ export function dingtalkInboundMessage(message, {
|
|
|
291
351
|
const text = msgtype === 'text'
|
|
292
352
|
? nonEmptyString(message?.text?.content) ?? ''
|
|
293
353
|
: richEntries.map(richTextEntryText).filter(Boolean).join('\n');
|
|
294
|
-
const imageCodes =
|
|
295
|
-
if (msgtype === 'picture') {
|
|
296
|
-
const code = downloadCodeFor(content);
|
|
297
|
-
if (code) imageCodes.push(code);
|
|
298
|
-
} else if (msgtype === 'richtext') {
|
|
299
|
-
for (const entry of richEntries) {
|
|
300
|
-
if (String(entry?.type ?? '').toLowerCase() !== 'picture') continue;
|
|
301
|
-
const code = downloadCodeFor(entry);
|
|
302
|
-
if (code) imageCodes.push(code);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
354
|
+
const imageCodes = dingtalkImageEntries(msgtype, content).map(downloadCodeFor).filter(Boolean);
|
|
305
355
|
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
|
|
306
356
|
const replyTo = dingtalkReplyReference(message, {
|
|
307
357
|
api,
|
|
@@ -838,6 +888,17 @@ export class DingtalkHarnessBridge {
|
|
|
838
888
|
signal: this.#signal, isDirect: String(message.conversationType) === '1',
|
|
839
889
|
pendingInteraction: this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
|
|
840
890
|
control: { owner: this, key }, deferredDelivery: this.#deferred,
|
|
891
|
+
enhancement: captureContextEnhancementSource(
|
|
892
|
+
this.#contextEnhancement,
|
|
893
|
+
String(message.conversationType) === '1' ? 'direct' : 'group',
|
|
894
|
+
() => ({
|
|
895
|
+
channel: 'dingtalk',
|
|
896
|
+
senderId: senderStaffId(message),
|
|
897
|
+
senderName: message.senderNick,
|
|
898
|
+
conversationTitle: message.conversationTitle,
|
|
899
|
+
chatId: message.conversationId,
|
|
900
|
+
}),
|
|
901
|
+
),
|
|
841
902
|
};
|
|
842
903
|
const access = evaluateInboundAccess(this.#accessPolicy, {
|
|
843
904
|
conversationType: options.isDirect ? 'direct' : 'group',
|
|
@@ -1102,6 +1163,17 @@ export class DingtalkHarnessBridge {
|
|
|
1102
1163
|
|| this.#approvals.hasPending(key),
|
|
1103
1164
|
control: { owner: this, key },
|
|
1104
1165
|
deferredDelivery: this.#deferred,
|
|
1166
|
+
enhancement: captureContextEnhancementSource(
|
|
1167
|
+
this.#contextEnhancement,
|
|
1168
|
+
String(message.conversationType) === '1' ? 'direct' : 'group',
|
|
1169
|
+
() => ({
|
|
1170
|
+
channel: 'dingtalk',
|
|
1171
|
+
senderId: senderStaffId(message),
|
|
1172
|
+
senderName: message.senderNick,
|
|
1173
|
+
conversationTitle: message.conversationTitle,
|
|
1174
|
+
chatId: message.conversationId,
|
|
1175
|
+
}),
|
|
1176
|
+
),
|
|
1105
1177
|
},
|
|
1106
1178
|
);
|
|
1107
1179
|
if (result?.stopped) {
|
|
@@ -1261,8 +1333,12 @@ export class DingtalkHarnessBridge {
|
|
|
1261
1333
|
return;
|
|
1262
1334
|
}
|
|
1263
1335
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1336
|
+
const modelMessage = prepareDingtalkReplyAttachments(message, promptMessage, {
|
|
1337
|
+
api: this.#api, clientId: this.#clientId, clientSecret: this.#clientSecret,
|
|
1338
|
+
signal: this.#signal,
|
|
1339
|
+
});
|
|
1340
|
+
let content = hasInboundImages(modelMessage) || hasReply
|
|
1341
|
+
? await promptContentForInboundMessage(modelMessage, { signal: this.#signal })
|
|
1266
1342
|
: undefined;
|
|
1267
1343
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
1268
1344
|
let contextEnhanced = false;
|
|
@@ -1300,6 +1376,7 @@ export class DingtalkHarnessBridge {
|
|
|
1300
1376
|
text,
|
|
1301
1377
|
content,
|
|
1302
1378
|
titleText: batchSubmission?.title,
|
|
1379
|
+
sourceGuidance: snapshot?.config?.guidance,
|
|
1303
1380
|
contextEnhanced,
|
|
1304
1381
|
createOptions: { signal: this.#signal },
|
|
1305
1382
|
existsOptions: { signal: this.#signal },
|
|
@@ -1317,7 +1394,7 @@ export class DingtalkHarnessBridge {
|
|
|
1317
1394
|
requiresMention: String(message.conversationType) === '2',
|
|
1318
1395
|
}),
|
|
1319
1396
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
1320
|
-
files:
|
|
1397
|
+
files: modelMessage.files,
|
|
1321
1398
|
},
|
|
1322
1399
|
});
|
|
1323
1400
|
if (batchSubmission) {
|