@xmanrui/dsh-im 4.2.1 → 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 (74) hide show
  1. package/README.en.md +4 -2
  2. package/README.md +4 -2
  3. package/lib/client.js +1267 -787
  4. package/lib/index.js +237 -235
  5. package/package.json +1 -1
  6. package/plugin-src/client/access-policy-settings.js +351 -0
  7. package/plugin-src/client/channel-card-meta.js +2 -1
  8. package/plugin-src/client/channels/dingtalk/api.js +5 -0
  9. package/plugin-src/client/channels/dingtalk/index.js +1 -0
  10. package/plugin-src/client/channels/feishu/api.js +5 -0
  11. package/plugin-src/client/channels/feishu/index.js +1 -0
  12. package/plugin-src/client/channels/qq/api.js +5 -0
  13. package/plugin-src/client/channels/qq/index.js +1 -0
  14. package/plugin-src/client/channels/shared/token-api.js +5 -0
  15. package/plugin-src/client/channels/shared/token-channel.js +1 -0
  16. package/plugin-src/client/channels/telegram/api.js +2 -17
  17. package/plugin-src/client/channels/telegram/index.js +0 -112
  18. package/plugin-src/client/channels/telegram/styles.js +0 -28
  19. package/plugin-src/client/channels/wecom/api.js +5 -0
  20. package/plugin-src/client/channels/wecom/index.js +1 -0
  21. package/plugin-src/client/channels/weixin/api.js +5 -0
  22. package/plugin-src/client/channels/weixin/index.js +1 -0
  23. package/plugin-src/client/channels/whatsapp/api.js +4 -10
  24. package/plugin-src/client/channels/whatsapp/index.js +1 -125
  25. package/plugin-src/client/channels/whatsapp/styles.js +0 -25
  26. package/plugin-src/client/context-enhancement.js +207 -102
  27. package/plugin-src/client/delivery-settings.js +61 -9
  28. package/plugin-src/client/i18n.js +71 -3
  29. package/plugin-src/client/index.js +1 -0
  30. package/plugin-src/client/styles.js +59 -8
  31. package/plugin-src/host/channels/dingtalk/production.mjs +12 -1
  32. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  33. package/plugin-src/host/channels/feishu/production.mjs +39 -1
  34. package/plugin-src/host/channels/feishu/rpc.mjs +19 -2
  35. package/plugin-src/host/channels/qq/production.mjs +12 -1
  36. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  37. package/plugin-src/host/channels/shared/access-policy-production.mjs +137 -0
  38. package/plugin-src/host/channels/shared/access-policy-rpc.mjs +17 -0
  39. package/plugin-src/host/channels/shared/production.mjs +15 -1
  40. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  41. package/plugin-src/host/channels/slack/production.mjs +12 -1
  42. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  43. package/plugin-src/host/channels/telegram/rpc.mjs +2 -49
  44. package/plugin-src/host/channels/wecom/production.mjs +12 -1
  45. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  46. package/plugin-src/host/channels/weixin/production.mjs +12 -1
  47. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  48. package/plugin-src/host/channels/whatsapp/production.mjs +16 -2
  49. package/plugin-src/host/channels/whatsapp/rpc.mjs +10 -15
  50. package/scripts/verify-package.mjs +4 -3
  51. package/src/channels/dingtalk/dingtalk-bridge.mjs +61 -9
  52. package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -0
  53. package/src/channels/discord/discord-runtime.mjs +29 -1
  54. package/src/channels/feishu/bridge.mjs +286 -120
  55. package/src/channels/feishu/feishu-runtime.mjs +4 -0
  56. package/src/channels/feishu/message-utils.mjs +5 -0
  57. package/src/channels/qq/qq-bridge.mjs +59 -17
  58. package/src/channels/qq/qq-runtime.mjs +25 -3
  59. package/src/channels/shared/access-policy.mjs +210 -0
  60. package/src/channels/shared/bot-workspace-store.mjs +176 -33
  61. package/src/channels/shared/command-permission.mjs +32 -0
  62. package/src/channels/shared/compact-command.mjs +5 -2
  63. package/src/channels/shared/context-enhancement.mjs +73 -20
  64. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  65. package/src/channels/shared/inbound-access.mjs +43 -0
  66. package/src/channels/shared/text-harness-bridge.mjs +47 -4
  67. package/src/channels/shared/workspace-command.mjs +10 -1
  68. package/src/channels/slack/slack-runtime.mjs +4 -0
  69. package/src/channels/telegram/telegram-runtime.mjs +5 -13
  70. package/src/channels/wecom/wecom-bridge.mjs +51 -4
  71. package/src/channels/wecom/wecom-runtime.mjs +4 -0
  72. package/src/channels/weixin/weixin-bridge.mjs +59 -16
  73. package/src/channels/weixin/weixin-runtime.mjs +4 -0
  74. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -20
@@ -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);
@@ -51,6 +51,10 @@ import {
51
51
  messageFailureText,
52
52
  setLastMessageFailure,
53
53
  } from '../shared/message-failure.mjs';
54
+ import {
55
+ COMMAND_PERMISSION_DENIED_MESSAGE,
56
+ evaluateInboundAccess,
57
+ } from '../shared/inbound-access.mjs';
54
58
  import { t } from '../shared/i18n.mjs';
55
59
 
56
60
  const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
@@ -490,6 +494,7 @@ export class WecomHarnessBridge {
490
494
  #harness;
491
495
  #state;
492
496
  #contextEnhancement;
497
+ #accessPolicy;
493
498
  #status;
494
499
  #logger;
495
500
  #replyTimeoutMs;
@@ -512,6 +517,7 @@ export class WecomHarnessBridge {
512
517
  harness,
513
518
  state,
514
519
  contextEnhancement,
520
+ accessPolicy,
515
521
  status = createWecomBridgeStatus(),
516
522
  logger = console,
517
523
  replyTimeoutMs = 600_000,
@@ -530,6 +536,7 @@ export class WecomHarnessBridge {
530
536
  this.#harness = harness;
531
537
  this.#state = state;
532
538
  this.#contextEnhancement = contextEnhancement;
539
+ this.#accessPolicy = accessPolicy;
533
540
  this.#status = status;
534
541
  this.#logger = logger;
535
542
  this.#replyTimeoutMs = replyTimeoutMs;
@@ -557,16 +564,28 @@ export class WecomHarnessBridge {
557
564
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
558
565
 
559
566
  const key = conversationKey(frame);
567
+ const pending = this.#pendingInteractions.get(key);
568
+ const commandMessage = wecomInboundMessage(frame, this.#client);
569
+ const commandText = nonEmptyString(commandMessage.content) ?? '';
570
+ const conversationType = body.chattype === 'single' ? 'direct' : 'group';
571
+ const access = evaluateInboundAccess(this.#accessPolicy, {
572
+ conversationType,
573
+ senderIds: senderId,
574
+ text: commandText,
575
+ hasImages: hasInboundImages(commandMessage),
576
+ hasFiles: hasInboundFiles(commandMessage),
577
+ });
578
+ if (!access.allowed) {
579
+ this.#acceptedMessageIds.set(messageId, null);
580
+ return this.#finishAccessDecision(frame, messageId, chatId, access);
581
+ }
560
582
  this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
561
583
  this.#contextEnhancement,
562
- body.chattype === 'single' ? 'direct' : 'group',
584
+ conversationType,
563
585
  ));
564
586
  if (body.chattype === 'single') {
565
587
  rememberConnectionTestTarget(this.#state, { chatId });
566
588
  }
567
- const pending = this.#pendingInteractions.get(key);
568
- const commandMessage = wecomInboundMessage(frame, this.#client);
569
- const commandText = nonEmptyString(commandMessage.content) ?? '';
570
589
  const batchCommand = isBatchInputCommand(commandText);
571
590
  const batchStatus = this.#batchInputs.status(key);
572
591
  if (batchCommand && body.chattype === 'group') {
@@ -760,6 +779,34 @@ export class WecomHarnessBridge {
760
779
  return task;
761
780
  }
762
781
 
782
+ #finishAccessDecision(frame, messageId, chatId, access) {
783
+ let task;
784
+ task = Promise.resolve().then(async () => {
785
+ if (this.#state.hasSeen(messageId)) return;
786
+ await this.#state.markSeen(messageId);
787
+ if (access.reason === 'command-not-allowed') {
788
+ this.#status.messagesReceived += 1;
789
+ this.#status.lastMessageAt = new Date().toISOString();
790
+ await this.#sendImmediate(frame, chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE));
791
+ this.#status.messagesReplied += 1;
792
+ this.#status.lastReplyAt = new Date().toISOString();
793
+ } else {
794
+ this.#status.messagesRejected += 1;
795
+ this.#status.lastRejectedAt = new Date().toISOString();
796
+ }
797
+ this.#status.lastError = null;
798
+ }).catch((error) => {
799
+ if (this.#signal?.aborted) return;
800
+ this.#status.lastError = error?.message ?? String(error);
801
+ this.#logger.error?.('[dsh-im:wecom] failed to apply inbound access policy', error);
802
+ }).finally(() => {
803
+ this.#acceptedMessageIds.delete(messageId);
804
+ this.#commandTasks.delete(task);
805
+ });
806
+ this.#commandTasks.add(task);
807
+ return task;
808
+ }
809
+
763
810
  async waitForIdle() {
764
811
  await Promise.allSettled([
765
812
  ...this.#queues.values(),
@@ -29,6 +29,7 @@ export class WecomRuntime {
29
29
  #harness;
30
30
  #state;
31
31
  #contextEnhancement;
32
+ #accessPolicy;
32
33
  #logger;
33
34
  #replyTimeoutMs;
34
35
  #connectTimeoutMs;
@@ -47,6 +48,7 @@ export class WecomRuntime {
47
48
  harness,
48
49
  state,
49
50
  contextEnhancement,
51
+ accessPolicy,
50
52
  logger = console,
51
53
  replyTimeoutMs = 600_000,
52
54
  connectTimeoutMs = 20_000,
@@ -61,6 +63,7 @@ export class WecomRuntime {
61
63
  this.#harness = harness;
62
64
  this.#state = state;
63
65
  this.#contextEnhancement = contextEnhancement;
66
+ this.#accessPolicy = accessPolicy;
64
67
  this.#logger = logger;
65
68
  this.#replyTimeoutMs = replyTimeoutMs;
66
69
  this.#connectTimeoutMs = connectTimeoutMs;
@@ -111,6 +114,7 @@ export class WecomRuntime {
111
114
  harness: this.#harness,
112
115
  state: this.#state,
113
116
  contextEnhancement: this.#contextEnhancement,
117
+ accessPolicy: this.#accessPolicy,
114
118
  status: this.#status,
115
119
  logger: this.#logger,
116
120
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -57,6 +57,10 @@ import {
57
57
  messageFailureText,
58
58
  setLastMessageFailure,
59
59
  } from '../shared/message-failure.mjs';
60
+ import {
61
+ COMMAND_PERMISSION_DENIED_MESSAGE,
62
+ evaluateInboundAccess,
63
+ } from '../shared/inbound-access.mjs';
60
64
  import { t } from '../shared/i18n.mjs';
61
65
 
62
66
  const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
@@ -260,6 +264,7 @@ export class WeixinHarnessBridge {
260
264
  #harness;
261
265
  #state;
262
266
  #contextEnhancement;
267
+ #accessPolicy;
263
268
  #status;
264
269
  #logger;
265
270
  #replyTimeoutMs;
@@ -292,6 +297,7 @@ export class WeixinHarnessBridge {
292
297
  harness,
293
298
  state,
294
299
  contextEnhancement,
300
+ accessPolicy,
295
301
  status = createWeixinBridgeStatus(),
296
302
  logger = console,
297
303
  replyTimeoutMs = 600_000,
@@ -312,6 +318,7 @@ export class WeixinHarnessBridge {
312
318
  this.#harness = harness;
313
319
  this.#state = state;
314
320
  this.#contextEnhancement = contextEnhancement;
321
+ this.#accessPolicy = accessPolicy;
315
322
  this.#status = status;
316
323
  this.#logger = logger;
317
324
  this.#replyTimeoutMs = replyTimeoutMs;
@@ -332,6 +339,22 @@ export class WeixinHarnessBridge {
332
339
  const sender = nonEmptyString(message?.from_user_id);
333
340
  if (!messageId || !sender || this.#state.hasSeen(messageId)
334
341
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
342
+ const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
343
+ const access = this.#accessPolicy
344
+ ? evaluateInboundAccess(this.#accessPolicy, {
345
+ conversationType: 'direct',
346
+ senderIds: sender,
347
+ text: commandText,
348
+ hasImages: hasWeixinImageItems(message),
349
+ hasFiles: hasWeixinFileItems(message),
350
+ })
351
+ : sender === this.#ownerUserId
352
+ ? { allowed: true, reason: 'legacy-owner' }
353
+ : { allowed: false, reason: 'sender-not-allowed' };
354
+ if (!access.allowed) {
355
+ this.#acceptedMessageIds.set(messageId, null);
356
+ return this.#finishAccessDecision(messageId, sender, message, access);
357
+ }
335
358
  this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
336
359
  this.#contextEnhancement,
337
360
  'direct',
@@ -343,11 +366,9 @@ export class WeixinHarnessBridge {
343
366
  const contextToken = nonEmptyString(message?.context_token) ?? undefined;
344
367
  const runId = nonEmptyString(message?.run_id) ?? undefined;
345
368
  const pending = this.#pendingInteractions.get(key);
346
- const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
347
369
  const batchCommand = isBatchInputCommand(commandText);
348
370
  const batchStatus = this.#batchInputs.status(key);
349
- if (sender === this.#ownerUserId
350
- && (batchCommand || batchStatus.phase === 'collecting')) {
371
+ if (batchCommand || batchStatus.phase === 'collecting') {
351
372
  const exactBatchStart = /^\/batch$/iu.test(commandText);
352
373
  const result = exactBatchStart
353
374
  && batchStatus.phase === 'idle'
@@ -380,7 +401,7 @@ export class WeixinHarnessBridge {
380
401
  : (isModelCommand(commandText)
381
402
  ? runModelCommand
382
403
  : (isPresetCommand(commandText) ? runPresetCommand : null));
383
- if (commandRunner && sender === this.#ownerUserId) {
404
+ if (commandRunner) {
384
405
  let task;
385
406
  task = this.#processFastCommand(
386
407
  message,
@@ -467,12 +488,10 @@ export class WeixinHarnessBridge {
467
488
  alreadyRecorded = false,
468
489
  batchSubmission = null,
469
490
  } = {}) {
470
- const preparedMessage = message.from_user_id === this.#ownerUserId
471
- ? prefetchInboundFiles(
472
- weixinInboundMessage(message, this.#api),
473
- { signal: this.#signal },
474
- )
475
- : undefined;
491
+ const preparedMessage = prefetchInboundFiles(
492
+ weixinInboundMessage(message, this.#api),
493
+ { signal: this.#signal },
494
+ );
476
495
  const previous = this.#queues.get(key) ?? Promise.resolve();
477
496
  const current = previous
478
497
  .catch(() => undefined)
@@ -518,6 +537,36 @@ export class WeixinHarnessBridge {
518
537
  return task;
519
538
  }
520
539
 
540
+ #finishAccessDecision(messageId, sender, message, access) {
541
+ const contextToken = nonEmptyString(message?.context_token) ?? undefined;
542
+ const runId = nonEmptyString(message?.run_id) ?? undefined;
543
+ let task;
544
+ task = Promise.resolve().then(async () => {
545
+ if (this.#state.hasSeen(messageId)) return;
546
+ await this.#state.markSeen(messageId);
547
+ if (access.reason === 'command-not-allowed') {
548
+ this.#status.messagesReceived += 1;
549
+ this.#status.lastMessageAt = new Date().toISOString();
550
+ await this.#send(sender, t(COMMAND_PERMISSION_DENIED_MESSAGE), contextToken, runId);
551
+ this.#status.messagesReplied += 1;
552
+ this.#status.lastReplyAt = new Date().toISOString();
553
+ } else {
554
+ this.#status.messagesRejected += 1;
555
+ this.#status.lastRejectedAt = new Date().toISOString();
556
+ }
557
+ this.#status.lastError = null;
558
+ }).catch((error) => {
559
+ if (this.#signal?.aborted) return;
560
+ this.#status.lastError = error?.message ?? String(error);
561
+ this.#logger.error?.('[dsh-weixin] failed to apply inbound access policy:', error);
562
+ }).finally(() => {
563
+ this.#acceptedMessageIds.delete(messageId);
564
+ this.#commandTasks.delete(task);
565
+ });
566
+ this.#commandTasks.add(task);
567
+ return task;
568
+ }
569
+
521
570
  async waitForIdle() {
522
571
  await Promise.allSettled([
523
572
  ...this.#queues.values(),
@@ -590,12 +639,6 @@ export class WeixinHarnessBridge {
590
639
  this.#status.messagesReceived += 1;
591
640
  this.#status.lastMessageAt = new Date().toISOString();
592
641
  }
593
- if (sender !== this.#ownerUserId) {
594
- this.#status.messagesRejected += 1;
595
- this.#status.lastRejectedAt = new Date().toISOString();
596
- return;
597
- }
598
-
599
642
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
600
643
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
601
644
  let batchSettled = batchSubmission === null;
@@ -109,6 +109,7 @@ export class WeixinRuntime {
109
109
  #harness;
110
110
  #state;
111
111
  #contextEnhancement;
112
+ #accessPolicy;
112
113
  #logger;
113
114
  #replyTimeoutMs;
114
115
  #maxMessageChars;
@@ -126,6 +127,7 @@ export class WeixinRuntime {
126
127
  harness,
127
128
  state,
128
129
  contextEnhancement,
130
+ accessPolicy,
129
131
  logger = console,
130
132
  replyTimeoutMs = 600_000,
131
133
  maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
@@ -140,6 +142,7 @@ export class WeixinRuntime {
140
142
  this.#harness = harness;
141
143
  this.#state = state;
142
144
  this.#contextEnhancement = contextEnhancement;
145
+ this.#accessPolicy = accessPolicy;
143
146
  this.#logger = logger;
144
147
  this.#replyTimeoutMs = replyTimeoutMs;
145
148
  this.#maxMessageChars = maxMessageChars;
@@ -182,6 +185,7 @@ export class WeixinRuntime {
182
185
  harness: this.#harness,
183
186
  state: this.#state,
184
187
  contextEnhancement: this.#contextEnhancement,
188
+ accessPolicy: this.#accessPolicy,
185
189
  status: this.#status,
186
190
  logger: this.#logger,
187
191
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -3,6 +3,7 @@ import { createHash, randomBytes } from 'node:crypto';
3
3
  import {
4
4
  areJidsSameUser,
5
5
  downloadMediaMessage,
6
+ jidDecode,
6
7
  normalizeMessageContent,
7
8
  } from '@whiskeysockets/baileys';
8
9
 
@@ -13,7 +14,6 @@ import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifac
13
14
  import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
14
15
  import {
15
16
  WHATSAPP_ACCESS_MODES,
16
- normalizeWhatsappAccessPolicy,
17
17
  } from './config-store.mjs';
18
18
  import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
19
19
 
@@ -37,6 +37,36 @@ const VIEW_ONCE_WRAPPER_KEYS = new Set([
37
37
  'viewOnceMessageV2',
38
38
  'viewOnceMessageV2Extension',
39
39
  ]);
40
+ const WHATSAPP_ACCESS_POLICY_USER_SERVERS = new Set([
41
+ 's.whatsapp.net',
42
+ 'c.us',
43
+ 'lid',
44
+ 'hosted',
45
+ 'hosted.lid',
46
+ ]);
47
+
48
+ function normalizeWhatsappAccessPolicyId(value) {
49
+ if (typeof value !== 'string') return null;
50
+ const candidate = value.trim();
51
+ if (/^\+?\d+$/.test(candidate)) {
52
+ return `${candidate.replace(/^\+/, '')}@s.whatsapp.net`;
53
+ }
54
+ const decoded = jidDecode(candidate);
55
+ if (!decoded || !/^\d+$/.test(decoded.user)
56
+ || !WHATSAPP_ACCESS_POLICY_USER_SERVERS.has(decoded.server)) return null;
57
+ return candidate;
58
+ }
59
+
60
+ export function whatsappAccessPolicyIdsEqual(left, right) {
61
+ const normalizedLeft = normalizeWhatsappAccessPolicyId(left);
62
+ const normalizedRight = normalizeWhatsappAccessPolicyId(right);
63
+ if (!normalizedLeft || !normalizedRight) return false;
64
+ try {
65
+ return areJidsSameUser(normalizedLeft, normalizedRight) === true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
40
70
 
41
71
  function hasViewOnceWrapper(content) {
42
72
  let current = content;
@@ -538,12 +568,11 @@ export class WhatsappRuntime {
538
568
  #harness;
539
569
  #state;
540
570
  #contextEnhancement;
571
+ #accessPolicy;
541
572
  #logger;
542
573
  #replyTimeoutMs;
543
574
  #connectTimeoutMs;
544
575
  #mediaUploadTimeoutMs;
545
- #accessMode;
546
- #allowedPrivateNumbers;
547
576
  #createSession;
548
577
  #status = createWhatsappRuntimeStatus();
549
578
  #abortController = null;
@@ -558,6 +587,7 @@ export class WhatsappRuntime {
558
587
  harness,
559
588
  state,
560
589
  contextEnhancement,
590
+ accessPolicy,
561
591
  logger = console,
562
592
  replyTimeoutMs = 600_000,
563
593
  connectTimeoutMs = 30_000,
@@ -572,6 +602,7 @@ export class WhatsappRuntime {
572
602
  this.#harness = harness;
573
603
  this.#state = state;
574
604
  this.#contextEnhancement = contextEnhancement;
605
+ this.#accessPolicy = accessPolicy;
575
606
  this.#logger = logger;
576
607
  this.#replyTimeoutMs = replyTimeoutMs;
577
608
  this.#connectTimeoutMs = connectTimeoutMs;
@@ -583,21 +614,12 @@ export class WhatsappRuntime {
583
614
  WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
584
615
  );
585
616
  this.#createSession = createSession;
586
- this.setAccessPolicy(config);
587
617
  }
588
618
 
589
619
  get status() {
590
620
  return structuredClone(this.#status);
591
621
  }
592
622
 
593
- setAccessPolicy(value) {
594
- const policy = normalizeWhatsappAccessPolicy(value);
595
- this.#accessMode = policy.accessMode;
596
- this.#allowedPrivateNumbers = new Set(policy.allowedNumbers);
597
- this.#config = { ...this.#config, ...policy };
598
- return policy;
599
- }
600
-
601
623
  async start() {
602
624
  if (this.#status.ready && this.#session) return this.status;
603
625
  if (this.#starting) return this.#starting;
@@ -636,14 +658,6 @@ export class WhatsappRuntime {
636
658
  });
637
659
  if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
638
660
  this.#status.lastCheckedAt = Date.now();
639
- if (!whatsappInboundAllowed(message, {
640
- accessMode: this.#accessMode,
641
- allowedNumbers: this.#allowedPrivateNumbers,
642
- })) {
643
- this.#status.messagesRejected += 1;
644
- this.#status.lastRejectedAt = new Date().toISOString();
645
- return;
646
- }
647
661
  await this.#bridge.accept(message);
648
662
  },
649
663
  onDisconnect: ({ error }) => {
@@ -678,6 +692,14 @@ export class WhatsappRuntime {
678
692
  harness: this.#harness,
679
693
  state: this.#state,
680
694
  contextEnhancement: this.#contextEnhancement,
695
+ accessPolicy: this.#accessPolicy ? {
696
+ botId: this.#accessPolicy.botId,
697
+ getSettings: (...args) => this.#accessPolicy.getSettings(...args),
698
+ ...(typeof this.#accessPolicy.isPrivileged === 'function' ? {
699
+ isPrivileged: (...args) => this.#accessPolicy.isPrivileged(...args),
700
+ } : {}),
701
+ equals: whatsappAccessPolicyIdsEqual,
702
+ } : undefined,
681
703
  status: this.#status,
682
704
  logger: this.#logger,
683
705
  replyTimeoutMs: this.#replyTimeoutMs,