@xmanrui/dsh-im 1.1.0 → 1.3.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.
Files changed (40) hide show
  1. package/README.en.md +5 -3
  2. package/README.md +5 -3
  3. package/lib/client.js +212 -0
  4. package/lib/index.js +166 -163
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/whatsapp/api.js +11 -0
  7. package/plugin-src/client/channels/whatsapp/index.js +125 -0
  8. package/plugin-src/client/channels/whatsapp/styles.js +25 -0
  9. package/plugin-src/client/i18n.js +18 -0
  10. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  11. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  12. package/plugin-src/host/channels/qq/production.mjs +3 -1
  13. package/plugin-src/host/channels/shared/production.mjs +3 -1
  14. package/plugin-src/host/channels/slack/production.mjs +3 -1
  15. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  16. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  17. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  18. package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
  19. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  20. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  22. package/src/channels/discord/discord-runtime.mjs +23 -0
  23. package/src/channels/feishu/bridge.mjs +18 -10
  24. package/src/channels/feishu/message-utils.mjs +47 -0
  25. package/src/channels/qq/qq-bridge.mjs +80 -28
  26. package/src/channels/shared/file-download.mjs +64 -0
  27. package/src/channels/shared/harness-client.mjs +45 -0
  28. package/src/channels/shared/inbound-file.mjs +206 -0
  29. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  30. package/src/channels/slack/slack-api.mjs +27 -4
  31. package/src/channels/slack/slack-runtime.mjs +55 -5
  32. package/src/channels/telegram/telegram-api.mjs +21 -6
  33. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  34. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  35. package/src/channels/weixin/weixin-api.mjs +45 -0
  36. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  37. package/src/channels/whatsapp/config-store.mjs +43 -0
  38. package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
  39. package/src/channels/whatsapp/whatsapp-runtime.mjs +83 -2
  40. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -9,6 +9,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
9
9
  cancelProvisioning: 'provision.cancel',
10
10
  reconnectBot: 'bot.reconnect',
11
11
  deleteBot: 'bot.delete',
12
+ setAccessPolicy: 'bot.access-policy.set',
12
13
  setWorkspace: 'bot.workspace.set',
13
14
  setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
14
15
  });
@@ -86,6 +87,16 @@ function normalizeBot(value) {
86
87
  state: connected ? 'connected' : state,
87
88
  workspace: text(value.workspace, '', 4_096),
88
89
  agentPreset: normalizeAgentPresetId(value.agentPreset),
90
+ accessPolicy: {
91
+ accessMode: ['self-only', 'private-allowlist', 'open'].includes(
92
+ value.accessPolicy?.accessMode,
93
+ ) ? value.accessPolicy.accessMode : 'self-only',
94
+ allowedNumbers: Array.isArray(value.accessPolicy?.allowedNumbers)
95
+ ? [...new Set(value.accessPolicy.allowedNumbers.filter((entry) => (
96
+ typeof entry === 'string' && /^[1-9]\d{4,14}$/.test(entry)
97
+ )))]
98
+ : [],
99
+ },
89
100
  bot: {
90
101
  name: text(value.bot?.name, 'WhatsApp机器人', 100),
91
102
  idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),
@@ -25,6 +25,119 @@ import { installWhatsappStyles } from './styles.js';
25
25
 
26
26
  const ACTIVE_STATES = new Set(['pending', 'connecting']);
27
27
 
28
+ function accessPolicyFor(account) {
29
+ const accessMode = ['self-only', 'private-allowlist', 'open'].includes(
30
+ account?.accessPolicy?.accessMode,
31
+ ) ? account.accessPolicy.accessMode : 'self-only';
32
+ return {
33
+ accessMode,
34
+ allowedNumbers: Array.isArray(account?.accessPolicy?.allowedNumbers)
35
+ ? account.accessPolicy.allowedNumbers : [],
36
+ };
37
+ }
38
+
39
+ function allowedNumbersFromText(value) {
40
+ const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
41
+ const normalized = entries.map((entry) => entry.replace(/^\+/, ''));
42
+ if (normalized.some((entry) => !/^[1-9]\d{4,14}$/.test(entry))) {
43
+ throw new TypeError('电话号码必须包含国家或地区代码,每行一个。');
44
+ }
45
+ return [...new Set(normalized)];
46
+ }
47
+
48
+ export function WhatsappAccessSettings({ account, busy = false, onSave }) {
49
+ const policy = accessPolicyFor(account);
50
+ const sourceNumbers = policy.allowedNumbers.join('\n');
51
+ const helpId = React.useId();
52
+ const [accessMode, setAccessMode] = React.useState(policy.accessMode);
53
+ const [allowedNumbers, setAllowedNumbers] = React.useState(sourceNumbers);
54
+ const [error, setError] = React.useState(null);
55
+
56
+ React.useEffect(() => {
57
+ setAccessMode(policy.accessMode);
58
+ setAllowedNumbers(sourceNumbers);
59
+ setError(null);
60
+ }, [policy.accessMode, sourceNumbers]);
61
+
62
+ const save = async (event) => {
63
+ event.preventDefault();
64
+ setError(null);
65
+ try {
66
+ const normalized = allowedNumbersFromText(allowedNumbers);
67
+ if (typeof onSave !== 'function') throw new Error('WhatsApp 访问设置暂不可用。');
68
+ await onSave({ accessMode, allowedNumbers: normalized });
69
+ } catch (caught) {
70
+ setError(caught?.message ?? 'WhatsApp 访问设置保存失败。');
71
+ }
72
+ };
73
+
74
+ const allowlistEnabled = accessMode === 'private-allowlist';
75
+ const labels = {
76
+ 'self-only': '仅自己模式',
77
+ 'private-allowlist': '指定联系人模式',
78
+ open: '开放响应模式',
79
+ };
80
+ return h('form', { className: 'dwa-access', onSubmit: save },
81
+ h('div', { className: 'dwa-accessHeading' },
82
+ h('strong', null, '访问设置'),
83
+ h('span', { className: 'dwa-accessStatus' },
84
+ h('span', { className: 'dwa-accessBadge', 'data-mode': policy.accessMode },
85
+ ['已生效:', labels[policy.accessMode]]),
86
+ h('span', { className: 'dwa-accessHelp' },
87
+ h('button', {
88
+ type: 'button',
89
+ className: 'dwa-accessHelpButton',
90
+ 'aria-label': '查看 WhatsApp 访问模式说明',
91
+ 'aria-describedby': helpId,
92
+ }, h('span', { 'aria-hidden': 'true' }, '?')),
93
+ h('span', { id: helpId, className: 'dwa-accessTooltip', role: 'tooltip' },
94
+ h('span', { className: 'dwa-accessTooltipItem' },
95
+ h('strong', null, '仅自己模式'),
96
+ h('span', null, '只响应已绑定 WhatsApp 账号的自聊消息。')),
97
+ h('span', { className: 'dwa-accessTooltipItem' },
98
+ h('strong', null, '指定联系人模式'),
99
+ h('span', null, '响应自聊和白名单联系人的私聊,忽略群聊。')),
100
+ h('span', { className: 'dwa-accessTooltipItem' },
101
+ h('strong', null, '开放响应模式'),
102
+ h('span', null, '响应所有私聊,以及群聊中的提及或回复。')))))),
103
+ h('label', { className: 'dwa-accessField' },
104
+ h('span', null, '模式'),
105
+ h('select', {
106
+ value: accessMode,
107
+ disabled: busy,
108
+ 'aria-label': 'WhatsApp 访问模式',
109
+ onChange: (event) => { setAccessMode(event.target.value); setError(null); },
110
+ },
111
+ h('option', { value: 'self-only' }, '仅自己模式(默认)'),
112
+ h('option', { value: 'private-allowlist' }, '指定联系人模式'),
113
+ h('option', { value: 'open' }, '开放响应模式'))),
114
+ allowlistEnabled
115
+ ? h('label', { className: 'dwa-accessField' },
116
+ h('span', null, '允许私聊的 WhatsApp 电话号码'),
117
+ h('textarea', {
118
+ value: allowedNumbers,
119
+ disabled: busy,
120
+ rows: 3,
121
+ placeholder: '每行一个含国家或地区代码的号码',
122
+ 'aria-label': '允许私聊的 WhatsApp 电话号码',
123
+ onChange: (event) => { setAllowedNumbers(event.target.value); setError(null); },
124
+ }),
125
+ h('small', null, '可以包含开头的 +,保存时会自动移除。'))
126
+ : null,
127
+ allowlistEnabled && allowedNumbers.trim() === ''
128
+ ? h('p', { className: 'dwa-accessWarning', role: 'status' },
129
+ '白名单为空;保存后将只接受自聊消息。')
130
+ : null,
131
+ error ? h('p', { className: 'dwa-accessError', role: 'alert' }, error) : null,
132
+ h('div', { className: 'dwa-accessActions' },
133
+ h('button', {
134
+ type: 'submit',
135
+ className: 'ddt-button',
136
+ 'data-kind': 'secondary',
137
+ disabled: busy,
138
+ }, busy ? '正在保存…' : '保存访问设置')));
139
+ }
140
+
28
141
  const Button = React.forwardRef(function Button(
29
142
  { children, kind = 'secondary', className = '', ...props },
30
143
  ref,
@@ -180,6 +293,7 @@ export function WhatsappAccountCard({
180
293
  onReconnect,
181
294
  onWorkspaceSave,
182
295
  onAgentPresetSave,
296
+ onAccessPolicySave,
183
297
  onRequestRemove,
184
298
  onConfirmRemove,
185
299
  onCancelRemove,
@@ -216,6 +330,11 @@ export function WhatsappAccountCard({
216
330
  disabled: Boolean(busy),
217
331
  onSave: onAgentPresetSave,
218
332
  }),
333
+ h(WhatsappAccessSettings, {
334
+ account,
335
+ busy: Boolean(busy),
336
+ onSave: onAccessPolicySave,
337
+ }),
219
338
  h('div', { className: 'ddt-accountFooter dim-cardFooter' },
220
339
  h('div', { className: 'dim-cardFooterLayout' },
221
340
  h('div', { className: 'ddt-actions dim-cardActions' },
@@ -469,6 +588,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
469
588
  WHATSAPP_ENDPOINTS.setAgentPreset,
470
589
  { botId: account.botId, agentPreset },
471
590
  ),
591
+ onAccessPolicySave: (accessPolicy) => botAction(
592
+ account,
593
+ 'access',
594
+ WHATSAPP_ENDPOINTS.setAccessPolicy,
595
+ { botId: account.botId, ...accessPolicy },
596
+ ),
472
597
  onRequestRemove: () => setRemoveTarget(account.botId),
473
598
  onCancelRemove: () => setRemoveTarget(null),
474
599
  onConfirmRemove: async () => {
@@ -4,6 +4,31 @@ const CSS = String.raw`
4
4
  .dwa-page { --ddt-accent: #25d366; --ddt-accent-deep: #128c7e; --ddt-accent-wash: #eafbf0; }
5
5
  .dwa-avatar { color: #fff; background: #25d366; }
6
6
  .dwa-avatar svg { display: block; }
7
+ .dwa-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); }
8
+ .dwa-accessHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
9
+ .dwa-accessHeading > strong { font-size: 13px; }
10
+ .dwa-accessStatus { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; }
11
+ .dwa-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #08785f; background: #eafbf0; font-size: 11px; font-weight: 700; }
12
+ .dwa-accessBadge[data-mode="private-allowlist"] { color: #0f6f8f; background: #eaf7fd; }
13
+ .dwa-accessBadge[data-mode="open"] { color: #a15c00; background: #fff3d6; }
14
+ .dwa-accessHelp { position: relative; display: inline-flex; flex: none; }
15
+ .dwa-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #25d366 34%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #128c7e; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; }
16
+ .dwa-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: 270px; max-width: min(290px, calc(100vw - 48px)); display: grid; gap: 8px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
17
+ .dwa-accessTooltipItem { display: grid; gap: 2px; }
18
+ .dwa-accessTooltipItem + .dwa-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); }
19
+ .dwa-accessTooltipItem strong { font-size: 12px; line-height: 17px; }
20
+ .dwa-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
21
+ .dwa-accessHelp:hover .dwa-accessTooltip, .dwa-accessHelp:focus-within .dwa-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
22
+ .dwa-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
23
+ .dwa-accessField select, .dwa-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; }
24
+ .dwa-accessField select { height: 34px; padding: 0 9px; }
25
+ .dwa-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
26
+ .dwa-accessField textarea:disabled { color: var(--dsw-alias-label-tertiary, #8f959e); background: var(--dsw-alias-bg-module-platform, #f2f3f5); cursor: not-allowed; resize: none; opacity: 1; }
27
+ .dwa-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
28
+ .dwa-accessWarning, .dwa-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
29
+ .dwa-accessWarning { color: #a15c00; }
30
+ .dwa-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
31
+ .dwa-accessActions { display: flex; justify-content: flex-end; }
7
32
  `;
8
33
 
9
34
  export function installWhatsappStyles() {
@@ -360,6 +360,24 @@ const EN = Object.freeze({
360
360
  '正在建立安全的关联设备会话。': 'Creating a secure linked-device session.',
361
361
  '关联设备正在接入 DeepSeek Harness。': 'Linking the device to DeepSeek Harness.',
362
362
  'WhatsApp Web 关联设备运行正常': 'WhatsApp linked device is healthy',
363
+ '查看 WhatsApp 访问模式说明': 'View WhatsApp access mode details',
364
+ 'WhatsApp 访问模式': 'WhatsApp access mode',
365
+ '仅自己模式': 'Only me',
366
+ '指定联系人模式': 'Selected contacts',
367
+ '开放响应模式': 'Open responses',
368
+ '仅自己模式(默认)': 'Only me (default)',
369
+ '已生效:': 'Active: ',
370
+ '只响应已绑定 WhatsApp 账号的自聊消息。': 'Only respond to self-chat messages from the linked WhatsApp account.',
371
+ '响应自聊和白名单联系人的私聊,忽略群聊。': 'Respond to self-chat and allowlisted direct messages; ignore group messages.',
372
+ '响应所有私聊,以及群聊中的提及或回复。': 'Respond to all direct messages and to group mentions or replies.',
373
+ '允许私聊的 WhatsApp 电话号码': 'WhatsApp phone numbers allowed to send direct messages',
374
+ '每行一个含国家或地区代码的号码': 'One number with country or region code per line',
375
+ '可以包含开头的 +,保存时会自动移除。': 'A leading + is allowed and removed when saved.',
376
+ '仅指定联系人模式使用白名单,切换模式时会保留。': 'Only Selected contacts uses the allowlist; it is retained when modes change.',
377
+ '白名单为空;保存后将只接受自聊消息。': 'The allowlist is empty; only self-chat messages will be accepted after saving.',
378
+ '电话号码必须包含国家或地区代码,每行一个。': 'Each phone number must include a country or region code on its own line.',
379
+ 'WhatsApp 访问设置暂不可用。': 'WhatsApp access settings are currently unavailable.',
380
+ 'WhatsApp 访问设置保存失败。': 'Could not save WhatsApp access settings.',
363
381
  'Bot API 长轮询': 'Bot API long polling',
364
382
  ' Gateway 长连接': ' Gateway persistent connection',
365
383
  'Gateway 长连接': 'Gateway persistent connection',
@@ -86,9 +86,10 @@ export async function createProductionController(ctx, config = {}, internals = {
86
86
  return state;
87
87
  };
88
88
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
89
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
89
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
90
90
  controlExecutor: internals.controlExecutor,
91
91
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
92
+ fileIngressExecutor: internals.fileIngressExecutor,
92
93
  });
93
94
  const harness = new Harness({
94
95
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -98,6 +99,7 @@ export async function createProductionController(ctx, config = {}, internals = {
98
99
  ...(commandExecutor ? { commandExecutor } : {}),
99
100
  ...(controlExecutor ? { controlExecutor } : {}),
100
101
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
102
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
101
103
  });
102
104
  const coreController = new Controller({
103
105
  deviceAuth,
@@ -133,9 +133,10 @@ export async function createProductionController(ctx, config = {}, internals = {
133
133
  return stateFor(botConfig);
134
134
  };
135
135
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
136
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
136
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
137
137
  controlExecutor: internals.controlExecutor,
138
138
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
139
+ fileIngressExecutor: internals.fileIngressExecutor,
139
140
  });
140
141
  const harness = new Harness({
141
142
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -147,6 +148,7 @@ export async function createProductionController(ctx, config = {}, internals = {
147
148
  ...(commandExecutor ? { commandExecutor } : {}),
148
149
  ...(controlExecutor ? { controlExecutor } : {}),
149
150
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
151
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
150
152
  });
151
153
  const proxyEnv = internals.proxyEnv ?? process.env;
152
154
  const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent);
@@ -77,9 +77,10 @@ export async function createProductionController(ctx, config = {}, internals = {
77
77
  return state;
78
78
  };
79
79
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
80
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
80
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
81
81
  controlExecutor: internals.controlExecutor,
82
82
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
83
+ fileIngressExecutor: internals.fileIngressExecutor,
83
84
  });
84
85
  const harness = new Harness({
85
86
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -89,6 +90,7 @@ export async function createProductionController(ctx, config = {}, internals = {
89
90
  ...(commandExecutor ? { commandExecutor } : {}),
90
91
  ...(controlExecutor ? { controlExecutor } : {}),
91
92
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
93
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
92
94
  });
93
95
  const coreController = new Controller({
94
96
  qrAuth,
@@ -78,9 +78,10 @@ export async function createTokenProductionController(ctx, config, internals, de
78
78
  return state;
79
79
  };
80
80
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
81
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
81
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
82
82
  controlExecutor: internals.controlExecutor,
83
83
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
84
+ fileIngressExecutor: internals.fileIngressExecutor,
84
85
  });
85
86
  const harness = new ResolvedHarness({
86
87
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -90,6 +91,7 @@ export async function createTokenProductionController(ctx, config, internals, de
90
91
  ...(commandExecutor ? { commandExecutor } : {}),
91
92
  ...(controlExecutor ? { controlExecutor } : {}),
92
93
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
94
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
93
95
  });
94
96
  const coreController = new ResolvedController({
95
97
  credentials: ctx.credentials,
@@ -56,9 +56,10 @@ export async function createProductionController(ctx, config = {}, internals = {
56
56
  return state;
57
57
  };
58
58
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
59
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
59
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
60
60
  controlExecutor: internals.controlExecutor,
61
61
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
62
+ fileIngressExecutor: internals.fileIngressExecutor,
62
63
  });
63
64
  const harness = new ResolvedHarness({
64
65
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -68,6 +69,7 @@ export async function createProductionController(ctx, config = {}, internals = {
68
69
  ...(commandExecutor ? { commandExecutor } : {}),
69
70
  ...(controlExecutor ? { controlExecutor } : {}),
70
71
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
72
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
71
73
  });
72
74
  const coreController = new ResolvedController({
73
75
  credentials: ctx.credentials,
@@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
80
80
  return state;
81
81
  };
82
82
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
83
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
83
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
84
84
  controlExecutor: internals.controlExecutor,
85
85
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
86
+ fileIngressExecutor: internals.fileIngressExecutor,
86
87
  });
87
88
  const harness = new Harness({
88
89
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
92
93
  ...(commandExecutor ? { commandExecutor } : {}),
93
94
  ...(controlExecutor ? { controlExecutor } : {}),
94
95
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
96
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
95
97
  });
96
98
  const coreController = new Controller({
97
99
  qrAuth,
@@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
80
80
  return state;
81
81
  };
82
82
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
83
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
83
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
84
84
  controlExecutor: internals.controlExecutor,
85
85
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
86
+ fileIngressExecutor: internals.fileIngressExecutor,
86
87
  });
87
88
  const harness = new Harness({
88
89
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
92
93
  ...(commandExecutor ? { commandExecutor } : {}),
93
94
  ...(controlExecutor ? { controlExecutor } : {}),
94
95
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
96
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
95
97
  });
96
98
  const coreController = new Controller({
97
99
  api,
@@ -83,9 +83,10 @@ export async function createProductionController(ctx, config = {}, internals = {
83
83
  return state;
84
84
  };
85
85
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
86
- const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
86
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
87
87
  controlExecutor: internals.controlExecutor,
88
88
  sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
89
+ fileIngressExecutor: internals.fileIngressExecutor,
89
90
  });
90
91
  const harness = new Harness({
91
92
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@@ -95,6 +96,7 @@ export async function createProductionController(ctx, config = {}, internals = {
95
96
  ...(commandExecutor ? { commandExecutor } : {}),
96
97
  ...(controlExecutor ? { controlExecutor } : {}),
97
98
  ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
99
+ ...(fileIngressExecutor ? { fileIngressExecutor } : {}),
98
100
  });
99
101
  const coreController = new Controller({
100
102
  configStore: observedConfigStore,
@@ -1,6 +1,7 @@
1
1
  import QRCode from 'qrcode';
2
2
 
3
3
  import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
4
+ import { normalizeWhatsappAccessPolicy } from '../../../../src/channels/whatsapp/config-store.mjs';
4
5
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
5
6
  import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
6
7
  import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
@@ -13,6 +14,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
13
14
  cancelProvisioning: 'provision.cancel',
14
15
  reconnectBot: 'bot.reconnect',
15
16
  deleteBot: 'bot.delete',
17
+ setAccessPolicy: 'bot.access-policy.set',
16
18
  setWorkspace: SET_WORKSPACE_ENDPOINT,
17
19
  setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
18
20
  });
@@ -50,6 +52,17 @@ function payloadFailure(endpoint, payload) {
50
52
  return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
51
53
  && payload.confirm === true ? null : 'bot.delete requires a botId and confirm=true.';
52
54
  }
55
+ if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
56
+ if (!exactKeys(payload, ['botId', 'accessMode', 'allowedNumbers'])
57
+ || Object.keys(payload).length !== 3
58
+ || !validId(payload.botId)) return '请输入有效的 WhatsApp 访问模式和电话号码。';
59
+ try {
60
+ normalizeWhatsappAccessPolicy(payload);
61
+ return null;
62
+ } catch {
63
+ return '请输入有效的 WhatsApp 访问模式和电话号码。';
64
+ }
65
+ }
53
66
  if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
54
67
  return validWorkspacePayload(payload)
55
68
  ? null : '请输入工作区绝对路径。';
@@ -92,7 +105,7 @@ async function publicStatus(value, encodeQr) {
92
105
  }
93
106
 
94
107
  export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
95
- for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
108
+ for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot', 'setAccessPolicy']) {
96
109
  if (typeof controller?.[method] !== 'function') {
97
110
  throw new TypeError(`A complete WhatsApp controller is required (${method})`);
98
111
  }
@@ -166,6 +179,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
166
179
  await controller.updateAgentPreset(payload.botId, payload.agentPreset),
167
180
  cachedEncode,
168
181
  );
182
+ } else if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
183
+ value = await publicStatus(
184
+ await controller.setAccessPolicy(payload.botId, normalizeWhatsappAccessPolicy(payload)),
185
+ cachedEncode,
186
+ );
169
187
  } else {
170
188
  value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
171
189
  }
@@ -1,5 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import {
4
+ InboundFileError,
5
+ stageInboundFiles,
6
+ } from '../../src/channels/shared/inbound-file.mjs';
7
+
3
8
  function agentsFromContext(ctx) {
4
9
  if (typeof ctx?.get !== 'function') return undefined;
5
10
  let agents;
@@ -108,13 +113,30 @@ function createSessionMaintenanceExecutor(agents) {
108
113
  };
109
114
  }
110
115
 
116
+ function createFileIngressExecutor(agents) {
117
+ return ({ sessionId, workspace, files, signal }) => {
118
+ const agent = agents.get(sessionId);
119
+ const attachedWorkspace = agent?.session?.header?.cwd;
120
+ const exactWorkspace = typeof attachedWorkspace === 'string' && attachedWorkspace
121
+ ? attachedWorkspace
122
+ : workspace;
123
+ if (typeof exactWorkspace !== 'string' || !exactWorkspace) {
124
+ throw new InboundFileError(
125
+ 'inbound-file-workspace-unavailable',
126
+ 'The Harness Session workspace is unavailable for inbound files.',
127
+ );
128
+ }
129
+ return stageInboundFiles({ files }, { workspace: exactWorkspace, signal });
130
+ };
131
+ }
132
+
111
133
  /**
112
- * Build optional same-process Session executors without adding a hard Cordis
113
- * service injection. Fixtures and deployments without AgentRegistry preserve
114
- * the existing HTTP behavior.
134
+ * Build optional same-process Session executors. File ingress also works for
135
+ * cold Sessions by using the authoritative cwd returned by session.list;
136
+ * when an Agent is attached, its live Session header remains authoritative.
115
137
  */
116
138
  export function createHarnessSessionExecutors(ctx, provided = {}) {
117
- const { controlExecutor, sessionMaintenanceExecutor } = provided;
139
+ const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = provided;
118
140
  if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
119
141
  throw new TypeError('controlExecutor must be a function');
120
142
  }
@@ -122,13 +144,18 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
122
144
  && typeof sessionMaintenanceExecutor !== 'function') {
123
145
  throw new TypeError('sessionMaintenanceExecutor must be a function');
124
146
  }
147
+ if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
148
+ throw new TypeError('fileIngressExecutor must be a function');
149
+ }
125
150
 
126
- const agents = controlExecutor && sessionMaintenanceExecutor
151
+ const agents = controlExecutor && sessionMaintenanceExecutor && fileIngressExecutor
127
152
  ? undefined
128
153
  : agentsFromContext(ctx);
129
154
  return {
130
155
  controlExecutor: controlExecutor ?? (agents ? createControlExecutor(agents) : undefined),
131
156
  sessionMaintenanceExecutor: sessionMaintenanceExecutor
132
157
  ?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
158
+ fileIngressExecutor: fileIngressExecutor
159
+ ?? createFileIngressExecutor(agents ?? { get: () => undefined }),
133
160
  };
134
161
  }