@xmanrui/dsh-im 4.3.0 → 4.4.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 (68) hide show
  1. package/lib/client.js +951 -626
  2. package/lib/index.js +233 -233
  3. package/package.json +1 -1
  4. package/plugin-src/client/access-policy-settings.js +351 -0
  5. package/plugin-src/client/channel-card-meta.js +2 -1
  6. package/plugin-src/client/channels/dingtalk/api.js +5 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +1 -0
  8. package/plugin-src/client/channels/feishu/api.js +5 -0
  9. package/plugin-src/client/channels/feishu/index.js +1 -0
  10. package/plugin-src/client/channels/qq/api.js +5 -0
  11. package/plugin-src/client/channels/qq/index.js +1 -0
  12. package/plugin-src/client/channels/shared/token-api.js +5 -0
  13. package/plugin-src/client/channels/shared/token-channel.js +1 -0
  14. package/plugin-src/client/channels/telegram/api.js +2 -17
  15. package/plugin-src/client/channels/telegram/index.js +0 -112
  16. package/plugin-src/client/channels/telegram/styles.js +0 -28
  17. package/plugin-src/client/channels/wecom/api.js +5 -0
  18. package/plugin-src/client/channels/wecom/index.js +1 -0
  19. package/plugin-src/client/channels/weixin/api.js +5 -0
  20. package/plugin-src/client/channels/weixin/index.js +1 -0
  21. package/plugin-src/client/channels/whatsapp/api.js +4 -10
  22. package/plugin-src/client/channels/whatsapp/index.js +1 -125
  23. package/plugin-src/client/channels/whatsapp/styles.js +0 -25
  24. package/plugin-src/client/delivery-settings.js +61 -9
  25. package/plugin-src/client/i18n.js +56 -1
  26. package/plugin-src/client/index.js +1 -0
  27. package/plugin-src/client/styles.js +50 -4
  28. package/plugin-src/host/channels/dingtalk/production.mjs +12 -1
  29. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  30. package/plugin-src/host/channels/feishu/production.mjs +39 -1
  31. package/plugin-src/host/channels/feishu/rpc.mjs +19 -2
  32. package/plugin-src/host/channels/qq/production.mjs +12 -1
  33. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  34. package/plugin-src/host/channels/shared/access-policy-production.mjs +137 -0
  35. package/plugin-src/host/channels/shared/access-policy-rpc.mjs +17 -0
  36. package/plugin-src/host/channels/shared/production.mjs +15 -1
  37. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  38. package/plugin-src/host/channels/slack/production.mjs +12 -1
  39. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  40. package/plugin-src/host/channels/telegram/rpc.mjs +2 -49
  41. package/plugin-src/host/channels/wecom/production.mjs +12 -1
  42. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  43. package/plugin-src/host/channels/weixin/production.mjs +12 -1
  44. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  45. package/plugin-src/host/channels/whatsapp/production.mjs +16 -2
  46. package/plugin-src/host/channels/whatsapp/rpc.mjs +10 -15
  47. package/src/channels/dingtalk/dingtalk-bridge.mjs +61 -9
  48. package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -0
  49. package/src/channels/discord/discord-runtime.mjs +29 -1
  50. package/src/channels/feishu/bridge.mjs +188 -80
  51. package/src/channels/feishu/feishu-runtime.mjs +4 -0
  52. package/src/channels/qq/qq-bridge.mjs +59 -17
  53. package/src/channels/qq/qq-runtime.mjs +20 -2
  54. package/src/channels/shared/access-policy.mjs +210 -0
  55. package/src/channels/shared/bot-workspace-store.mjs +176 -33
  56. package/src/channels/shared/command-permission.mjs +32 -0
  57. package/src/channels/shared/compact-command.mjs +5 -2
  58. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  59. package/src/channels/shared/inbound-access.mjs +43 -0
  60. package/src/channels/shared/text-harness-bridge.mjs +47 -4
  61. package/src/channels/shared/workspace-command.mjs +10 -1
  62. package/src/channels/slack/slack-runtime.mjs +4 -0
  63. package/src/channels/telegram/telegram-runtime.mjs +5 -13
  64. package/src/channels/wecom/wecom-bridge.mjs +51 -4
  65. package/src/channels/wecom/wecom-runtime.mjs +4 -0
  66. package/src/channels/weixin/weixin-bridge.mjs +59 -16
  67. package/src/channels/weixin/weixin-runtime.mjs +4 -0
  68. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -20
@@ -13,6 +13,10 @@ import {
13
13
  normalizeAgentPresetCatalog,
14
14
  validateAgentPresetId,
15
15
  } from './agent-preset.mjs';
16
+ import {
17
+ normalizeAccessPolicy,
18
+ validateAccessPolicy,
19
+ } from './access-policy.mjs';
16
20
  import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
17
21
  import {
18
22
  DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
@@ -131,6 +135,26 @@ function normalizeDeliveryTargets(value) {
131
135
  return deliveryTargets;
132
136
  }
133
137
 
138
+ function normalizeAccessPolicies(value, workspaces) {
139
+ const accessPolicies = Object.create(null);
140
+ if (value === undefined) return accessPolicies;
141
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
142
+ // Preserve the distinction between a missing policy (eligible for startup
143
+ // initialization) and damaged persisted data (fail closed).
144
+ for (const botId of Object.keys(workspaces)) accessPolicies[botId] = null;
145
+ return accessPolicies;
146
+ }
147
+ for (const [botId, policy] of Object.entries(value)) {
148
+ try {
149
+ botIdOf(botId);
150
+ accessPolicies[botId] = normalizeAccessPolicy(policy);
151
+ } catch {
152
+ // An invalid key cannot identify a bot, so it is isolated and ignored.
153
+ }
154
+ }
155
+ return accessPolicies;
156
+ }
157
+
134
158
  function normalizeDocument(value) {
135
159
  if (!value || ![1, 2].includes(value.version) || !value.workspaces
136
160
  || typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
@@ -168,15 +192,52 @@ function normalizeDocument(value) {
168
192
  if (value.version === 1 && value.deliveryTargets !== undefined) return null;
169
193
  const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
170
194
  if (!deliveryTargets) return null;
195
+ const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
196
+ const version = value.accessPolicies === undefined ? value.version : 2;
171
197
  return {
172
- version: value.version,
198
+ // A v1 file cannot be emitted with this optional v2 section. If one is
199
+ // recovered from an interrupted/manual edit, retain it on the next write.
200
+ version,
173
201
  workspaces,
174
202
  agentPresets,
175
203
  contextEnhancement,
176
204
  deliveryTargets,
205
+ accessPolicies,
177
206
  };
178
207
  }
179
208
 
209
+ function storedDocument({
210
+ version,
211
+ workspaces,
212
+ agentPresets,
213
+ contextEnhancement,
214
+ deliveryTargets,
215
+ accessPolicies,
216
+ }) {
217
+ const document = { version, workspaces };
218
+ if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
219
+ if (Object.keys(contextEnhancement).length > 0) {
220
+ document.contextEnhancement = contextEnhancement;
221
+ }
222
+ if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
223
+ document.deliveryTargets = deliveryTargets;
224
+ }
225
+ if (version >= 2 && Object.keys(accessPolicies).length > 0) {
226
+ document.accessPolicies = accessPolicies;
227
+ }
228
+ return document;
229
+ }
230
+
231
+ async function writeStoredDocument(path, document) {
232
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
233
+ const temporary = `${path}.tmp`;
234
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
235
+ encoding: 'utf8',
236
+ mode: 0o600,
237
+ });
238
+ await rename(temporary, path);
239
+ }
240
+
180
241
  export async function validateWorkspacePath(value) {
181
242
  if (typeof value !== 'string' || !value.trim() || !isAbsolute(value.trim())) {
182
243
  const error = new Error('工作区必须是绝对路径。');
@@ -208,6 +269,7 @@ export class BotWorkspaceStore {
208
269
  #agentPresets = {};
209
270
  #contextEnhancement = {};
210
271
  #deliveryTargets = Object.create(null);
272
+ #accessPolicies = Object.create(null);
211
273
  #generations = new Map();
212
274
  #nextGeneration = 1;
213
275
  #incarnations = new Map();
@@ -233,6 +295,7 @@ export class BotWorkspaceStore {
233
295
  this.#agentPresets = normalized.agentPresets;
234
296
  this.#contextEnhancement = normalized.contextEnhancement;
235
297
  this.#deliveryTargets = normalized.deliveryTargets;
298
+ this.#accessPolicies = normalized.accessPolicies;
236
299
  } catch (error) {
237
300
  if (error?.code !== 'ENOENT') throw error;
238
301
  this.#version = 1;
@@ -240,6 +303,7 @@ export class BotWorkspaceStore {
240
303
  this.#agentPresets = {};
241
304
  this.#contextEnhancement = {};
242
305
  this.#deliveryTargets = Object.create(null);
306
+ this.#accessPolicies = Object.create(null);
243
307
  }
244
308
  this.#generations.clear();
245
309
  this.#nextGeneration = 1;
@@ -278,6 +342,13 @@ export class BotWorkspaceStore {
278
342
  : DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
279
343
  }
280
344
 
345
+ accessPolicyFor(botId) {
346
+ const id = botIdOf(botId);
347
+ return this.has(id) && Object.hasOwn(this.#accessPolicies, id)
348
+ ? this.#accessPolicies[id]
349
+ : null;
350
+ }
351
+
281
352
  listDeliveryTargets(botId) {
282
353
  const id = botIdOf(botId);
283
354
  if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
@@ -373,28 +444,53 @@ export class BotWorkspaceStore {
373
444
  }
374
445
  }
375
446
 
376
- async ensure(botId, { workspace = this.#defaultWorkspace, defaultAgentPreset } = {}) {
447
+ async ensure(botId, {
448
+ workspace = this.#defaultWorkspace,
449
+ defaultAgentPreset,
450
+ initialAccessPolicy,
451
+ } = {}) {
377
452
  const id = botIdOf(botId);
378
453
  const initialWorkspace = resolve(workspace);
379
454
  return this.#enqueue(id, async () => {
380
- if (!this.#workspaces[id]) {
381
- const agentPreset = validateAgentPresetId(defaultAgentPreset);
455
+ const createsBot = !this.#workspaces[id];
456
+ const initializesAccessPolicy = initialAccessPolicy !== undefined
457
+ && !Object.hasOwn(this.#accessPolicies, id);
458
+ if (createsBot || initializesAccessPolicy) {
459
+ const accessPolicy = initializesAccessPolicy
460
+ ? validateAccessPolicy(initialAccessPolicy)
461
+ : undefined;
462
+ const agentPreset = createsBot ? validateAgentPresetId(defaultAgentPreset) : null;
382
463
  const hadAgentPreset = Object.hasOwn(this.#agentPresets, id);
383
464
  const previousAgentPreset = this.#agentPresets[id];
384
- this.#workspaces[id] = initialWorkspace;
385
- if (agentPreset) this.#agentPresets[id] = agentPreset;
386
- this.#generations.set(id, this.#freshGeneration());
387
- this.#incarnations.set(id, this.#freshIncarnation());
465
+ const nextAccessPolicies = initializesAccessPolicy
466
+ ? { ...this.#accessPolicies, [id]: accessPolicy }
467
+ : this.#accessPolicies;
468
+ if (createsBot) {
469
+ this.#workspaces[id] = initialWorkspace;
470
+ if (agentPreset) this.#agentPresets[id] = agentPreset;
471
+ this.#generations.set(id, this.#freshGeneration());
472
+ this.#incarnations.set(id, this.#freshIncarnation());
473
+ }
474
+ const nextVersion = initializesAccessPolicy ? 2 : this.#version;
388
475
  try {
389
- await this.#persist();
476
+ await this.#persist(
477
+ this.#contextEnhancement,
478
+ this.#deliveryTargets,
479
+ nextVersion,
480
+ nextAccessPolicies,
481
+ );
390
482
  } catch (error) {
391
- delete this.#workspaces[id];
392
- if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
393
- else delete this.#agentPresets[id];
394
- this.#generations.delete(id);
395
- this.#incarnations.delete(id);
483
+ if (createsBot) {
484
+ delete this.#workspaces[id];
485
+ if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
486
+ else delete this.#agentPresets[id];
487
+ this.#generations.delete(id);
488
+ this.#incarnations.delete(id);
489
+ }
396
490
  throw error;
397
491
  }
492
+ this.#accessPolicies = nextAccessPolicies;
493
+ this.#version = nextVersion;
398
494
  } else if (!this.#generations.has(id)) {
399
495
  this.#generations.set(id, this.#freshGeneration());
400
496
  }
@@ -487,6 +583,30 @@ export class BotWorkspaceStore {
487
583
  });
488
584
  }
489
585
 
586
+ async setAccessPolicy(botId, value, { incarnation } = {}) {
587
+ const id = botIdOf(botId);
588
+ const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
589
+ const policy = validateAccessPolicy(value);
590
+ return this.#enqueue(id, async () => {
591
+ if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
592
+ const error = new Error('找不到要修改的机器人。');
593
+ error.code = 'workspace-bot-not-found';
594
+ throw error;
595
+ }
596
+ const next = { ...this.#accessPolicies, [id]: policy };
597
+ // Inbound messages keep the previous committed snapshot until rename succeeds.
598
+ await this.#persist(
599
+ this.#contextEnhancement,
600
+ this.#deliveryTargets,
601
+ 2,
602
+ next,
603
+ );
604
+ this.#accessPolicies = next;
605
+ this.#version = 2;
606
+ return policy;
607
+ });
608
+ }
609
+
490
610
  async bindWorkspaceSession(botId, value, {
491
611
  conversationKey,
492
612
  sessionId,
@@ -646,6 +766,7 @@ export class BotWorkspaceStore {
646
766
  ...Object.keys(this.#agentPresets),
647
767
  ...Object.keys(this.#contextEnhancement),
648
768
  ...Object.keys(this.#deliveryTargets),
769
+ ...Object.keys(this.#accessPolicies),
649
770
  ...this.#dirtyRemovals,
650
771
  ]);
651
772
  for (const botId of candidates) {
@@ -663,6 +784,7 @@ export class BotWorkspaceStore {
663
784
  workspace: this.workspaceFor(bot.botId),
664
785
  agentPreset: this.agentPresetFor(bot.botId),
665
786
  contextEnhancement: this.contextEnhancementFor(bot.botId),
787
+ accessPolicy: this.accessPolicyFor(bot.botId),
666
788
  }
667
789
  : bot),
668
790
  };
@@ -694,12 +816,14 @@ export class BotWorkspaceStore {
694
816
  const hadPreset = Object.hasOwn(this.#agentPresets, id);
695
817
  const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
696
818
  const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
819
+ const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
697
820
  const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement
698
- || hadDeliveryTargets || this.#dirtyRemovals.has(id);
821
+ || hadDeliveryTargets || hadAccessPolicy || this.#dirtyRemovals.has(id);
699
822
  delete this.#workspaces[id];
700
823
  delete this.#agentPresets[id];
701
824
  delete this.#contextEnhancement[id];
702
825
  delete this.#deliveryTargets[id];
826
+ delete this.#accessPolicies[id];
703
827
  this.#generations.delete(id);
704
828
  this.#incarnations.delete(id);
705
829
  if (!needsCleanup) return {
@@ -733,24 +857,16 @@ export class BotWorkspaceStore {
733
857
  contextEnhancement = this.#contextEnhancement,
734
858
  deliveryTargets = this.#deliveryTargets,
735
859
  version = this.#version,
860
+ accessPolicies = this.#accessPolicies,
736
861
  ) {
737
- const document = { version, workspaces: this.#workspaces };
738
- if (Object.keys(this.#agentPresets).length > 0) {
739
- document.agentPresets = this.#agentPresets;
740
- }
741
- if (Object.keys(contextEnhancement).length > 0) {
742
- document.contextEnhancement = contextEnhancement;
743
- }
744
- if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
745
- document.deliveryTargets = deliveryTargets;
746
- }
747
- await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
748
- const temporary = `${this.#path}.tmp`;
749
- await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
750
- encoding: 'utf8',
751
- mode: 0o600,
752
- });
753
- await rename(temporary, this.#path);
862
+ await writeStoredDocument(this.#path, storedDocument({
863
+ version,
864
+ workspaces: this.#workspaces,
865
+ agentPresets: this.#agentPresets,
866
+ contextEnhancement,
867
+ deliveryTargets,
868
+ accessPolicies,
869
+ }));
754
870
  this.#dirtyRemovals.clear();
755
871
  }
756
872
 
@@ -758,7 +874,8 @@ export class BotWorkspaceStore {
758
874
  if (Object.keys(this.#workspaces).length > 0
759
875
  || Object.keys(this.#agentPresets).length > 0
760
876
  || Object.keys(this.#contextEnhancement).length > 0
761
- || Object.keys(this.#deliveryTargets).length > 0) {
877
+ || Object.keys(this.#deliveryTargets).length > 0
878
+ || Object.keys(this.#accessPolicies).length > 0) {
762
879
  await this.#persist();
763
880
  return;
764
881
  }
@@ -1280,6 +1397,31 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
1280
1397
  return result;
1281
1398
  });
1282
1399
  };
1400
+ const updateAccessPolicy = (botId, value, projectStatus) => {
1401
+ const incarnation = workspaces.incarnationFor(botId);
1402
+ const policy = validateAccessPolicy(value);
1403
+ return withBotTransition(botId, async () => {
1404
+ const snapshot = await controller.status();
1405
+ if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
1406
+ const error = new Error('找不到要修改的机器人。');
1407
+ error.code = 'workspace-bot-not-found';
1408
+ throw error;
1409
+ }
1410
+ const catalog = await resolveAgentPresetCatalog(agentPresetCatalog);
1411
+ const decorated = workspaces.decorateStatus(snapshot);
1412
+ const updated = {
1413
+ ...decorated,
1414
+ bots: decorated.bots.map((bot) => bot?.botId === botId
1415
+ ? { ...bot, accessPolicy: policy } : bot),
1416
+ ...(catalog ? { agentPresetCatalog: catalog } : {}),
1417
+ };
1418
+ // Prepare the complete channel-specific response before commit. Failed
1419
+ // projections and disk writes must leave the live policy unchanged.
1420
+ const result = projectStatus ? await projectStatus(updated) : updated;
1421
+ await workspaces.setAccessPolicy(botId, policy, { incarnation });
1422
+ return result;
1423
+ });
1424
+ };
1283
1425
  const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
1284
1426
  // Fence the old runtime without changing the durable mapping. A crash
1285
1427
  // before the controller removes its config therefore keeps the bot's
@@ -1320,6 +1462,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
1320
1462
  if (property === 'updateWorkspace') return updateWorkspace;
1321
1463
  if (property === 'updateAgentPreset') return updateAgentPreset;
1322
1464
  if (property === 'updateContextEnhancement') return updateContextEnhancement;
1465
+ if (property === 'updateAccessPolicy') return updateAccessPolicy;
1323
1466
  const value = Reflect.get(target, property, target);
1324
1467
  if (typeof value !== 'function') return value;
1325
1468
  if (property === 'deleteBot') {
@@ -0,0 +1,32 @@
1
+ import { isBatchInputCommand } from './batch-input.mjs';
2
+ import { isCompactCommand } from './compact-command.mjs';
3
+ import { isControlCommand } from './control-command.mjs';
4
+ import { isHistoryCommand } from './history-command.mjs';
5
+ import { isModelCommand } from './model-command.mjs';
6
+ import { isPresetCommand } from './preset-command.mjs';
7
+ import { isWorkspaceCommand } from './workspace-command.mjs';
8
+
9
+ const SIMPLE_TEXT_COMMANDS = new Set(['/help', '/status', '/new']);
10
+
11
+ /**
12
+ * Match only commands that the shared bridges already execute locally.
13
+ * Unknown slash-prefixed text remains an ordinary prompt.
14
+ */
15
+ export function isSharedLocalCommand(text, {
16
+ hasImages = false,
17
+ hasFiles = false,
18
+ } = {}) {
19
+ if (typeof text !== 'string') return false;
20
+ const command = text.trim();
21
+ if (!command) return false;
22
+ if (isBatchInputCommand(command) || isHistoryCommand(command)) return true;
23
+ if (!hasFiles && (
24
+ isControlCommand(command)
25
+ || isModelCommand(command)
26
+ || isPresetCommand(command)
27
+ )) return true;
28
+ if (hasImages || hasFiles) return false;
29
+ return SIMPLE_TEXT_COMMANDS.has(command.toLowerCase())
30
+ || isWorkspaceCommand(command)
31
+ || isCompactCommand(command);
32
+ }
@@ -70,9 +70,8 @@ function compactErrorMessage(error) {
70
70
  * Unknown input returns null so the caller may continue ordinary message routing.
71
71
  */
72
72
  export async function runCompactCommand(text, harness, state, conversationKey, options = {}) {
73
- if (typeof text !== 'string') return null;
73
+ if (!isCompactCommand(text)) return null;
74
74
  const match = COMPACT_COMMAND.exec(text.trim());
75
- if (!match) return null;
76
75
  if (match[1].trim()) return commandResult(t(COMPACT_USAGE));
77
76
  if (typeof state?.sessionFor !== 'function') {
78
77
  return commandResult(t('当前机器人没有可用的会话状态。'));
@@ -94,3 +93,7 @@ export async function runCompactCommand(text, harness, state, conversationKey, o
94
93
  return commandResult(compactErrorMessage(error));
95
94
  }
96
95
  }
96
+
97
+ export function isCompactCommand(text) {
98
+ return typeof text === 'string' && COMPACT_COMMAND.test(text.trim());
99
+ }
@@ -185,4 +185,6 @@ export default {
185
185
  'Collected {count}/{limit} messages. The batch is full; send /send or /cancel.',
186
186
  '批量内容提交失败,已保留 {count} 条消息。\n请再次发送 /send 重试或 /cancel 取消。':
187
187
  'Batch submission failed; {count} messages were retained.\nSend /send to retry or /cancel to cancel.',
188
+ '你可以发送普通消息,但没有执行命令的权限。':
189
+ 'You can send regular messages, but you do not have permission to run commands.',
188
190
  };
@@ -0,0 +1,43 @@
1
+ import { evaluateAccessPolicy } from './access-policy.mjs';
2
+ import { isSharedLocalCommand } from './command-permission.mjs';
3
+
4
+ export const COMMAND_PERMISSION_DENIED_MESSAGE = '你可以发送普通消息,但没有执行命令的权限。';
5
+
6
+ const POLICY_NOT_CONFIGURED = Object.freeze({ allowed: true, reason: 'policy-not-configured' });
7
+ const PRIVILEGED_SENDER = Object.freeze({ allowed: true, reason: 'privileged-sender' });
8
+
9
+ /**
10
+ * Read one committed policy snapshot and decide a single inbound event.
11
+ * A missing provider is kept backward-compatible for direct bridge fixtures;
12
+ * production always injects a provider, whose missing/damaged value fails closed.
13
+ */
14
+ export function evaluateInboundAccess(accessPolicy, {
15
+ conversationType,
16
+ senderIds,
17
+ text = '',
18
+ hasImages = false,
19
+ hasFiles = false,
20
+ isCommand = isSharedLocalCommand(text, { hasImages, hasFiles }),
21
+ } = {}) {
22
+ if (!accessPolicy) return POLICY_NOT_CONFIGURED;
23
+ try {
24
+ if (typeof accessPolicy.isPrivileged === 'function'
25
+ && accessPolicy.isPrivileged(senderIds, conversationType) === true) {
26
+ return PRIVILEGED_SENDER;
27
+ }
28
+ } catch {
29
+ // A broken privilege lookup must not bypass the persisted policy.
30
+ }
31
+ let policy = null;
32
+ try {
33
+ policy = accessPolicy.getSettings();
34
+ } catch {
35
+ // Provider failures are equivalent to an unavailable persisted policy.
36
+ }
37
+ return evaluateAccessPolicy(policy, {
38
+ conversationType,
39
+ senderIds,
40
+ isCommand,
41
+ ...(typeof accessPolicy.equals === 'function' ? { equals: accessPolicy.equals } : {}),
42
+ });
43
+ }
@@ -1,4 +1,8 @@
1
1
  import { t } from './i18n.mjs';
2
+ import {
3
+ COMMAND_PERMISSION_DENIED_MESSAGE,
4
+ evaluateInboundAccess,
5
+ } from './inbound-access.mjs';
2
6
  import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
3
7
  import { runWorkspaceCommand } from './workspace-command.mjs';
4
8
  import { runCompactCommand } from './compact-command.mjs';
@@ -129,6 +133,7 @@ export class TextHarnessBridge {
129
133
  #harness;
130
134
  #state;
131
135
  #contextEnhancement;
136
+ #accessPolicy;
132
137
  #status;
133
138
  #logger;
134
139
  #replyTimeoutMs;
@@ -149,6 +154,7 @@ export class TextHarnessBridge {
149
154
  harness,
150
155
  state,
151
156
  contextEnhancement,
157
+ accessPolicy,
152
158
  status = createTextBridgeStatus(),
153
159
  logger = console,
154
160
  replyTimeoutMs = 600_000,
@@ -162,6 +168,7 @@ export class TextHarnessBridge {
162
168
  this.#harness = harness;
163
169
  this.#state = state;
164
170
  this.#contextEnhancement = contextEnhancement;
171
+ this.#accessPolicy = accessPolicy;
165
172
  this.#status = status;
166
173
  this.#logger = logger;
167
174
  this.#replyTimeoutMs = replyTimeoutMs;
@@ -176,7 +183,7 @@ export class TextHarnessBridge {
176
183
  return structuredClone(this.#status);
177
184
  }
178
185
 
179
- accept(message, { contextSnapshot } = {}) {
186
+ accept(message, { contextSnapshot, accessDecision } = {}) {
180
187
  if (this.#signal?.aborted) return Promise.resolve();
181
188
  const conversationId = cleanText(message?.conversationId);
182
189
  const kind = message?.kind === 'group' ? 'group' : 'direct';
@@ -187,6 +194,40 @@ export class TextHarnessBridge {
187
194
  || this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
188
195
  return Promise.resolve();
189
196
  }
197
+ // Preserve the channel trigger boundary. Access policy never turns an
198
+ // unaddressed group message into a denial reply.
199
+ if (kind === 'group' && normalized.addressed !== true) {
200
+ this.#status.messagesRejected += 1;
201
+ this.#status.lastRejectedAt = new Date().toISOString();
202
+ this.#acceptedMessageIds.set(messageId, null);
203
+ return this.#finishLocalMessage(normalized, messageId, null);
204
+ }
205
+ if (this.#accessPolicy || accessDecision) {
206
+ const hasImages = hasInboundImages(normalized);
207
+ const hasFiles = hasInboundFiles(normalized);
208
+ const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
209
+ conversationType: kind,
210
+ senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
211
+ text: normalized.content,
212
+ hasImages,
213
+ hasFiles,
214
+ });
215
+ if (!decision.allowed) {
216
+ this.#status.messagesRejected += 1;
217
+ this.#status.lastRejectedAt = new Date().toISOString();
218
+ // Mark policy denials so a webhook replay cannot repeat local work or
219
+ // a command-permission notice.
220
+ this.#acceptedMessageIds.set(messageId, null);
221
+ return this.#finishLocalMessage(
222
+ normalized,
223
+ messageId,
224
+ decision.reason === 'command-not-allowed'
225
+ ? t(COMMAND_PERMISSION_DENIED_MESSAGE)
226
+ : null,
227
+ { recordReceived: decision.reason === 'command-not-allowed' },
228
+ );
229
+ }
230
+ }
190
231
  this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined
191
232
  ? captureContextEnhancement(this.#contextEnhancement, message?.kind)
192
233
  : contextSnapshot);
@@ -350,13 +391,15 @@ export class TextHarnessBridge {
350
391
  return this.#enqueueMessage(normalized, messageId, senderId, key);
351
392
  }
352
393
 
353
- #finishLocalMessage(message, messageId, reply) {
394
+ #finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
354
395
  let task;
355
396
  task = (async () => {
356
397
  if (this.#state.hasSeen(messageId)) return;
357
398
  await this.#state.markSeen(messageId);
358
- this.#status.messagesReceived += 1;
359
- this.#status.lastMessageAt = new Date().toISOString();
399
+ if (recordReceived) {
400
+ this.#status.messagesReceived += 1;
401
+ this.#status.lastMessageAt = new Date().toISOString();
402
+ }
360
403
  if (reply) await this.#bot.sendText(message.replyTarget, reply);
361
404
  this.#status.lastError = null;
362
405
  })().catch(async (error) => {
@@ -353,7 +353,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
353
353
  }
354
354
 
355
355
  export async function runWorkspaceCommand(text, harness, conversationKey) {
356
- if (typeof text !== 'string') return null;
356
+ if (!isWorkspaceCommand(text)) return null;
357
357
  const command = text.trim();
358
358
  if (SESSION_BIND_PREFIX.test(command)) {
359
359
  return runSessionBindCommand(command, harness, conversationKey);
@@ -385,3 +385,12 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
385
385
  throw error;
386
386
  }
387
387
  }
388
+
389
+ export function isWorkspaceCommand(text) {
390
+ if (typeof text !== 'string') return false;
391
+ const command = text.trim();
392
+ return SESSION_BIND_PREFIX.test(command)
393
+ || SESSION_LIST_COMMAND.test(command)
394
+ || WORKSPACE_LIST_COMMAND.test(command)
395
+ || WORKSPACE_COMMAND.test(command);
396
+ }
@@ -347,6 +347,7 @@ export class SlackRuntime {
347
347
  #harness;
348
348
  #state;
349
349
  #contextEnhancement;
350
+ #accessPolicy;
350
351
  #logger;
351
352
  #replyTimeoutMs;
352
353
  #connectTimeoutMs;
@@ -371,6 +372,7 @@ export class SlackRuntime {
371
372
  harness,
372
373
  state,
373
374
  contextEnhancement,
375
+ accessPolicy,
374
376
  logger = console,
375
377
  replyTimeoutMs = 600_000,
376
378
  connectTimeoutMs = 20_000,
@@ -387,6 +389,7 @@ export class SlackRuntime {
387
389
  this.#harness = harness;
388
390
  this.#state = state;
389
391
  this.#contextEnhancement = contextEnhancement;
392
+ this.#accessPolicy = accessPolicy;
390
393
  this.#logger = logger;
391
394
  this.#replyTimeoutMs = replyTimeoutMs;
392
395
  this.#connectTimeoutMs = connectTimeoutMs;
@@ -464,6 +467,7 @@ export class SlackRuntime {
464
467
  harness: this.#harness,
465
468
  state: this.#state,
466
469
  contextEnhancement: this.#contextEnhancement,
470
+ accessPolicy: this.#accessPolicy,
467
471
  status: this.#status,
468
472
  logger: this.#logger,
469
473
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -14,7 +14,6 @@ import {
14
14
  } from './telegram-rich-message.mjs';
15
15
  import {
16
16
  TELEGRAM_ACCESS_MODES,
17
- normalizeTelegramAccessPolicy,
18
17
  } from './config-store.mjs';
19
18
 
20
19
  export const TELEGRAM_COMMAND_MENU = Object.freeze([
@@ -669,12 +668,11 @@ export class TelegramRuntime {
669
668
  #harness;
670
669
  #state;
671
670
  #contextEnhancement;
671
+ #accessPolicy;
672
672
  #logger;
673
673
  #replyTimeoutMs;
674
674
  #createApi;
675
675
  #createHttpTransport;
676
- #accessMode;
677
- #allowedPrivateUserIds;
678
676
  #status = createTelegramRuntimeStatus();
679
677
  #httpTransport = null;
680
678
  #api = null;
@@ -689,6 +687,7 @@ export class TelegramRuntime {
689
687
  harness,
690
688
  state,
691
689
  contextEnhancement,
690
+ accessPolicy,
692
691
  logger = console,
693
692
  replyTimeoutMs = 600_000,
694
693
  createApi = (options) => new TelegramApi(options),
@@ -702,13 +701,11 @@ export class TelegramRuntime {
702
701
  this.#harness = harness;
703
702
  this.#state = state;
704
703
  this.#contextEnhancement = contextEnhancement;
704
+ this.#accessPolicy = accessPolicy;
705
705
  this.#logger = logger;
706
706
  this.#replyTimeoutMs = replyTimeoutMs;
707
707
  this.#createApi = createApi;
708
708
  this.#createHttpTransport = createHttpTransport;
709
- const accessPolicy = normalizeTelegramAccessPolicy(config);
710
- this.#accessMode = accessPolicy.accessMode;
711
- this.#allowedPrivateUserIds = new Set(accessPolicy.allowedUsers);
712
709
  }
713
710
 
714
711
  get status() {
@@ -805,6 +802,7 @@ export class TelegramRuntime {
805
802
  harness: this.#harness,
806
803
  state: this.#state,
807
804
  contextEnhancement: this.#contextEnhancement,
805
+ accessPolicy: this.#accessPolicy,
808
806
  status: this.#status,
809
807
  logger: this.#logger,
810
808
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -865,10 +863,7 @@ export class TelegramRuntime {
865
863
  loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
866
864
  loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
867
865
  });
868
- if (message && telegramInboundAllowed(message, {
869
- accessMode: this.#accessMode,
870
- allowedPrivateUserIds: this.#allowedPrivateUserIds,
871
- })) {
866
+ if (message) {
872
867
  void this.#bridge.accept(message, { contextSnapshot }).catch((error) => {
873
868
  if (signal.aborted) return;
874
869
  this.#logger.error?.(
@@ -876,9 +871,6 @@ export class TelegramRuntime {
876
871
  error,
877
872
  );
878
873
  });
879
- } else if (message) {
880
- this.#status.messagesRejected += 1;
881
- this.#status.lastRejectedAt = new Date().toISOString();
882
874
  }
883
875
  cursor = update.update_id + 1;
884
876
  await this.#state.setCursor(cursor);