@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.
- package/lib/client.js +951 -626
- package/lib/index.js +233 -233
- package/package.json +1 -1
- package/plugin-src/client/access-policy-settings.js +351 -0
- package/plugin-src/client/channel-card-meta.js +2 -1
- package/plugin-src/client/channels/dingtalk/api.js +5 -0
- package/plugin-src/client/channels/dingtalk/index.js +1 -0
- package/plugin-src/client/channels/feishu/api.js +5 -0
- package/plugin-src/client/channels/feishu/index.js +1 -0
- package/plugin-src/client/channels/qq/api.js +5 -0
- package/plugin-src/client/channels/qq/index.js +1 -0
- package/plugin-src/client/channels/shared/token-api.js +5 -0
- package/plugin-src/client/channels/shared/token-channel.js +1 -0
- package/plugin-src/client/channels/telegram/api.js +2 -17
- package/plugin-src/client/channels/telegram/index.js +0 -112
- package/plugin-src/client/channels/telegram/styles.js +0 -28
- package/plugin-src/client/channels/wecom/api.js +5 -0
- package/plugin-src/client/channels/wecom/index.js +1 -0
- package/plugin-src/client/channels/weixin/api.js +5 -0
- package/plugin-src/client/channels/weixin/index.js +1 -0
- package/plugin-src/client/channels/whatsapp/api.js +4 -10
- package/plugin-src/client/channels/whatsapp/index.js +1 -125
- package/plugin-src/client/channels/whatsapp/styles.js +0 -25
- package/plugin-src/client/delivery-settings.js +61 -9
- package/plugin-src/client/i18n.js +56 -1
- package/plugin-src/client/index.js +1 -0
- package/plugin-src/client/styles.js +50 -4
- package/plugin-src/host/channels/dingtalk/production.mjs +12 -1
- package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
- package/plugin-src/host/channels/feishu/production.mjs +39 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +19 -2
- package/plugin-src/host/channels/qq/production.mjs +12 -1
- package/plugin-src/host/channels/qq/rpc.mjs +11 -0
- package/plugin-src/host/channels/shared/access-policy-production.mjs +137 -0
- package/plugin-src/host/channels/shared/access-policy-rpc.mjs +17 -0
- package/plugin-src/host/channels/shared/production.mjs +15 -1
- package/plugin-src/host/channels/shared/rpc.mjs +9 -0
- package/plugin-src/host/channels/slack/production.mjs +12 -1
- package/plugin-src/host/channels/slack/rpc.mjs +10 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +2 -49
- package/plugin-src/host/channels/wecom/production.mjs +12 -1
- package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
- package/plugin-src/host/channels/weixin/production.mjs +12 -1
- package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +16 -2
- package/plugin-src/host/channels/whatsapp/rpc.mjs +10 -15
- package/src/channels/dingtalk/dingtalk-bridge.mjs +61 -9
- package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -0
- package/src/channels/discord/discord-runtime.mjs +29 -1
- package/src/channels/feishu/bridge.mjs +188 -80
- package/src/channels/feishu/feishu-runtime.mjs +4 -0
- package/src/channels/qq/qq-bridge.mjs +59 -17
- package/src/channels/qq/qq-runtime.mjs +20 -2
- package/src/channels/shared/access-policy.mjs +210 -0
- package/src/channels/shared/bot-workspace-store.mjs +176 -33
- package/src/channels/shared/command-permission.mjs +32 -0
- package/src/channels/shared/compact-command.mjs +5 -2
- package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
- package/src/channels/shared/inbound-access.mjs +43 -0
- package/src/channels/shared/text-harness-bridge.mjs +47 -4
- package/src/channels/shared/workspace-command.mjs +10 -1
- package/src/channels/slack/slack-runtime.mjs +4 -0
- package/src/channels/telegram/telegram-runtime.mjs +5 -13
- package/src/channels/wecom/wecom-bridge.mjs +51 -4
- package/src/channels/wecom/wecom-runtime.mjs +4 -0
- package/src/channels/weixin/weixin-bridge.mjs +59 -16
- package/src/channels/weixin/weixin-runtime.mjs +4 -0
- 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 &&
|
|
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 (
|
|
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 &&
|
|
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 =
|
|
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) =>
|
|
181
|
-
|
|
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
|
+
}
|