@xmanrui/dsh-im 4.3.0 → 4.5.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 (81) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/lib/client.js +976 -634
  4. package/lib/index.js +241 -238
  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 +19 -5
  27. package/plugin-src/client/delivery-settings.js +61 -9
  28. package/plugin-src/client/i18n.js +61 -2
  29. package/plugin-src/client/index.js +1 -0
  30. package/plugin-src/client/styles.js +50 -4
  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/plugin-src/host/modern-harness-api.mjs +3 -0
  51. package/src/channels/dingtalk/dingtalk-bridge.mjs +70 -12
  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 +207 -82
  55. package/src/channels/feishu/feishu-cards.mjs +2 -2
  56. package/src/channels/feishu/feishu-channel.mjs +36 -6
  57. package/src/channels/feishu/feishu-runtime.mjs +4 -0
  58. package/src/channels/qq/qq-bridge.mjs +67 -20
  59. package/src/channels/qq/qq-runtime.mjs +20 -2
  60. package/src/channels/shared/access-policy.mjs +210 -0
  61. package/src/channels/shared/bot-workspace-store.mjs +181 -33
  62. package/src/channels/shared/command-permission.mjs +32 -0
  63. package/src/channels/shared/compact-command.mjs +5 -2
  64. package/src/channels/shared/context-enhancement.mjs +9 -4
  65. package/src/channels/shared/harness-client.mjs +6 -0
  66. package/src/channels/shared/i18n-en/feishu.mjs +3 -3
  67. package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
  68. package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
  69. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  70. package/src/channels/shared/inbound-access.mjs +43 -0
  71. package/src/channels/shared/session-title.mjs +74 -0
  72. package/src/channels/shared/text-harness-bridge.mjs +54 -6
  73. package/src/channels/shared/workspace-command.mjs +26 -5
  74. package/src/channels/shared/workspace-session.mjs +28 -2
  75. package/src/channels/slack/slack-runtime.mjs +4 -0
  76. package/src/channels/telegram/telegram-runtime.mjs +6 -13
  77. package/src/channels/wecom/wecom-bridge.mjs +57 -6
  78. package/src/channels/wecom/wecom-runtime.mjs +4 -0
  79. package/src/channels/weixin/weixin-bridge.mjs +67 -19
  80. package/src/channels/weixin/weixin-runtime.mjs +4 -0
  81. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -20
@@ -490,7 +490,7 @@ export function menuHelpText() {
490
490
  '/sessionlist 或 /sessions 列出工作区会话',
491
491
  '/session ID 绑定已有会话',
492
492
  '/workspacelist 列出工作区',
493
- '/workspace 路径 切换工作区',
493
+ '/workspace 工作区序号或绝对路径 切换工作区',
494
494
  '/new 开启全新会话',
495
495
  '',
496
496
  '📊 状态 / 压缩',
@@ -554,7 +554,7 @@ const HELP_TEXT_COMMANDS = [
554
554
  '`/new` — 开启全新会话',
555
555
  '`/session ID` — 绑定已有会话',
556
556
  '`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话',
557
- '`/workspace 路径` — 切换工作区',
557
+ '`/workspace 工作区序号或绝对路径` — 切换工作区',
558
558
  '`/workspacelist` — 列出工作区',
559
559
  '`/status` — 查看连接状态',
560
560
  '`/compact` — 压缩上下文',
@@ -205,15 +205,45 @@ export class VerifiedFeishuChannel {
205
205
  }
206
206
 
207
207
  const cards = [];
208
+ let activeCard = null;
209
+ let rotating = false;
208
210
  try {
209
- const firstCard = await this.#createStreamCard(chatId, options.replyTo);
210
- cards.push(firstCard);
211
+ activeCard = await this.#createStreamCard(chatId, options.replyTo);
212
+ cards.push(activeCard);
211
213
  let lastContent = this.#initialText;
214
+ // issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
215
+ // 回写旧卡。rotate() 把旧卡定格为「过程记录 + 指引行」并标记换卡态;
216
+ // 下一次 setContent(过程更新或最终答案)才创建新卡——新卡必然创建于
217
+ // 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
218
+ const ensureActiveCard = async () => {
219
+ if (!rotating) return activeCard;
220
+ activeCard = await this.#createStreamCard(chatId, options.replyTo);
221
+ cards.push(activeCard);
222
+ rotating = false;
223
+ return activeCard;
224
+ };
212
225
  const controller = {
213
- messageId: firstCard.messageId,
226
+ get messageId() {
227
+ return activeCard.messageId;
228
+ },
229
+ rotate: async () => {
230
+ if (rotating) return;
231
+ rotating = true;
232
+ try {
233
+ await this.#updateStreamCard(
234
+ activeCard,
235
+ `${streamPreview(lastContent)}\n\n${t('⤵️ 最终结果见下方')}`,
236
+ );
237
+ await this.#finishStreamCard(activeCard);
238
+ } catch (error) {
239
+ // 明确降级:定格失败不阻塞交互呈现,旧卡保留原内容。
240
+ console.warn('[dsh-feishu] unable to finalize the superseded stream card:', error.message);
241
+ }
242
+ },
214
243
  setContent: async (content) => {
215
244
  const next = String(content ?? '') || '…';
216
- await this.#updateStreamCard(firstCard, streamPreview(next));
245
+ const card = await ensureActiveCard();
246
+ await this.#updateStreamCard(card, streamPreview(next));
217
247
  // Updates are replaceable snapshots, including progress/tool text.
218
248
  // Retain the full latest snapshot even when its preview is unchanged.
219
249
  lastContent = next;
@@ -224,14 +254,14 @@ export class VerifiedFeishuChannel {
224
254
  const chunks = splitStreamContent(lastContent);
225
255
  for (const [index, chunk] of chunks.entries()) {
226
256
  const card = index === 0
227
- ? firstCard
257
+ ? await ensureActiveCard()
228
258
  : await this.#createStreamCard(chatId, options.replyTo);
229
259
  if (index > 0) cards.push(card);
230
260
  await this.#updateStreamCard(card, chunk);
231
261
  await this.#finishStreamCard(card);
232
262
  }
233
263
  return {
234
- messageId: firstCard.messageId,
264
+ messageId: cards[0].messageId,
235
265
  providerMessageIds: cards.map((card) => card.messageId),
236
266
  };
237
267
  } catch (error) {
@@ -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
 
@@ -81,7 +85,7 @@ function helpText() {
81
85
  t('/new 开启一个全新会话'),
82
86
  t('/compact 压缩当前会话的较早上下文'),
83
87
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
84
- t('/workspace 工作区绝对路径 切换工作区'),
88
+ t('/workspace 工作区序号或绝对路径 切换工作区'),
85
89
  t('/workspacelist 列出工作区绝对路径'),
86
90
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
87
91
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
@@ -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;
@@ -798,12 +840,15 @@ export class QqHarnessBridge {
798
840
  ? await promptContentForMessage(promptMessage, { signal: this.#signal })
799
841
  : undefined;
800
842
  const snapshot = this.#acceptedMessageIds.get(messageId);
843
+ let contextEnhanced = false;
801
844
  if (snapshot) {
802
- content = enhanceContextContent(content ?? text, snapshot, () => ({
845
+ const originalContent = content ?? text;
846
+ content = enhanceContextContent(originalContent, snapshot, () => ({
803
847
  channel: 'qq',
804
848
  senderId: sender,
805
849
  senderName: message.kind === 'group' ? message.senderName : undefined,
806
850
  }));
851
+ contextEnhanced = content !== originalContent;
807
852
  }
808
853
  // QQ stream_messages can acknowledge a final frame without rendering it in
809
854
  // some C2C clients. Standard Markdown delivery is the reliable reply path.
@@ -818,7 +863,9 @@ export class QqHarnessBridge {
818
863
  harness: this.#harness,
819
864
  state: this.#state,
820
865
  key,
821
- ...(content !== undefined ? { content } : { text }),
866
+ text,
867
+ content,
868
+ contextEnhanced,
822
869
  createOptions: { signal: this.#signal },
823
870
  existsOptions: { signal: this.#signal },
824
871
  askOptions: {
@@ -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
+ }