@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.
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/lib/client.js +976 -634
- package/lib/index.js +241 -238
- 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/context-enhancement.js +19 -5
- package/plugin-src/client/delivery-settings.js +61 -9
- package/plugin-src/client/i18n.js +61 -2
- 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/plugin-src/host/modern-harness-api.mjs +3 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +70 -12
- package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -0
- package/src/channels/discord/discord-runtime.mjs +29 -1
- package/src/channels/feishu/bridge.mjs +207 -82
- package/src/channels/feishu/feishu-cards.mjs +2 -2
- package/src/channels/feishu/feishu-channel.mjs +36 -6
- package/src/channels/feishu/feishu-runtime.mjs +4 -0
- package/src/channels/qq/qq-bridge.mjs +67 -20
- 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 +181 -33
- package/src/channels/shared/command-permission.mjs +32 -0
- package/src/channels/shared/compact-command.mjs +5 -2
- package/src/channels/shared/context-enhancement.mjs +9 -4
- package/src/channels/shared/harness-client.mjs +6 -0
- package/src/channels/shared/i18n-en/feishu.mjs +3 -3
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
- package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
- package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
- package/src/channels/shared/inbound-access.mjs +43 -0
- package/src/channels/shared/session-title.mjs +74 -0
- package/src/channels/shared/text-harness-bridge.mjs +54 -6
- package/src/channels/shared/workspace-command.mjs +26 -5
- package/src/channels/shared/workspace-session.mjs +28 -2
- package/src/channels/slack/slack-runtime.mjs +4 -0
- package/src/channels/telegram/telegram-runtime.mjs +6 -13
- package/src/channels/wecom/wecom-bridge.mjs +57 -6
- package/src/channels/wecom/wecom-runtime.mjs +4 -0
- package/src/channels/weixin/weixin-bridge.mjs +67 -19
- package/src/channels/weixin/weixin-runtime.mjs +4 -0
- 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
|
-
|
|
210
|
-
cards.push(
|
|
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
|
|
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
|
|
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
|
-
?
|
|
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:
|
|
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 &&
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
+
}
|