@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
@@ -114,6 +114,7 @@ export class FeishuRuntime {
114
114
  #harness;
115
115
  #state;
116
116
  #contextEnhancement;
117
+ #accessPolicy;
117
118
  #replyTimeoutMs;
118
119
  #connectTimeoutMs;
119
120
  #requestTimeoutMs;
@@ -143,6 +144,7 @@ export class FeishuRuntime {
143
144
  harness,
144
145
  state,
145
146
  contextEnhancement,
147
+ accessPolicy,
146
148
  repair,
147
149
  replyTimeoutMs = 600000,
148
150
  connectTimeoutMs = 15000,
@@ -176,6 +178,7 @@ export class FeishuRuntime {
176
178
  this.#harness = harness;
177
179
  this.#state = state;
178
180
  this.#contextEnhancement = contextEnhancement;
181
+ this.#accessPolicy = accessPolicy;
179
182
  this.#repair = repair ?? null;
180
183
  this.#replyTimeoutMs = replyTimeoutMs;
181
184
  this.#connectTimeoutMs = connectTimeoutMs;
@@ -276,6 +279,7 @@ export class FeishuRuntime {
276
279
  harness: this.#harness,
277
280
  state: this.#state,
278
281
  contextEnhancement: this.#contextEnhancement,
282
+ accessPolicy: this.#accessPolicy,
279
283
  status: this.#status,
280
284
  allowedSenderOpenIds: new Set(this.#ownerOpenIds),
281
285
  botId: this.#botId,
@@ -54,6 +54,10 @@ import {
54
54
  messageFailureText,
55
55
  setLastMessageFailure,
56
56
  } from '../shared/message-failure.mjs';
57
+ import {
58
+ COMMAND_PERMISSION_DENIED_MESSAGE,
59
+ evaluateInboundAccess,
60
+ } from '../shared/inbound-access.mjs';
57
61
  import { sendMarkdownReply } from './markdown-reply.mjs';
58
62
  import { t } from '../shared/i18n.mjs';
59
63
 
@@ -110,9 +114,6 @@ function conversationKey(message) {
110
114
  }
111
115
 
112
116
  function senderAllowed(message, ownerUserOpenid) {
113
- // QR binding yields a C2C user_openid, while group events identify senders
114
- // with a group-scoped member_openid. Treat group membership plus @mention as
115
- // the access boundary, and keep the scanner restriction for private chats.
116
117
  return message?.kind === 'group'
117
118
  || ownerUserOpenid === '*'
118
119
  || message?.senderId === ownerUserOpenid;
@@ -376,6 +377,7 @@ export class QqHarnessBridge {
376
377
  #harness;
377
378
  #state;
378
379
  #contextEnhancement;
380
+ #accessPolicy;
379
381
  #status;
380
382
  #logger;
381
383
  #replyTimeoutMs;
@@ -398,6 +400,7 @@ export class QqHarnessBridge {
398
400
  harness,
399
401
  state,
400
402
  contextEnhancement,
403
+ accessPolicy,
401
404
  status = createQqBridgeStatus(),
402
405
  logger = console,
403
406
  replyTimeoutMs = 600_000,
@@ -417,6 +420,7 @@ export class QqHarnessBridge {
417
420
  this.#harness = harness;
418
421
  this.#state = state;
419
422
  this.#contextEnhancement = contextEnhancement;
423
+ this.#accessPolicy = accessPolicy;
420
424
  this.#status = status;
421
425
  this.#logger = logger;
422
426
  this.#replyTimeoutMs = replyTimeoutMs;
@@ -439,6 +443,26 @@ export class QqHarnessBridge {
439
443
  || this.#state.hasSeen(messageId)
440
444
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
441
445
  const key = conversationKey(message);
446
+ const addressed = message.kind !== 'group'
447
+ || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
448
+ const commandText = safeText(message);
449
+ if (addressed) {
450
+ const access = this.#accessPolicy
451
+ ? evaluateInboundAccess(this.#accessPolicy, {
452
+ conversationType: message.kind === 'c2c' ? 'direct' : 'group',
453
+ senderIds: sender,
454
+ text: commandText,
455
+ hasImages: hasQqImageAttachments(message),
456
+ hasFiles: hasQqFileAttachments(message),
457
+ })
458
+ : senderAllowed(message, this.#ownerUserOpenid)
459
+ ? { allowed: true, reason: 'legacy-owner' }
460
+ : { allowed: false, reason: 'sender-not-allowed' };
461
+ if (!access.allowed) {
462
+ this.#acceptedMessageIds.set(messageId, null);
463
+ return this.#finishAccessDecision(message, messageId, access);
464
+ }
465
+ }
442
466
  this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
443
467
  this.#contextEnhancement,
444
468
  message.kind === 'c2c' ? 'direct' : 'group',
@@ -450,20 +474,16 @@ export class QqHarnessBridge {
450
474
  rememberConnectionTestTarget(this.#state, message.replyTarget);
451
475
  }
452
476
  const pending = this.#pendingInteractions.get(key);
453
- const commandText = safeText(message);
454
- const allowed = senderAllowed(message, this.#ownerUserOpenid);
455
- const addressed = message.kind !== 'group'
456
- || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
457
477
  const batchCommand = isBatchInputCommand(commandText);
458
478
  const batchStatus = this.#batchInputs.status(key);
459
- if (batchCommand && allowed && addressed && message.kind === 'group') {
479
+ if (batchCommand && addressed && message.kind === 'group') {
460
480
  return this.#finishBatchResult(
461
481
  message,
462
482
  messageId,
463
483
  { message: batchInputGroupUnsupportedMessage() },
464
484
  );
465
485
  }
466
- if (allowed && message.kind === 'c2c'
486
+ if (message.kind === 'c2c'
467
487
  && (batchCommand || batchStatus.phase === 'collecting')) {
468
488
  const exactBatchStart = /^\/batch$/iu.test(commandText);
469
489
  const result = exactBatchStart
@@ -493,7 +513,7 @@ export class QqHarnessBridge {
493
513
  : (isModelCommand(commandText)
494
514
  ? runModelCommand
495
515
  : (isPresetCommand(commandText) ? runPresetCommand : null));
496
- if (commandRunner && allowed && addressed) {
516
+ if (commandRunner && addressed) {
497
517
  let task;
498
518
  task = this.#processFastCommand(
499
519
  message,
@@ -578,10 +598,9 @@ export class QqHarnessBridge {
578
598
  alreadyRecorded = false,
579
599
  batchSubmission = null,
580
600
  } = {}) {
581
- const allowed = senderAllowed(message, this.#ownerUserOpenid);
582
601
  const addressed = message.kind !== 'group'
583
602
  || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
584
- const preparedMessage = allowed && addressed
603
+ const preparedMessage = addressed
585
604
  ? prefetchInboundFiles(
586
605
  qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
587
606
  { signal: this.#signal },
@@ -668,6 +687,34 @@ export class QqHarnessBridge {
668
687
  return task;
669
688
  }
670
689
 
690
+ #finishAccessDecision(message, messageId, access) {
691
+ let task;
692
+ task = Promise.resolve().then(async () => {
693
+ if (this.#state.hasSeen(messageId)) return;
694
+ await this.#state.markSeen(messageId);
695
+ if (access.reason === 'command-not-allowed') {
696
+ this.#status.messagesReceived += 1;
697
+ this.#status.lastMessageAt = new Date().toISOString();
698
+ await this.#bot.sendText(message.replyTarget, t(COMMAND_PERMISSION_DENIED_MESSAGE));
699
+ this.#status.messagesReplied += 1;
700
+ this.#status.lastReplyAt = new Date().toISOString();
701
+ } else {
702
+ this.#status.messagesRejected += 1;
703
+ this.#status.lastRejectedAt = new Date().toISOString();
704
+ }
705
+ this.#status.lastError = null;
706
+ }).catch((error) => {
707
+ if (this.#signal?.aborted) return;
708
+ this.#status.lastError = error?.message ?? String(error);
709
+ this.#logger.error?.('[dsh-im:qq] failed to apply inbound access policy:', error);
710
+ }).finally(() => {
711
+ this.#acceptedMessageIds.delete(messageId);
712
+ this.#commandTasks.delete(task);
713
+ });
714
+ this.#commandTasks.add(task);
715
+ return task;
716
+ }
717
+
671
718
  async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
672
719
  if (artifacts.length === 0) {
673
720
  return { receipt: baseReceipt, failureNoticeVisible: false, artifactSendErrors: 0 };
@@ -730,11 +777,6 @@ export class QqHarnessBridge {
730
777
  await this.#state.markSeen(messageId);
731
778
  messageRecorded = true;
732
779
  };
733
- if (!senderAllowed(message, this.#ownerUserOpenid)) {
734
- this.#status.messagesRejected += 1;
735
- this.#status.lastRejectedAt = new Date().toISOString();
736
- return;
737
- }
738
780
  if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
739
781
 
740
782
  const target = message.replyTarget;
@@ -5,6 +5,7 @@ import {
5
5
  connectionTestTargetUnavailable,
6
6
  } from '../shared/connection-test.mjs';
7
7
  import { t } from '../shared/i18n.mjs';
8
+ import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
8
9
  import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
9
10
 
10
11
  function timeoutError() {
@@ -32,6 +33,7 @@ export class QqRuntime {
32
33
  #harness;
33
34
  #state;
34
35
  #contextEnhancement;
36
+ #accessPolicy;
35
37
  #logger;
36
38
  #replyTimeoutMs;
37
39
  #connectTimeoutMs;
@@ -50,6 +52,7 @@ export class QqRuntime {
50
52
  harness,
51
53
  state,
52
54
  contextEnhancement,
55
+ accessPolicy,
53
56
  logger = console,
54
57
  replyTimeoutMs = 600_000,
55
58
  connectTimeoutMs = 20_000,
@@ -64,6 +67,7 @@ export class QqRuntime {
64
67
  this.#harness = harness;
65
68
  this.#state = state;
66
69
  this.#contextEnhancement = contextEnhancement;
70
+ this.#accessPolicy = accessPolicy;
67
71
  this.#logger = logger;
68
72
  this.#replyTimeoutMs = replyTimeoutMs;
69
73
  this.#connectTimeoutMs = connectTimeoutMs;
@@ -166,6 +170,7 @@ export class QqRuntime {
166
170
  harness: this.#harness,
167
171
  state: this.#state,
168
172
  contextEnhancement: this.#contextEnhancement,
173
+ accessPolicy: this.#accessPolicy,
169
174
  status: this.#status,
170
175
  logger: this.#logger,
171
176
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -177,8 +182,21 @@ export class QqRuntime {
177
182
  bot.use(contentSanitizer({ parseFaceTags: true }));
178
183
  bot.use?.(this.#typingMiddleware({
179
184
  keepAlive: true,
180
- predicate: (ctx) => this.#config.ownerUserOpenid === '*'
181
- || ctx?.message?.senderId === this.#config.ownerUserOpenid,
185
+ predicate: (ctx) => {
186
+ const message = ctx?.message;
187
+ if (!message || (message.kind === 'group'
188
+ && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE')) return false;
189
+ if (!this.#accessPolicy) {
190
+ return message.kind === 'group'
191
+ || this.#config.ownerUserOpenid === '*'
192
+ || message.senderId === this.#config.ownerUserOpenid;
193
+ }
194
+ return evaluateInboundAccess(this.#accessPolicy, {
195
+ conversationType: message.kind === 'c2c' ? 'direct' : 'group',
196
+ senderIds: message.senderId,
197
+ text: typeof message.content === 'string' ? message.content.trim() : '',
198
+ }).allowed;
199
+ },
182
200
  }));
183
201
 
184
202
  let readyResolve;
@@ -0,0 +1,210 @@
1
+ // Shared by the Host and settings UI; keep this module browser-compatible.
2
+ export const ACCESS_POLICY_MODES = Object.freeze(['open', 'allowlist']);
3
+ export const ACCESS_POLICY_CONVERSATION_TYPES = Object.freeze(['direct', 'group']);
4
+ export const ACCESS_POLICY_USER_ID_MAX_LENGTH = 256;
5
+
6
+ const POLICY_KEYS = ['direct', 'group'];
7
+ const SCOPE_KEYS = ['mode', 'open', 'allowlist'];
8
+ const OPEN_KEYS = ['defaultCanExecuteCommands', 'commandPermissionOverrides'];
9
+ const ALLOWLIST_KEYS = ['users'];
10
+ const USER_KEYS = ['id', 'canExecuteCommands'];
11
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
12
+
13
+ function invalidAccessPolicy(message) {
14
+ const error = new TypeError(message);
15
+ error.code = 'access-policy-invalid';
16
+ return error;
17
+ }
18
+
19
+ function hasExactKeys(input, keys) {
20
+ return input && typeof input === 'object' && !Array.isArray(input)
21
+ && [Object.prototype, null].includes(Object.getPrototypeOf(input))
22
+ && Reflect.ownKeys(input).length === keys.length
23
+ && keys.every((key) => Object.hasOwn(input, key));
24
+ }
25
+
26
+ /** Normalize one opaque channel identity without interpreting its contents. */
27
+ export function normalizeAccessPolicyUserId(value) {
28
+ if (typeof value === 'number') {
29
+ if (!Number.isFinite(value)) throw invalidAccessPolicy('用户标识无效。');
30
+ value = String(value);
31
+ } else if (typeof value === 'bigint') {
32
+ value = String(value);
33
+ }
34
+ if (typeof value !== 'string') throw invalidAccessPolicy('用户标识必须是字符串。');
35
+ const id = value.trim();
36
+ if (!id || id.length > ACCESS_POLICY_USER_ID_MAX_LENGTH || CONTROL_CHARACTERS.test(id)) {
37
+ throw invalidAccessPolicy(`用户标识不能为空、包含控制字符或超过 ${ACCESS_POLICY_USER_ID_MAX_LENGTH} 个字符。`);
38
+ }
39
+ return id;
40
+ }
41
+
42
+ function validateAccessPolicyUser(input) {
43
+ if (!hasExactKeys(input, USER_KEYS)) {
44
+ throw invalidAccessPolicy('用户条目必须包含用户标识和命令权限。');
45
+ }
46
+ if (typeof input.canExecuteCommands !== 'boolean') {
47
+ throw invalidAccessPolicy('命令权限必须是布尔值。');
48
+ }
49
+ return Object.freeze({
50
+ id: normalizeAccessPolicyUserId(input.id),
51
+ canExecuteCommands: input.canExecuteCommands,
52
+ });
53
+ }
54
+
55
+ function validateUsers(input, { listMessage, duplicateMessage }) {
56
+ if (!Array.isArray(input)) throw invalidAccessPolicy(listMessage);
57
+ const users = input.map(validateAccessPolicyUser);
58
+ if (new Set(users.map(({ id }) => id)).size !== users.length) {
59
+ throw invalidAccessPolicy(duplicateMessage);
60
+ }
61
+ return Object.freeze(users);
62
+ }
63
+
64
+ function validateOpenSettings(input) {
65
+ if (!hasExactKeys(input, OPEN_KEYS)) {
66
+ throw invalidAccessPolicy('开放模式设置必须完整。');
67
+ }
68
+ if (typeof input.defaultCanExecuteCommands !== 'boolean') {
69
+ throw invalidAccessPolicy('开放模式默认命令权限必须是布尔值。');
70
+ }
71
+ return Object.freeze({
72
+ defaultCanExecuteCommands: input.defaultCanExecuteCommands,
73
+ commandPermissionOverrides: validateUsers(input.commandPermissionOverrides, {
74
+ listMessage: '开放模式命令权限覆盖用户必须是数组。',
75
+ duplicateMessage: '开放模式命令权限覆盖用户不能包含重复的用户标识。',
76
+ }),
77
+ });
78
+ }
79
+
80
+ function validateAllowlistSettings(input) {
81
+ if (!hasExactKeys(input, ALLOWLIST_KEYS)) {
82
+ throw invalidAccessPolicy('白名单模式设置必须完整。');
83
+ }
84
+ return Object.freeze({
85
+ users: validateUsers(input.users, {
86
+ listMessage: '白名单模式用户必须是数组。',
87
+ duplicateMessage: '白名单模式用户不能包含重复的用户标识。',
88
+ }),
89
+ });
90
+ }
91
+
92
+ function validateAccessPolicyScope(input) {
93
+ if (!hasExactKeys(input, SCOPE_KEYS)) {
94
+ throw invalidAccessPolicy('访问场景设置必须同时包含模式、开放模式设置和白名单模式设置。');
95
+ }
96
+ if (!ACCESS_POLICY_MODES.includes(input.mode)) {
97
+ throw invalidAccessPolicy('访问模式只能是 open 或 allowlist。');
98
+ }
99
+ return Object.freeze({
100
+ mode: input.mode,
101
+ open: validateOpenSettings(input.open),
102
+ allowlist: validateAllowlistSettings(input.allowlist),
103
+ });
104
+ }
105
+
106
+ /** Validate one canonical direct + group atomic save. */
107
+ export function validateAccessPolicy(input) {
108
+ if (!hasExactKeys(input, POLICY_KEYS)) {
109
+ throw invalidAccessPolicy('请同时提交完整的私聊和群聊访问设置。');
110
+ }
111
+ return Object.freeze({
112
+ direct: validateAccessPolicyScope(input.direct),
113
+ group: validateAccessPolicyScope(input.group),
114
+ });
115
+ }
116
+
117
+ /** Normalize canonical persisted/runtime data; damaged settings fail closed. */
118
+ export function normalizeAccessPolicy(input) {
119
+ try {
120
+ return validateAccessPolicy(input);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /** Small canonical constructor used by channel initialization. */
127
+ export function createAccessPolicyScope(options) {
128
+ return validateAccessPolicyScope(options === undefined ? {
129
+ mode: 'allowlist',
130
+ open: {
131
+ defaultCanExecuteCommands: false,
132
+ commandPermissionOverrides: [],
133
+ },
134
+ allowlist: { users: [] },
135
+ } : options);
136
+ }
137
+
138
+ const DENY_SCOPE = createAccessPolicyScope();
139
+
140
+ export const DEFAULT_ACCESS_POLICY = Object.freeze({
141
+ direct: DENY_SCOPE,
142
+ group: DENY_SCOPE,
143
+ });
144
+
145
+ export const DENY_ALL_ACCESS_POLICY = DEFAULT_ACCESS_POLICY;
146
+
147
+ export function createAccessPolicy({
148
+ direct = DEFAULT_ACCESS_POLICY.direct,
149
+ group = DEFAULT_ACCESS_POLICY.group,
150
+ } = {}) {
151
+ return validateAccessPolicy({ direct, group });
152
+ }
153
+
154
+ const ALLOWED = Object.freeze({ allowed: true, reason: 'allowed' });
155
+ const POLICY_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'policy-unavailable' });
156
+ const INVALID_CONTEXT = Object.freeze({ allowed: false, reason: 'invalid-context' });
157
+ const SENDER_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'sender-unavailable' });
158
+ const SENDER_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'sender-not-allowed' });
159
+ const COMMAND_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'command-not-allowed' });
160
+
161
+ /**
162
+ * Decide access for one ordinary message or one already-recognized command.
163
+ * `senderIds` accepts multiple identities so WhatsApp can reuse its JID aliases.
164
+ * Privileged/owner bypass is intentionally handled by the inbound adapter.
165
+ */
166
+ export function evaluateAccessPolicy(policy, {
167
+ conversationType,
168
+ senderIds,
169
+ isCommand = false,
170
+ equals = (left, right) => left === right,
171
+ } = {}) {
172
+ const normalizedPolicy = normalizeAccessPolicy(policy);
173
+ if (!normalizedPolicy) return POLICY_UNAVAILABLE;
174
+ if (!ACCESS_POLICY_CONVERSATION_TYPES.includes(conversationType)
175
+ || typeof isCommand !== 'boolean' || typeof equals !== 'function') {
176
+ return INVALID_CONTEXT;
177
+ }
178
+ const candidates = (Array.isArray(senderIds) ? senderIds : [senderIds])
179
+ .flatMap((candidate) => {
180
+ try {
181
+ return [normalizeAccessPolicyUserId(candidate)];
182
+ } catch {
183
+ return [];
184
+ }
185
+ });
186
+ if (candidates.length === 0) return SENDER_UNAVAILABLE;
187
+
188
+ const scope = normalizedPolicy[conversationType];
189
+ const users = scope.mode === 'open'
190
+ ? scope.open.commandPermissionOverrides
191
+ : scope.allowlist.users;
192
+ let matchedUsers;
193
+ try {
194
+ matchedUsers = users.filter((user) => (
195
+ candidates.some((candidate) => equals(candidate, user.id) === true)
196
+ ));
197
+ } catch {
198
+ return INVALID_CONTEXT;
199
+ }
200
+ if (scope.mode === 'allowlist' && matchedUsers.length === 0) return SENDER_NOT_ALLOWED;
201
+ if (isCommand) {
202
+ const canExecuteCommands = scope.mode === 'open'
203
+ ? (matchedUsers.length > 0
204
+ ? matchedUsers.every((user) => user.canExecuteCommands)
205
+ : scope.open.defaultCanExecuteCommands)
206
+ : matchedUsers.every((user) => user.canExecuteCommands);
207
+ if (!canExecuteCommands) return COMMAND_NOT_ALLOWED;
208
+ }
209
+ return ALLOWED;
210
+ }