@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.
Files changed (30) hide show
  1. package/README.en.md +10 -0
  2. package/README.md +10 -0
  3. package/lib/client.js +46 -9
  4. package/lib/index.js +281 -280
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/feishu/index.js +22 -7
  7. package/plugin-src/client/credential-binding.js +2 -0
  8. package/plugin-src/client/i18n.js +6 -0
  9. package/plugin-src/host/channels/feishu/rpc.mjs +2 -1
  10. package/plugin-src/host/index.mjs +7 -0
  11. package/plugin-src/host/injected-context.mjs +104 -0
  12. package/scripts/verify-lan-management.mjs +53 -8
  13. package/src/channels/dingtalk/dingtalk-bridge.mjs +104 -27
  14. package/src/channels/feishu/bridge.mjs +35 -2
  15. package/src/channels/qq/qq-bridge.mjs +27 -2
  16. package/src/channels/shared/context-enhancement.mjs +40 -3
  17. package/src/channels/shared/control-command.mjs +8 -1
  18. package/src/channels/shared/harness-client.mjs +7 -0
  19. package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
  20. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  21. package/src/channels/shared/im-source-guidance.mjs +65 -0
  22. package/src/channels/shared/injected-context.mjs +362 -0
  23. package/src/channels/shared/semantic/artifact.mjs +3 -3
  24. package/src/channels/shared/semantic/reply-reference.mjs +2 -1
  25. package/src/channels/shared/text-harness-bridge.mjs +23 -2
  26. package/src/channels/shared/workspace-session.mjs +9 -0
  27. package/src/channels/wecom/wecom-bridge.mjs +11 -1
  28. package/src/channels/wecom-app/wecom-app-bridge.mjs +11 -1
  29. package/src/channels/weixin/weixin-api.mjs +52 -13
  30. package/src/channels/weixin/weixin-bridge.mjs +13 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.21.0",
3
+ "version": "4.21.2",
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",
@@ -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
- channel: "飞书",
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', lanIp], {
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
- for (const channel of ['feishu', 'weixin', 'dingtalk', 'wecom', 'wecom-app', 'qq',
150
- 'slack', 'telegram', 'discord', 'whatsapp', 'imessage', 'office']) {
151
- expectStatus(`LAN default: ${channel}`, await rpc(lan, channel), 200, true);
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
- expectStatus('Loopback default: feishu', await rpc(local), 200, true);
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 localhost', await rpc(await login(restrictedUrl, '127.0.0.1')), 200, true);
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 Host/Origin ${lanIp}.`);
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 { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
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 dingtalkReplyReference(message, options) {
210
- const replyEnvelope = message?.text;
211
- if (replyEnvelope?.isReplyMsg !== true) return null;
212
- const replied = replyEnvelope?.repliedMsg;
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
- const pseudoMessage = {
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: repliedContent,
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 = richTextEntries(repliedContent)
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
- let content = hasImages || hasReply
1265
- ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
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: promptMessage.files,
1397
+ files: modelMessage.files,
1321
1398
  },
1322
1399
  });
1323
1400
  if (batchSubmission) {