@openclaw/qqbot 2026.5.7 → 2026.5.10-beta.1

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.
@@ -1,49 +1,30 @@
1
- import { E as setOutboundAudioPort, _ as sendVoice, c as isMediaPayload, g as sendVideoMsg, h as sendPhoto, l as parseQQBotPayload, m as sendDocument, o as encodePayloadForCron, r as sendMedia, s as isCronReminderPayload, u as normalizeMediaTags, v as resolveUserFacingMediaError, w as DEFAULT_MEDIA_SEND_ERROR } from "./outbound-BJfhwrPg.js";
2
- import { c as getPlatformAdapter, i as normalizeOptionalString, n as normalizeLowercaseStringOrEmpty, o as readStringField, s as sanitizeFileName, t as asOptionalObjectRecord } from "./string-normalize-Ci6NM5DE.js";
3
- import { d as setBridgeLogger, l as ensurePlatformAdapter, s as resolveAccountBase } from "./resolve-D_06fV6-.js";
1
+ import { E as setOutboundAudioPort, _ as sendVoice, c as isMediaPayload, g as sendVideoMsg, h as sendPhoto, l as parseQQBotPayload, m as sendDocument, o as encodePayloadForCron, r as sendMedia, s as isCronReminderPayload, u as normalizeMediaTags, v as resolveUserFacingMediaError, w as DEFAULT_MEDIA_SEND_ERROR } from "./outbound-DL154TS6.js";
2
+ import { c as getPlatformAdapter, i as normalizeOptionalString, n as normalizeLowercaseStringOrEmpty, o as readStringField, s as sanitizeFileName, t as asOptionalObjectRecord } from "./string-normalize-BzzzPOeb.js";
3
+ import { d as setBridgeLogger, l as ensurePlatformAdapter, s as resolveAccountBase } from "./resolve-Cm33TWZp.js";
4
4
  import { i as parseApprovalButtonData } from "./approval-cg0SVahb.js";
5
5
  import { t as toGatewayAccount } from "./narrowing-BoieBTIU.js";
6
- import { B as StreamInputState, C as debugLog, E as getNextMsgSeq, I as formatDuration, L as formatErrorMessage, N as getImageMimeType, P as getMaxUploadSize, R as StreamContentType, S as debugError, a as createRawInputNotifyFn, b as withTokenRetry, c as getMessageApi, d as initSender, f as onMessageSent, g as sendText, h as sendMedia$1, i as clearTokenCache, j as formatFileSize, k as downloadFile, l as getPluginUserAgent, m as sendInputNotify, n as acknowledgeInteraction, o as getAccessToken, p as registerAccount, r as buildDeliveryTarget, s as getGatewayUrl, t as accountToCreds, u as initApiConfig, v as startBackgroundTokenRefresh, w as debugWarn, x as openLocalFile, y as stopBackgroundTokenRefresh, z as StreamInputMode } from "./sender-p-B14eLG.js";
7
- import { n as getQQBotRuntimeForEngine, t as getQQBotRuntime } from "./runtime-BJAS3eXW.js";
8
- import { a as getHomeDir, c as getQQBotMediaDir, d as isLocalPath, f as isWindows, i as checkSilkWasmAvailable, m as resolveQQBotPayloadLocalFilePath, o as getQQBotDataDir, p as normalizePath$1, s as getQQBotDataPath, u as getTempDir } from "./target-parser-Y0prnrXD.js";
9
- import { a as getPluginVersion, i as getFrameworkVersion, n as runWithRequestContext, o as initCommands, s as matchSlashCommand } from "./request-context-Cjwx7OwW.js";
6
+ import { B as StreamInputState, C as debugWarn, D as openLocalFile, I as formatDuration, L as formatErrorMessage, N as getImageMimeType, P as getMaxUploadSize, R as StreamContentType, S as debugLog, T as getNextMsgSeq, a as createRawInputNotifyFn, b as withTokenRetry, c as getMessageApi, d as initSender, f as onMessageSent, g as sendText, h as sendMedia$1, i as clearTokenCache, j as formatFileSize, k as downloadFile, l as getPluginUserAgent, m as sendInputNotify, n as acknowledgeInteraction, o as getAccessToken, p as registerAccount, r as buildDeliveryTarget, s as getGatewayUrl, t as accountToCreds, u as initApiConfig, v as startBackgroundTokenRefresh, x as debugError, y as stopBackgroundTokenRefresh, z as StreamInputMode } from "./sender-C-TmIFow.js";
7
+ import { n as getQQBotRuntimeForEngine, t as getQQBotRuntime } from "./runtime-CHGblcVc.js";
8
+ import { a as getHomeDir, c as getQQBotMediaDir, d as isLocalPath, f as isWindows, i as checkSilkWasmAvailable, m as resolveQQBotPayloadLocalFilePath, o as getQQBotDataDir, p as normalizePath$1, s as getQQBotDataPath, u as getTempDir } from "./target-parser-CTNFSKb1.js";
9
+ import { a as getPluginVersion, i as getFrameworkVersion, n as runWithRequestContext, o as initCommands, s as matchSlashCommand } from "./request-context-Bn63CUkr.js";
10
10
  import * as fs$1 from "node:fs";
11
11
  import fs from "node:fs";
12
12
  import * as os$1 from "node:os";
13
13
  import crypto from "node:crypto";
14
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
15
+ import { appendRegularFileSync, privateFileStoreSync, readRegularFileSync, replaceFileAtomicSync } from "openclaw/plugin-sdk/security-runtime";
14
16
  import * as path$1 from "node:path";
15
17
  import path from "node:path";
18
+ import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
16
19
  import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime";
17
20
  import WebSocket from "ws";
21
+ import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared";
18
22
  import { Buffer as Buffer$1 } from "node:buffer";
19
23
  import { fileURLToPath } from "node:url";
20
- import { implicitMentionKindWhen, resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-mention-gating";
24
+ import { createChannelIngressResolver, defineStableChannelIngressIdentity } from "openclaw/plugin-sdk/channel-ingress-runtime";
25
+ import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-mention-gating";
21
26
  import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history";
22
27
  //#region extensions/qqbot/src/engine/group/activation.ts
23
- /**
24
- * Group activation mode — how the bot decides whether to respond in a group.
25
- *
26
- * Resolution chain:
27
- * 1. session store override (`/activation` command writes per-session
28
- * `groupActivation` value) — highest priority
29
- * 2. per-group `requireMention` config
30
- * 3. `"mention"` default (require @-bot to respond)
31
- *
32
- * File I/O is isolated in the default node-based reader so the gating
33
- * logic itself stays a pure function, testable without touching disk.
34
- *
35
- * Note: the implicit-mention predicate (quoting a bot message counts as
36
- * @-ing the bot) lives in `./mention.ts` alongside the other mention
37
- * helpers — see `resolveImplicitMention` there.
38
- */
39
- /**
40
- * Resolve the effective activation mode for one inbound message.
41
- *
42
- * Order of precedence:
43
- * 1. `store[sessionKey].groupActivation` (read via the injected reader)
44
- * 2. config-level `requireMention` (maps to `"mention"` / `"always"`)
45
- * 3. `"mention"` (safe default)
46
- */
47
28
  function resolveGroupActivation(params) {
48
29
  const fallback = params.configRequireMention ? "mention" : "always";
49
30
  const store = params.sessionStoreReader?.read({
@@ -57,14 +38,6 @@ function resolveGroupActivation(params) {
57
38
  if (normalized === "mention" || normalized === "always") return normalized;
58
39
  return fallback;
59
40
  }
60
- /**
61
- * Resolve the on-disk path to the agent-sessions file.
62
- *
63
- * Priority:
64
- * 1. `cfg.session.store` (supports `{agentId}` placeholder and `~` expansion)
65
- * 2. `$OPENCLAW_STATE_DIR` / `$CLAWDBOT_STATE_DIR`
66
- * 3. `~/.openclaw/agents/{agentId}/sessions/sessions.json`
67
- */
68
41
  function resolveSessionStorePath(cfg, agentId) {
69
42
  const resolvedAgentId = agentId || "default";
70
43
  const session = typeof cfg.session === "object" && cfg.session !== null ? cfg.session : void 0;
@@ -81,16 +54,6 @@ function resolveSessionStorePath(cfg, agentId) {
81
54
  const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path.join(process.env.HOME || process.env.USERPROFILE || "", ".openclaw");
82
55
  return path.join(stateDir, "agents", resolvedAgentId, "sessions", "sessions.json");
83
56
  }
84
- /**
85
- * Create the default, production-ready session-store reader.
86
- *
87
- * Reads the file synchronously on every call. The overhead is acceptable
88
- * because activation mode is only resolved once per group message and
89
- * the sessions file is typically a handful of kilobytes.
90
- *
91
- * Any I/O or JSON error is swallowed and returned as `null` so the
92
- * gating pipeline falls back to the config default.
93
- */
94
57
  function createNodeSessionStoreReader() {
95
58
  return { read: ({ cfg, agentId }) => {
96
59
  try {
@@ -258,7 +221,10 @@ function ensureDir$2() {
258
221
  function appendLine(line) {
259
222
  try {
260
223
  ensureDir$2();
261
- fs.appendFileSync(getRefIndexFile(), JSON.stringify(line) + "\n", "utf-8");
224
+ appendRegularFileSync({
225
+ filePath: getRefIndexFile(),
226
+ content: JSON.stringify(line) + "\n"
227
+ });
262
228
  totalLinesOnDisk++;
263
229
  } catch (err) {
264
230
  debugError(`[ref-index-store] Failed to append: ${formatErrorMessage(err)}`);
@@ -273,7 +239,6 @@ function compactFile() {
273
239
  try {
274
240
  ensureDir$2();
275
241
  const refIndexFile = getRefIndexFile();
276
- const tmpPath = refIndexFile + ".tmp";
277
242
  const lines = [];
278
243
  for (const [key, entry] of cache) lines.push(JSON.stringify({
279
244
  k: key,
@@ -287,8 +252,11 @@ function compactFile() {
287
252
  },
288
253
  t: entry._createdAt
289
254
  }));
290
- fs.writeFileSync(tmpPath, lines.join("\n") + "\n", "utf-8");
291
- fs.renameSync(tmpPath, refIndexFile);
255
+ replaceFileAtomicSync({
256
+ filePath: refIndexFile,
257
+ content: `${lines.join("\n")}\n`,
258
+ tempPrefix: ".qqbot-ref-index"
259
+ });
292
260
  totalLinesOnDisk = cache.size;
293
261
  debugLog(`[ref-index-store] Compacted: ${before} lines → ${totalLinesOnDisk} lines`);
294
262
  } catch (err) {
@@ -405,27 +373,6 @@ async function runDiagnostics() {
405
373
  return report;
406
374
  }
407
375
  //#endregion
408
- //#region extensions/qqbot/src/engine/access/resolve-policy.ts
409
- function hasRealRestriction(list) {
410
- if (!list || list.length === 0) return false;
411
- return !list.every((entry) => String(entry).trim() === "*");
412
- }
413
- /**
414
- * Derive the effective dmPolicy and groupPolicy applied at runtime.
415
- *
416
- * Caller should pass the raw `QQBotAccountConfig`. The resolver does
417
- * not look at `groups[id]` overrides — per-group overrides are layered
418
- * on top elsewhere (see `inbound-pipeline` mention gating).
419
- */
420
- function resolveQQBotEffectivePolicies(input) {
421
- const allowFromRestricted = hasRealRestriction(input.allowFrom);
422
- const groupAllowFromRestricted = hasRealRestriction(input.groupAllowFrom);
423
- return {
424
- dmPolicy: input.dmPolicy ?? (allowFromRestricted ? "allowlist" : "open"),
425
- groupPolicy: input.groupPolicy ?? (groupAllowFromRestricted || allowFromRestricted ? "allowlist" : "open")
426
- };
427
- }
428
- //#endregion
429
376
  //#region extensions/qqbot/src/engine/access/sender-match.ts
430
377
  /**
431
378
  * QQBot sender normalization and allowlist matching.
@@ -465,177 +412,13 @@ function createQQBotSenderMatcher(senderId) {
465
412
  };
466
413
  }
467
414
  //#endregion
468
- //#region extensions/qqbot/src/engine/access/types.ts
469
- /** Structured reason codes used in logs and metrics. */
470
- const QQBOT_ACCESS_REASON = {
471
- DM_POLICY_OPEN: "dm_policy_open",
472
- DM_POLICY_DISABLED: "dm_policy_disabled",
473
- DM_POLICY_ALLOWLISTED: "dm_policy_allowlisted",
474
- DM_POLICY_NOT_ALLOWLISTED: "dm_policy_not_allowlisted",
475
- DM_POLICY_EMPTY_ALLOWLIST: "dm_policy_empty_allowlist",
476
- GROUP_POLICY_ALLOWED: "group_policy_allowed",
477
- GROUP_POLICY_DISABLED: "group_policy_disabled",
478
- GROUP_POLICY_EMPTY_ALLOWLIST: "group_policy_empty_allowlist",
479
- GROUP_POLICY_NOT_ALLOWLISTED: "group_policy_not_allowlisted",
480
- BOT_SELF_ECHO: "bot_self_echo"
481
- };
482
- //#endregion
483
- //#region extensions/qqbot/src/engine/access/access-control.ts
484
- /**
485
- * QQBot inbound access decision.
486
- *
487
- * This module is the single place where the QQBot engine decides
488
- * whether an inbound message from a given sender is allowed to
489
- * proceed into the outbound pipeline. The implementation mirrors the
490
- * semantics of the framework-wide `resolveDmGroupAccessDecision`
491
- * (`src/security/dm-policy-shared.ts`) but is kept standalone so the
492
- * `engine/` layer does not pull in `openclaw/plugin-sdk/*` modules —
493
- * a hard constraint shared with the standalone `openclaw-qqbot` build.
494
- *
495
- * If in the future we lift the zero-dependency rule in the engine
496
- * layer, this file can be replaced by a thin adapter around the
497
- * framework API with identical semantics.
498
- */
499
- /**
500
- * Evaluate the inbound access policy.
501
- *
502
- * Semantics (aligned with `resolveDmGroupAccessDecision`):
503
- * - Group message:
504
- * - `groupPolicy=disabled` → block
505
- * - `groupPolicy=open` → allow
506
- * - `groupPolicy=allowlist`:
507
- * - empty effectiveGroupAllowFrom → block (empty_allowlist)
508
- * - sender not in list → block (not_allowlisted)
509
- * - otherwise → allow
510
- * - Direct message:
511
- * - `dmPolicy=disabled` → block
512
- * - `dmPolicy=open` → allow wildcard, legacy empty allowFrom, or matching allowFrom
513
- * - `dmPolicy=allowlist`:
514
- * - empty effectiveAllowFrom → block (empty_allowlist)
515
- * - sender not in list → block (not_allowlisted)
516
- * - otherwise → allow
517
- *
518
- * The function never throws; callers can rely on the returned
519
- * `decision`/`reasonCode` pair for branching.
520
- */
521
- function resolveQQBotAccess(input) {
522
- const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input);
523
- const rawGroupAllowFrom = input.groupAllowFrom && input.groupAllowFrom.length > 0 ? input.groupAllowFrom : input.allowFrom ?? [];
524
- const normalizedAllowFrom = normalizeQQBotAllowFrom(input.allowFrom);
525
- const effectiveAllowFrom = dmPolicy === "open" && normalizedAllowFrom.length === 0 ? ["*"] : normalizedAllowFrom;
526
- const effectiveGroupAllowFrom = normalizeQQBotAllowFrom(rawGroupAllowFrom);
527
- const isSenderAllowed = createQQBotSenderMatcher(input.senderId);
528
- if (input.isGroup) return evaluateGroupDecision({
529
- groupPolicy,
530
- dmPolicy,
531
- effectiveAllowFrom,
532
- effectiveGroupAllowFrom,
533
- isSenderAllowed
534
- });
535
- return evaluateDmDecision({
536
- groupPolicy,
537
- dmPolicy,
538
- effectiveAllowFrom,
539
- effectiveGroupAllowFrom,
540
- isSenderAllowed
541
- });
542
- }
543
- function evaluateGroupDecision(ctx) {
544
- const base = buildResultBase(ctx);
545
- if (ctx.groupPolicy === "disabled") return {
546
- ...base,
547
- decision: "block",
548
- reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED,
549
- reason: "groupPolicy=disabled"
550
- };
551
- if (ctx.groupPolicy === "open") return {
552
- ...base,
553
- decision: "allow",
554
- reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED,
555
- reason: "groupPolicy=open"
556
- };
557
- if (ctx.effectiveGroupAllowFrom.length === 0) return {
558
- ...base,
559
- decision: "block",
560
- reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
561
- reason: "groupPolicy=allowlist (empty allowlist)"
562
- };
563
- if (!ctx.isSenderAllowed(ctx.effectiveGroupAllowFrom)) return {
564
- ...base,
565
- decision: "block",
566
- reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
567
- reason: "groupPolicy=allowlist (not allowlisted)"
568
- };
569
- return {
570
- ...base,
571
- decision: "allow",
572
- reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED,
573
- reason: "groupPolicy=allowlist (allowlisted)"
574
- };
575
- }
576
- function evaluateDmDecision(ctx) {
577
- const base = buildResultBase(ctx);
578
- if (ctx.dmPolicy === "disabled") return {
579
- ...base,
580
- decision: "block",
581
- reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_DISABLED,
582
- reason: "dmPolicy=disabled"
583
- };
584
- if (ctx.dmPolicy === "open") {
585
- if (ctx.effectiveAllowFrom.includes("*")) return {
586
- ...base,
587
- decision: "allow",
588
- reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN,
589
- reason: "dmPolicy=open"
590
- };
591
- if (ctx.isSenderAllowed(ctx.effectiveAllowFrom)) return {
592
- ...base,
593
- decision: "allow",
594
- reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
595
- reason: "dmPolicy=open (allowlisted)"
596
- };
597
- return {
598
- ...base,
599
- decision: "block",
600
- reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
601
- reason: "dmPolicy=open (not allowlisted)"
602
- };
603
- }
604
- if (ctx.effectiveAllowFrom.length === 0) return {
605
- ...base,
606
- decision: "block",
607
- reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST,
608
- reason: "dmPolicy=allowlist (empty allowlist)"
609
- };
610
- if (!ctx.isSenderAllowed(ctx.effectiveAllowFrom)) return {
611
- ...base,
612
- decision: "block",
613
- reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
614
- reason: "dmPolicy=allowlist (not allowlisted)"
615
- };
616
- return {
617
- ...base,
618
- decision: "allow",
619
- reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
620
- reason: "dmPolicy=allowlist (allowlisted)"
621
- };
622
- }
623
- function buildResultBase(ctx) {
624
- return {
625
- effectiveAllowFrom: ctx.effectiveAllowFrom,
626
- effectiveGroupAllowFrom: ctx.effectiveGroupAllowFrom,
627
- dmPolicy: ctx.dmPolicy,
628
- groupPolicy: ctx.groupPolicy
629
- };
630
- }
631
- //#endregion
632
415
  //#region extensions/qqbot/src/engine/commands/slash-command-auth.ts
633
416
  /**
634
417
  * Pre-dispatch authorization for requireAuth slash commands.
635
418
  *
636
- * Unlike the access-stage's `resolveCommandAuthorized` (which permits
637
- * `dm_policy_open` senders — i.e. anyone), this function requires the
638
- * sender to appear in an **explicit non-wildcard** allowFrom list.
419
+ * Unlike the inbound message ingress command projection (which permits
420
+ * open-policy chat senders), this function requires the sender to appear in an
421
+ * **explicit non-wildcard** allowFrom list.
639
422
  *
640
423
  * Rationale: sensitive operations (log export, file deletion, approval
641
424
  * config changes) must be gated behind a deliberate operator decision.
@@ -704,6 +487,22 @@ async function trySlashCommand(msg, ctx) {
704
487
  }
705
488
  const receivedAt = Date.now();
706
489
  const peerId = ctx.getMessagePeerId(msg);
490
+ const isGroup = msg.type === "group" || msg.type === "guild";
491
+ const commandsAllowFrom = resolveQQBotCommandsAllowFrom(ctx.cfg);
492
+ const commandAuthorized = ctx.resolveCommandAuthorized ? await ctx.resolveCommandAuthorized({
493
+ isGroup,
494
+ senderId: msg.senderId,
495
+ conversationId: msg.groupOpenid ?? msg.channelId ?? msg.senderId,
496
+ allowFrom: account.config?.allowFrom,
497
+ groupAllowFrom: account.config?.groupAllowFrom,
498
+ commandsAllowFrom
499
+ }) : resolveSlashCommandAuth({
500
+ senderId: msg.senderId,
501
+ isGroup,
502
+ allowFrom: account.config?.allowFrom,
503
+ groupAllowFrom: account.config?.groupAllowFrom,
504
+ commandsAllowFrom
505
+ });
707
506
  const cmdCtx = {
708
507
  type: msg.type,
709
508
  senderId: msg.senderId,
@@ -718,13 +517,7 @@ async function trySlashCommand(msg, ctx) {
718
517
  accountId: account.accountId,
719
518
  appId: account.appId,
720
519
  accountConfig: account.config,
721
- commandAuthorized: resolveSlashCommandAuth({
722
- senderId: msg.senderId,
723
- isGroup: msg.type === "group" || msg.type === "guild",
724
- allowFrom: account.config?.allowFrom,
725
- groupAllowFrom: account.config?.groupAllowFrom,
726
- commandsAllowFrom: resolveQQBotCommandsAllowFrom(ctx.cfg)
727
- }),
520
+ commandAuthorized,
728
521
  queueSnapshot: ctx.getQueueSnapshot(peerId)
729
522
  };
730
523
  try {
@@ -778,9 +571,8 @@ function loadUsersFromFile() {
778
571
  usersCache = /* @__PURE__ */ new Map();
779
572
  try {
780
573
  const knownUsersFile = getKnownUsersFile();
781
- if (fs.existsSync(knownUsersFile)) {
782
- const data = fs.readFileSync(knownUsersFile, "utf-8");
783
- const users = JSON.parse(data);
574
+ const users = privateFileStoreSync(path.dirname(knownUsersFile)).readJsonIfExists(path.basename(knownUsersFile));
575
+ if (users) {
784
576
  for (const user of users) usersCache.set(makeUserKey(user), user);
785
577
  debugLog(`[known-users] Loaded ${usersCache.size} users`);
786
578
  }
@@ -801,7 +593,8 @@ function doSaveUsersToFile() {
801
593
  if (!usersCache || !isDirty) return;
802
594
  try {
803
595
  ensureDir$1();
804
- fs.writeFileSync(getKnownUsersFile(), JSON.stringify(Array.from(usersCache.values()), null, 2), "utf-8");
596
+ const filePath = getKnownUsersFile();
597
+ privateFileStoreSync(path.dirname(filePath)).writeJson(path.basename(filePath), Array.from(usersCache.values()));
805
598
  isDirty = false;
806
599
  } catch (err) {
807
600
  debugError(`[known-users] Failed to save users: ${formatErrorMessage(err)}`);
@@ -878,13 +671,15 @@ function getCandidateSessionPaths(accountId) {
878
671
  function loadSession(accountId, expectedAppId) {
879
672
  try {
880
673
  let filePath = null;
881
- for (const candidatePath of getCandidateSessionPaths(accountId)) if (fs.existsSync(candidatePath)) {
882
- filePath = candidatePath;
883
- break;
674
+ let state = null;
675
+ for (const candidatePath of getCandidateSessionPaths(accountId)) {
676
+ state = privateFileStoreSync(path.dirname(candidatePath)).readJsonIfExists(path.basename(candidatePath));
677
+ if (state) {
678
+ filePath = candidatePath;
679
+ break;
680
+ }
884
681
  }
885
- if (!filePath) return null;
886
- const data = fs.readFileSync(filePath, "utf-8");
887
- const state = JSON.parse(data);
682
+ if (!filePath || !state) return null;
888
683
  const now = Date.now();
889
684
  if (now - state.savedAt > SESSION_EXPIRE_TIME) {
890
685
  debugLog(`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1e3)}s`);
@@ -958,7 +753,7 @@ function doSaveSession(state) {
958
753
  ...state,
959
754
  savedAt: Date.now()
960
755
  };
961
- fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
756
+ privateFileStoreSync(path.dirname(filePath)).writeJson(path.basename(filePath), stateToSave);
962
757
  if (legacyPath !== filePath && fs.existsSync(legacyPath)) fs.unlinkSync(legacyPath);
963
758
  debugLog(`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`);
964
759
  } catch (err) {
@@ -1334,43 +1129,16 @@ function buildGroupQueuedMessage(data, accountId, eventType) {
1334
1129
  }
1335
1130
  //#endregion
1336
1131
  //#region extensions/qqbot/src/engine/gateway/message-queue.ts
1337
- /**
1338
- * Per-user concurrent message queue.
1339
- *
1340
- * Messages are serialized per **peer** (one DM user, one group, one guild
1341
- * channel) and processed in parallel across peers up to
1342
- * {@link DEFAULT_MAX_CONCURRENT_USERS}.
1343
- *
1344
- * Group-specific enhancements (added when merging from the standalone build):
1345
- * - Group peers have a larger queue cap ({@link DEFAULT_GROUP_QUEUE_SIZE})
1346
- * because groups can burst more chatter than a single DM.
1347
- * - When a group's queue overflows, bot-authored messages are evicted
1348
- * preferentially so human messages don't get dropped.
1349
- * - When draining a group peer with more than one queued message, the
1350
- * non-command messages are **merged** into one logical turn (see
1351
- * {@link mergeGroupMessages}). Slash commands are always processed
1352
- * individually to avoid conflating a "/stop" with surrounding chatter.
1353
- *
1354
- * The module is self-contained: the only injected dependency is the
1355
- * logger / abort probe supplied via {@link MessageQueueContext}.
1356
- */
1357
- /** Global cap across all peers. */
1358
1132
  const DEFAULT_GLOBAL_QUEUE_SIZE = 1e3;
1359
- /** Per-DM / per-channel cap. */
1360
1133
  const DEFAULT_PER_PEER_QUEUE_SIZE = 20;
1361
- /** Per-group cap — larger because groups burst more. */
1362
1134
  const DEFAULT_GROUP_QUEUE_SIZE = 50;
1363
- /** Parallel fanout across peers. */
1364
1135
  const DEFAULT_MAX_CONCURRENT_USERS = 10;
1365
- /** Convenience predicate: is this a merged multi-message turn? */
1366
1136
  function isMergedTurn(msg) {
1367
1137
  return (msg.merge?.count ?? 0) > 1;
1368
1138
  }
1369
- /** Return true when the peer id refers to a group-like conversation. */
1370
1139
  function isGroupPeer(peerId) {
1371
1140
  return peerId.startsWith("group:") || peerId.startsWith("guild:");
1372
1141
  }
1373
- /** Slash-command test used by {@link drainGroupBatch}. */
1374
1142
  function isSlashCommand(msg) {
1375
1143
  return (msg.content ?? "").trim().startsWith("/");
1376
1144
  }
@@ -1437,9 +1205,6 @@ function mergeGroupMessages(batch) {
1437
1205
  }
1438
1206
  };
1439
1207
  }
1440
- /**
1441
- * Create a per-user concurrent queue with built-in group enhancements.
1442
- */
1443
1208
  function createMessageQueue(ctx) {
1444
1209
  const { accountId: _accountId, log } = ctx;
1445
1210
  const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE;
@@ -1455,13 +1220,6 @@ function createMessageQueue(ctx) {
1455
1220
  if (msg.type === "group") return `group:${msg.groupOpenid ?? "unknown"}`;
1456
1221
  return `dm:${msg.senderId}`;
1457
1222
  };
1458
- /**
1459
- * Evict one message from an over-full queue.
1460
- *
1461
- * For group peers we prefer to drop a bot-authored message so human
1462
- * input never gets lost. Falling back to dropping the oldest keeps the
1463
- * queue bounded when all members are bots.
1464
- */
1465
1223
  const evictOne = (queue, isGroup) => {
1466
1224
  if (isGroup) {
1467
1225
  const botIdx = queue.findIndex((m) => m.senderIsBot);
@@ -1469,7 +1227,6 @@ function createMessageQueue(ctx) {
1469
1227
  }
1470
1228
  return queue.shift();
1471
1229
  };
1472
- /** Run a single message, capturing errors in the log. */
1473
1230
  const processOne = async (msg, peerId, label) => {
1474
1231
  try {
1475
1232
  await handleMessageFnRef(msg);
@@ -1477,11 +1234,6 @@ function createMessageQueue(ctx) {
1477
1234
  log?.error(`${label} error for ${peerId}: ${formatErrorMessage(err)}`);
1478
1235
  }
1479
1236
  };
1480
- /**
1481
- * Drain a group's batch:
1482
- * - slash commands are processed one by one (order preserved);
1483
- * - the remaining messages are merged into a single turn.
1484
- */
1485
1237
  const drainGroupBatch = async (batch, peerId) => {
1486
1238
  const commands = [];
1487
1239
  const normal = [];
@@ -1497,7 +1249,6 @@ function createMessageQueue(ctx) {
1497
1249
  await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`);
1498
1250
  }
1499
1251
  };
1500
- /** Process one peer's queue serially. */
1501
1252
  const drainUserQueue = async (peerId) => {
1502
1253
  if (activeUsers.has(peerId)) return;
1503
1254
  if (activeUsers.size >= maxConcurrentUsers) {
@@ -1740,13 +1491,16 @@ var ReconnectState = class {
1740
1491
  }
1741
1492
  };
1742
1493
  //#endregion
1494
+ //#region extensions/qqbot/src/engine/gateway/ws-client.ts
1495
+ async function createQQWSClient(options) {
1496
+ const wsAgent = await resolveAmbientNodeProxyAgent();
1497
+ return new WebSocket(options.gatewayUrl, {
1498
+ headers: { "User-Agent": options.userAgent },
1499
+ ...wsAgent ? { agent: wsAgent } : {}
1500
+ });
1501
+ }
1502
+ //#endregion
1743
1503
  //#region extensions/qqbot/src/engine/gateway/gateway-connection.ts
1744
- /**
1745
- * GatewayConnection — WebSocket lifecycle, heartbeat, reconnect, and session persistence.
1746
- *
1747
- * Encapsulates all connection state as class fields (replaces 11 closure variables).
1748
- * Event handling and message processing are delegated to injected handlers.
1749
- */
1750
1504
  var GatewayConnection = class {
1751
1505
  constructor(ctx) {
1752
1506
  this.isAborted = false;
@@ -1765,7 +1519,6 @@ var GatewayConnection = class {
1765
1519
  isAborted: () => this.isAborted
1766
1520
  });
1767
1521
  }
1768
- /** Start the connection loop. Resolves when abortSignal fires. */
1769
1522
  async start() {
1770
1523
  this.restoreSession();
1771
1524
  this.registerAbortHandler();
@@ -1852,14 +1605,22 @@ var GatewayConnection = class {
1852
1605
  log?.info(`✅ Access token obtained successfully`);
1853
1606
  const gatewayUrl = await getGatewayUrl(accessToken, account.appId);
1854
1607
  log?.info(`Connecting to ${gatewayUrl}`);
1855
- const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": getPluginUserAgent() } });
1608
+ const ws = await createQQWSClient({
1609
+ gatewayUrl,
1610
+ userAgent: getPluginUserAgent()
1611
+ });
1856
1612
  this.currentWs = ws;
1857
1613
  const slashCtx = {
1858
1614
  account,
1859
1615
  cfg: this.ctx.cfg,
1860
1616
  log,
1861
1617
  getMessagePeerId: (msg) => this.msgQueue.getMessagePeerId(msg),
1862
- getQueueSnapshot: (peerId) => this.msgQueue.getSnapshot(peerId)
1618
+ getQueueSnapshot: (peerId) => this.msgQueue.getSnapshot(peerId),
1619
+ resolveCommandAuthorized: (params) => this.ctx.adapters.access.resolveSlashCommandAuthorization({
1620
+ cfg: this.ctx.cfg,
1621
+ accountId: account.accountId,
1622
+ ...params
1623
+ })
1863
1624
  };
1864
1625
  const trySlashCommandOrEnqueue = async (msg) => {
1865
1626
  const result = await trySlashCommand(msg, slashCtx);
@@ -2026,20 +1787,28 @@ async function transcribeAudio(audioPath, cfg) {
2026
1787
  if (!sttCfg) return null;
2027
1788
  const fileBuffer = fs$1.readFileSync(audioPath);
2028
1789
  const fileName = sanitizeFileName(path.basename(audioPath));
2029
- const mime = fileName.endsWith(".wav") ? "audio/wav" : fileName.endsWith(".mp3") ? "audio/mpeg" : fileName.endsWith(".ogg") ? "audio/ogg" : "application/octet-stream";
1790
+ const mime = mimeTypeFromFilePath(fileName) ?? "application/octet-stream";
2030
1791
  const form = new FormData();
2031
1792
  form.append("file", new Blob([fileBuffer], { type: mime }), fileName);
2032
1793
  form.append("model", sttCfg.model);
2033
- const resp = await fetch(`${sttCfg.baseUrl}/audio/transcriptions`, {
2034
- method: "POST",
2035
- headers: { Authorization: `Bearer ${sttCfg.apiKey}` },
2036
- body: form
1794
+ const { response: resp, release } = await fetchWithSsrFGuard({
1795
+ url: `${sttCfg.baseUrl}/audio/transcriptions`,
1796
+ auditContext: "qqbot-stt",
1797
+ init: {
1798
+ method: "POST",
1799
+ headers: { Authorization: `Bearer ${sttCfg.apiKey}` },
1800
+ body: form
1801
+ }
2037
1802
  });
2038
- if (!resp.ok) {
2039
- const detail = await resp.text().catch(() => "");
2040
- throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
1803
+ try {
1804
+ if (!resp.ok) {
1805
+ const detail = await resp.text().catch(() => "");
1806
+ throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
1807
+ }
1808
+ return normalizeOptionalString((await resp.json()).text) ?? null;
1809
+ } finally {
1810
+ await release();
2041
1811
  }
2042
- return normalizeOptionalString((await resp.json()).text) ?? null;
2043
1812
  }
2044
1813
  //#endregion
2045
1814
  //#region extensions/qqbot/src/engine/gateway/inbound-attachments.ts
@@ -2288,7 +2057,6 @@ async function processVoiceAttachment(localPath, audioPath, att, asrReferText, c
2288
2057
  }
2289
2058
  //#endregion
2290
2059
  //#region extensions/qqbot/src/engine/gateway/stages/stub-contexts.ts
2291
- /** Build an {@link InboundContext} with all non-routing fields cleared. */
2292
2060
  function emptyInboundContext(fields) {
2293
2061
  return {
2294
2062
  event: fields.event,
@@ -2297,30 +2065,12 @@ function emptyInboundContext(fields) {
2297
2065
  peerId: fields.peerId,
2298
2066
  qualifiedTarget: fields.qualifiedTarget,
2299
2067
  fromAddress: fields.fromAddress,
2300
- parsedContent: "",
2301
- userContent: "",
2302
- quotePart: "",
2303
- dynamicCtx: "",
2304
- userMessage: "",
2305
2068
  agentBody: "",
2306
2069
  body: "",
2307
- systemPrompts: [],
2308
2070
  groupSystemPrompt: void 0,
2309
- attachments: {
2310
- attachmentInfo: "",
2311
- imageUrls: [],
2312
- imageMediaTypes: [],
2313
- voiceAttachmentPaths: [],
2314
- voiceAttachmentUrls: [],
2315
- voiceAsrReferTexts: [],
2316
- voiceTranscripts: [],
2317
- voiceTranscriptSources: [],
2318
- attachmentLocalPaths: []
2319
- },
2320
2071
  localMediaPaths: [],
2321
2072
  localMediaTypes: [],
2322
2073
  remoteMediaUrls: [],
2323
- remoteMediaTypes: [],
2324
2074
  uniqueVoicePaths: [],
2325
2075
  uniqueVoiceUrls: [],
2326
2076
  uniqueVoiceAsrReferTexts: [],
@@ -2336,52 +2086,29 @@ function emptyInboundContext(fields) {
2336
2086
  inputNotifyRefIdx: void 0
2337
2087
  };
2338
2088
  }
2339
- /**
2340
- * Build an {@link InboundContext} that represents a message blocked by
2341
- * access control (policy denial, allowlist mismatch, etc.).
2342
- */
2343
2089
  function buildBlockedInboundContext(params) {
2344
2090
  return {
2345
2091
  ...emptyInboundContext(params),
2346
2092
  blocked: true,
2347
- blockReason: params.access.reason,
2348
- blockReasonCode: params.access.reasonCode,
2349
- accessDecision: params.access.decision
2093
+ blockReason: params.access.senderAccess.reasonCode,
2094
+ blockReasonCode: params.access.senderAccess.reasonCode,
2095
+ accessDecision: params.access.senderAccess.decision
2350
2096
  };
2351
2097
  }
2352
- /**
2353
- * Build an {@link InboundContext} that represents a message stopped by
2354
- * the group gate (drop_other_mention, block_unauthorized_command,
2355
- * skip_no_mention). Any history side-effects have already been applied
2356
- * by the gate stage.
2357
- */
2358
2098
  function buildSkippedInboundContext(params) {
2359
2099
  return {
2360
2100
  ...emptyInboundContext(params),
2361
2101
  group: params.group,
2362
2102
  skipped: true,
2363
2103
  skipReason: params.skipReason,
2364
- accessDecision: params.access.decision,
2104
+ accessDecision: params.access.senderAccess.decision,
2365
2105
  typing: params.typing,
2366
2106
  inputNotifyRefIdx: params.inputNotifyRefIdx
2367
2107
  };
2368
2108
  }
2369
2109
  //#endregion
2370
2110
  //#region extensions/qqbot/src/engine/gateway/stages/access-stage.ts
2371
- /**
2372
- * Access stage — resolves routing target + runs access control.
2373
- *
2374
- * Split from the pipeline so it is trivially unit-testable: given a raw
2375
- * event and the runtime's routing info, the stage returns either:
2376
- * - `{ kind: "allow", ... }` — proceed through the rest of the pipeline
2377
- * - `{ kind: "block", context }` — short-circuit; the caller returns
2378
- * `context` directly to its own caller.
2379
- */
2380
- /**
2381
- * Resolve the routing target, walk the access policy, and decide whether
2382
- * the inbound message should proceed to the rest of the pipeline.
2383
- */
2384
- function runAccessStage(event, deps) {
2111
+ async function runAccessStage(event, deps) {
2385
2112
  const { account, cfg, runtime, log } = deps;
2386
2113
  const isGroupChat = event.type === "guild" || event.type === "group";
2387
2114
  const peerId = resolvePeerId(event, isGroupChat);
@@ -2395,16 +2122,19 @@ function runAccessStage(event, deps) {
2395
2122
  id: peerId
2396
2123
  }
2397
2124
  });
2398
- const access = resolveQQBotAccess({
2125
+ const access = await deps.adapters.access.resolveInboundAccess({
2126
+ cfg,
2127
+ accountId: account.accountId,
2399
2128
  isGroup: isGroupChat,
2400
2129
  senderId: event.senderId,
2130
+ conversationId: peerId,
2401
2131
  allowFrom: account.config?.allowFrom,
2402
2132
  groupAllowFrom: account.config?.groupAllowFrom,
2403
2133
  dmPolicy: account.config?.dmPolicy,
2404
2134
  groupPolicy: account.config?.groupPolicy
2405
2135
  });
2406
- if (access.decision !== "allow") {
2407
- log?.info(`Blocked qqbot inbound: decision=${access.decision} reasonCode=${access.reasonCode} reason=${access.reason} senderId=${event.senderId} accountId=${account.accountId} isGroup=${isGroupChat}`);
2136
+ if (access.senderAccess.decision !== "allow") {
2137
+ log?.info(`Blocked qqbot inbound: decision=${access.senderAccess.decision} reasonCode=${access.senderAccess.reasonCode} senderId=${event.senderId} accountId=${account.accountId} isGroup=${isGroupChat}`);
2408
2138
  return {
2409
2139
  kind: "block",
2410
2140
  context: buildBlockedInboundContext({
@@ -2438,28 +2168,9 @@ function buildQualifiedTarget(event, isGroupChat) {
2438
2168
  if (isGroupChat) return event.type === "guild" ? `qqbot:channel:${event.channelId}` : `qqbot:group:${event.groupOpenid}`;
2439
2169
  return event.type === "dm" ? `qqbot:dm:${event.guildId}` : `qqbot:c2c:${event.senderId}`;
2440
2170
  }
2441
- /**
2442
- * Decide whether the access decision permits running text-based control
2443
- * commands. Placed in the access stage because the rule is an
2444
- * access-policy derivative, not a gate derivative.
2445
- */
2446
- function resolveCommandAuthorized(access) {
2447
- return access.reasonCode === "dm_policy_open" || access.reasonCode === "dm_policy_allowlisted" || access.reasonCode === "group_policy_allowed" && access.effectiveGroupAllowFrom.length > 0 && access.groupPolicy === "allowlist";
2448
- }
2449
2171
  //#endregion
2450
2172
  //#region extensions/qqbot/src/engine/group/mention.ts
2451
- /** Regex detecting `<@openid>` / `<@!openid>` mention tags in raw content. */
2452
2173
  const MENTION_TAG_RE = /<@!?\w+>/;
2453
- /**
2454
- * Detect whether the inbound message explicitly targets the bot.
2455
- *
2456
- * Priority order:
2457
- * 1. `mentions[].is_you === true` (most reliable)
2458
- * 2. `eventType === "GROUP_AT_MESSAGE_CREATE"` (QQ-level @bot event)
2459
- * 3. regex match on any of `mentionPatterns` (fallback, e.g. "@bot-name")
2460
- *
2461
- * Returns `false` for direct messages or when no signal is found.
2462
- */
2463
2174
  function detectWasMentioned(input) {
2464
2175
  const { eventType, mentions, content, mentionPatterns } = input;
2465
2176
  if (mentions?.some((m) => m.is_you)) return true;
@@ -2472,30 +2183,11 @@ function detectWasMentioned(input) {
2472
2183
  }
2473
2184
  return false;
2474
2185
  }
2475
- /**
2476
- * Report whether the message contains **any** @mention (not necessarily @bot).
2477
- *
2478
- * Used by the gating layer to decide whether to bypass mention requirements
2479
- * for control commands. A control command like `/stop` that also @-s another
2480
- * user should NOT bypass the mention gate — the `@other-user` prefix is a
2481
- * strong signal that the command wasn't addressed to the bot.
2482
- */
2483
2186
  function hasAnyMention(input) {
2484
2187
  if (input.mentions && input.mentions.length > 0) return true;
2485
2188
  if (input.content && MENTION_TAG_RE.test(input.content)) return true;
2486
2189
  return false;
2487
2190
  }
2488
- /**
2489
- * Clean up `<@openid>` mention tags in raw QQ group content.
2490
- *
2491
- * - For the bot's own mention (`is_you === true`): the tag is removed
2492
- * outright so prompts don't contain visible `<@BOTID>` garbage.
2493
- * - For other mentioned users: the tag is replaced with `@nickname` (or
2494
- * `@username`) for readability. Entries without a display name are left
2495
- * as-is (rare in practice).
2496
- *
2497
- * Returns the original text unchanged when `text` or `mentions` is empty.
2498
- */
2499
2191
  function stripMentionText(text, mentions) {
2500
2192
  if (!text || !mentions?.length) return text;
2501
2193
  let cleaned = text;
@@ -2511,7 +2203,6 @@ function stripMentionText(text, mentions) {
2511
2203
  }
2512
2204
  return cleaned;
2513
2205
  }
2514
- /** Escape characters that carry regex meaning. */
2515
2206
  function escapeRegex$1(str) {
2516
2207
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2517
2208
  }
@@ -2821,7 +2512,6 @@ const DEFAULT_GROUP_CONFIG = {
2821
2512
  name: "",
2822
2513
  historyLimit: 50
2823
2514
  };
2824
- /** Read a named account's raw `groups` map from an OpenClawConfig. */
2825
2515
  function readGroupsMap(cfg, accountId) {
2826
2516
  const groups = asOptionalObjectRecord(resolveAccountBase(cfg, accountId).config.groups);
2827
2517
  if (!groups) return {};
@@ -2849,12 +2539,6 @@ function readHistoryLimit(obj, key) {
2849
2539
  if (typeof v !== "number" || !Number.isFinite(v)) return;
2850
2540
  return Math.max(0, Math.floor(v));
2851
2541
  }
2852
- /**
2853
- * Resolve per-group configuration with `specific > "*" > default` precedence.
2854
- *
2855
- * When `groupOpenid` is not provided, only the wildcard/default values are
2856
- * returned. This lets callers query the "default" behaviour for new groups.
2857
- */
2858
2542
  function resolveGroupConfig(cfg, groupOpenid, accountId) {
2859
2543
  const groups = readGroupsMap(cfg, accountId);
2860
2544
  const wildcard = groups["*"] ?? {};
@@ -2868,13 +2552,6 @@ function resolveGroupConfig(cfg, groupOpenid, accountId) {
2868
2552
  historyLimit: readHistoryLimit(specific, "historyLimit") ?? readHistoryLimit(wildcard, "historyLimit") ?? DEFAULT_GROUP_CONFIG.historyLimit
2869
2553
  };
2870
2554
  }
2871
- /**
2872
- * Resolve all per-inbound group-related settings in one pass.
2873
- *
2874
- * Prefer this over calling `resolveHistoryLimit` / `resolveRequireMention`
2875
- * / etc. individually in hot paths — each of those currently re-walks
2876
- * the config tree on its own.
2877
- */
2878
2555
  function resolveGroupSettings(params) {
2879
2556
  const config = resolveGroupConfig(params.cfg, params.groupOpenid, params.accountId);
2880
2557
  return {
@@ -2883,12 +2560,6 @@ function resolveGroupSettings(params) {
2883
2560
  mentionPatterns: resolveMentionPatterns(params.cfg, params.agentId)
2884
2561
  };
2885
2562
  }
2886
- /**
2887
- * Resolve mentionPatterns with `agent > global > []` precedence.
2888
- *
2889
- * Mirrors the framework's `messages.groupChat.mentionPatterns` / per-agent
2890
- * `agents.list[].groupChat.mentionPatterns` chain.
2891
- */
2892
2563
  function resolveMentionPatterns(cfg, agentId) {
2893
2564
  if (agentId) {
2894
2565
  const agents = asOptionalObjectRecord(cfg.agents);
@@ -2907,34 +2578,6 @@ function resolveMentionPatterns(cfg, agentId) {
2907
2578
  }
2908
2579
  //#endregion
2909
2580
  //#region extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts
2910
- /**
2911
- * Group-gate stage — for `type === "group"` inbound events, decide
2912
- * whether the message should pass to AI dispatch or be intercepted.
2913
- *
2914
- * Three possible outcomes:
2915
- * - `{ kind: "pass", groupInfo }` — continue the pipeline
2916
- * - `{ kind: "skip", groupInfo, skipReason }` — buffered to history
2917
- * (if applicable) and short-circuit
2918
- * - No group info at all — returned when the event isn't a group event
2919
- * (caller should treat as a straight pass-through)
2920
- *
2921
- * Consolidates the control-command auth check, session-store
2922
- * activation override, mention detection, and the unified
2923
- * {@link resolveGroupMessageGate} call. Delegates all pure logic to
2924
- * existing `engine/group/*` modules so this stage remains a thin
2925
- * orchestrator.
2926
- */
2927
- /**
2928
- * Run the group-gate stage.
2929
- *
2930
- * Precondition: `event.type === "group"` && `event.groupOpenid` is set.
2931
- * The caller (pipeline) enforces this; the stage doesn't re-check.
2932
- *
2933
- * On `skip` outcomes the stage records the message into the group's
2934
- * history buffer when the skip reason is one that should preserve
2935
- * context (drop / skip_no_mention), then returns. `block` skip
2936
- * reasons do NOT write history — they are silent rejects.
2937
- */
2938
2581
  function runGroupGateStage(input) {
2939
2582
  const { event, deps, accountId, agentId, sessionKey, userContent, processedAttachments } = input;
2940
2583
  const groupOpenid = event.groupOpenid;
@@ -2971,7 +2614,7 @@ function runGroupGateStage(input) {
2971
2614
  });
2972
2615
  const content = (event.content ?? "").trim();
2973
2616
  const isControlCommand = Boolean(deps.isControlCommand?.(content));
2974
- const commandAuthorized = deps.allowTextCommands !== false && isSenderAllowedForCommands(event.senderId, deps);
2617
+ const commandAuthorized = deps.allowTextCommands !== false && input.access.commandAccess.authorized;
2975
2618
  const gate = resolveGateWithPort({
2976
2619
  mentionGatePort: deps.adapters.mentionGate,
2977
2620
  ignoreOtherMentions,
@@ -3021,12 +2664,6 @@ function runGroupGateStage(input) {
3021
2664
  skipReason: gate.action
3022
2665
  };
3023
2666
  }
3024
- /**
3025
- * Resolve the gate using the SDK MentionGatePort adapter.
3026
- *
3027
- * Layer 1 (ignoreOtherMentions) is QQ-specific and handled here.
3028
- * Layers 2+3 delegate to the SDK's `resolveInboundMentionDecision`.
3029
- */
3030
2667
  function resolveGateWithPort(params) {
3031
2668
  if (params.ignoreOtherMentions && params.hasAnyMention && !params.wasMentioned && !params.implicitMention) return {
3032
2669
  action: "drop_other_mention",
@@ -3064,15 +2701,6 @@ function resolveGateWithPort(params) {
3064
2701
  shouldBypassMention: decision.shouldBypassMention
3065
2702
  };
3066
2703
  }
3067
- /**
3068
- * Test whether the sender is on the DM `allowFrom` list.
3069
- */
3070
- function isSenderAllowedForCommands(senderId, deps) {
3071
- const raw = deps.account.config?.allowFrom;
3072
- if (!Array.isArray(raw) || raw.length === 0) return true;
3073
- const normalized = normalizeQQBotAllowFrom(raw);
3074
- return createQQBotSenderMatcher(senderId)(normalized);
3075
- }
3076
2704
  function recordGroupHistory(params) {
3077
2705
  const { historyMap, groupOpenid, historyLimit, event, userContent, historyPort, localPaths } = params;
3078
2706
  if (!historyMap || historyLimit <= 0) return;
@@ -3240,15 +2868,9 @@ function writeRefIndex(params) {
3240
2868
  }
3241
2869
  //#endregion
3242
2870
  //#region extensions/qqbot/src/engine/gateway/inbound-pipeline.ts
3243
- /**
3244
- * Process a raw queued message through the full inbound pipeline.
3245
- *
3246
- * Returns an {@link InboundContext} with `blocked` / `skipped` set when
3247
- * the message should not reach the AI dispatcher.
3248
- */
3249
2871
  async function buildInboundContext(event, deps) {
3250
2872
  const { account, log } = deps;
3251
- const accessResult = runAccessStage(event, deps);
2873
+ const accessResult = await runAccessStage(event, deps);
3252
2874
  if (accessResult.kind === "block") return accessResult.context;
3253
2875
  const { isGroupChat, peerId, qualifiedTarget, fromAddress, route, access } = accessResult;
3254
2876
  const typingPromise = deps.startTyping(event);
@@ -3280,7 +2902,8 @@ async function buildInboundContext(event, deps) {
3280
2902
  agentId: route.agentId,
3281
2903
  sessionKey: route.sessionKey,
3282
2904
  userContent,
3283
- processedAttachments: processed
2905
+ processedAttachments: processed,
2906
+ access
3284
2907
  });
3285
2908
  if (gateOutcome.kind === "skip") {
3286
2909
  typingResult.keepAlive?.stop();
@@ -3315,26 +2938,6 @@ async function buildInboundContext(event, deps) {
3315
2938
  uniqueVoiceUrls: media.uniqueVoiceUrls,
3316
2939
  uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts
3317
2940
  });
3318
- const userMessage = buildUserMessage({
3319
- event,
3320
- userContent,
3321
- quotePart,
3322
- isGroupChat,
3323
- groupInfo
3324
- });
3325
- const agentBody = buildAgentBody({
3326
- event,
3327
- userContent,
3328
- userMessage,
3329
- dynamicCtx,
3330
- isGroupChat,
3331
- groupInfo,
3332
- deps
3333
- });
3334
- const systemPrompts = [];
3335
- if (account.systemPrompt) systemPrompts.push(account.systemPrompt);
3336
- const groupSystemPrompt = buildGroupSystemPrompt(systemPrompts.length > 0 ? systemPrompts.join("\n") : "", groupInfo);
3337
- const commandAuthorized = resolveCommandAuthorized(access);
3338
2941
  return {
3339
2942
  event,
3340
2943
  route,
@@ -3342,20 +2945,26 @@ async function buildInboundContext(event, deps) {
3342
2945
  peerId,
3343
2946
  qualifiedTarget,
3344
2947
  fromAddress,
3345
- parsedContent,
3346
- userContent,
3347
- quotePart,
3348
- dynamicCtx,
3349
- userMessage,
3350
- agentBody,
2948
+ agentBody: buildAgentBody({
2949
+ event,
2950
+ userContent,
2951
+ userMessage: buildUserMessage({
2952
+ event,
2953
+ userContent,
2954
+ quotePart,
2955
+ isGroupChat,
2956
+ groupInfo
2957
+ }),
2958
+ dynamicCtx,
2959
+ isGroupChat,
2960
+ groupInfo,
2961
+ deps
2962
+ }),
3351
2963
  body,
3352
- systemPrompts,
3353
- groupSystemPrompt,
3354
- attachments: processed,
2964
+ groupSystemPrompt: buildGroupSystemPrompt(account.systemPrompt ?? "", groupInfo),
3355
2965
  localMediaPaths: media.localMediaPaths,
3356
2966
  localMediaTypes: media.localMediaTypes,
3357
2967
  remoteMediaUrls: media.remoteMediaUrls,
3358
- remoteMediaTypes: media.remoteMediaTypes,
3359
2968
  uniqueVoicePaths: media.uniqueVoicePaths,
3360
2969
  uniqueVoiceUrls: media.uniqueVoiceUrls,
3361
2970
  uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts,
@@ -3363,19 +2972,15 @@ async function buildInboundContext(event, deps) {
3363
2972
  hasAsrReferFallback: media.hasAsrReferFallback,
3364
2973
  voiceTranscriptSources: media.voiceTranscriptSources,
3365
2974
  replyTo,
3366
- commandAuthorized,
2975
+ commandAuthorized: access.commandAccess.authorized,
3367
2976
  group: groupInfo,
3368
2977
  blocked: false,
3369
2978
  skipped: false,
3370
- accessDecision: access.decision,
2979
+ accessDecision: access.senderAccess.decision,
3371
2980
  typing: { keepAlive: typingResult.keepAlive },
3372
2981
  inputNotifyRefIdx: typingResult.refIdx
3373
2982
  };
3374
2983
  }
3375
- /**
3376
- * Clear a group's pending history buffer. Exposed so the gateway can
3377
- * call it in its `finally` block after a reply attempt.
3378
- */
3379
2984
  function clearGroupPendingHistory(params) {
3380
2985
  if (!params.historyMap || !params.groupOpenid) return;
3381
2986
  params.historyPort.clearPendingHistory({
@@ -3385,6 +2990,20 @@ function clearGroupPendingHistory(params) {
3385
2990
  });
3386
2991
  }
3387
2992
  //#endregion
2993
+ //#region extensions/qqbot/src/engine/access/resolve-policy.ts
2994
+ function hasRealRestriction(list) {
2995
+ if (!list || list.length === 0) return false;
2996
+ return !list.every((entry) => String(entry).trim() === "*");
2997
+ }
2998
+ function resolveQQBotEffectivePolicies(input) {
2999
+ const allowFromRestricted = hasRealRestriction(input.allowFrom);
3000
+ const groupAllowFromRestricted = hasRealRestriction(input.groupAllowFrom);
3001
+ return {
3002
+ dmPolicy: input.dmPolicy ?? (allowFromRestricted ? "allowlist" : "open"),
3003
+ groupPolicy: input.groupPolicy ?? (groupAllowFromRestricted || allowFromRestricted ? "allowlist" : "open")
3004
+ };
3005
+ }
3006
+ //#endregion
3388
3007
  //#region extensions/qqbot/src/engine/gateway/interaction-handler.ts
3389
3008
  /**
3390
3009
  * INTERACTION_CREATE event handler.
@@ -5552,8 +5171,13 @@ function stripAmrHeader(buf) {
5552
5171
  }
5553
5172
  /** Convert a SILK or AMR voice file to WAV format. */
5554
5173
  async function convertSilkToWav(inputPath, outputDir) {
5555
- if (!fs$1.existsSync(inputPath)) return null;
5556
- const strippedBuf = stripAmrHeader(fs$1.readFileSync(inputPath));
5174
+ let fileBuf;
5175
+ try {
5176
+ fileBuf = readRegularFileSync({ filePath: inputPath }).buffer;
5177
+ } catch {
5178
+ return null;
5179
+ }
5180
+ const strippedBuf = stripAmrHeader(fileBuf);
5557
5181
  const rawData = new Uint8Array(strippedBuf.buffer, strippedBuf.byteOffset, strippedBuf.byteLength);
5558
5182
  const silk = await loadSilkWasm();
5559
5183
  if (!silk || !silk.isSilk(rawData)) return null;
@@ -5642,8 +5266,12 @@ function normalizeFormats(formats) {
5642
5266
  * Attempts conversion via direct QQ-native upload → WASM decoders → null fallback chain.
5643
5267
  */
5644
5268
  async function audioFileToSilkBase64(filePath, directUploadFormats) {
5645
- if (!fs$1.existsSync(filePath)) return null;
5646
- const buf = fs$1.readFileSync(filePath);
5269
+ let buf;
5270
+ try {
5271
+ buf = readRegularFileSync({ filePath }).buffer;
5272
+ } catch {
5273
+ return null;
5274
+ }
5647
5275
  if (buf.length === 0) {
5648
5276
  debugError(`[audio-convert] file is empty: ${filePath}`);
5649
5277
  return null;
@@ -5902,7 +5530,7 @@ async function dispatchOutbound(inbound, deps) {
5902
5530
  };
5903
5531
  const sendWithRetry = (sendFn) => sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
5904
5532
  const sendErrorMessage = (errorText) => sendErrorToTarget(replyCtx, errorText);
5905
- const ctxPayload = buildCtxPayload(inbound, runtime);
5533
+ const ctxPayload = buildCtxPayload(inbound, runtime, cfg);
5906
5534
  let hasResponse = false;
5907
5535
  let hasBlockResponse = false;
5908
5536
  let toolDeliverCount = 0;
@@ -6186,8 +5814,14 @@ async function dispatchOutbound(inbound, deps) {
6186
5814
  }
6187
5815
  }
6188
5816
  }
6189
- function buildCtxPayload(inbound, runtime) {
5817
+ function resolveCommandSource(inbound, runtime, cfg) {
5818
+ const commandBody = inbound.event.content;
5819
+ if (!runtime.channel.commands?.isControlCommandMessage?.(commandBody, cfg)) return;
5820
+ return "text";
5821
+ }
5822
+ function buildCtxPayload(inbound, runtime, cfg) {
6190
5823
  const { event } = inbound;
5824
+ const commandSource = resolveCommandSource(inbound, runtime, cfg);
6191
5825
  return runtime.channel.reply.finalizeInboundContext({
6192
5826
  Body: inbound.body,
6193
5827
  BodyForAgent: inbound.agentBody,
@@ -6217,6 +5851,7 @@ function buildCtxPayload(inbound, runtime) {
6217
5851
  QQVoiceAsrReferTexts: inbound.uniqueVoiceAsrReferTexts,
6218
5852
  QQVoiceInputStrategy: "prefer_audio_stt_then_asr_fallback",
6219
5853
  CommandAuthorized: inbound.commandAuthorized,
5854
+ ...commandSource ? { CommandSource: commandSource } : {},
6220
5855
  ...inbound.voiceMediaTypes.length > 0 ? {
6221
5856
  MediaTypes: inbound.voiceMediaTypes,
6222
5857
  MediaType: inbound.voiceMediaTypes[0]
@@ -6297,22 +5932,6 @@ var TypingKeepAlive = class {
6297
5932
  };
6298
5933
  //#endregion
6299
5934
  //#region extensions/qqbot/src/engine/gateway/gateway.ts
6300
- /**
6301
- * Core gateway entry point — thin shell that wires together:
6302
- *
6303
- * - GatewayConnection: WebSocket lifecycle, heartbeat, reconnect
6304
- * - buildInboundContext: content building, attachments, quote resolution
6305
- * - dispatchOutbound: AI dispatch, deliver callbacks, timeouts
6306
- *
6307
- * The only responsibilities of this file are:
6308
- * 1. Initialize adapters from EngineAdapters
6309
- * 2. Initialize API config + refIdx cache hook
6310
- * 3. Create the message handler (inbound → outbound pipeline)
6311
- * 4. Start GatewayConnection
6312
- */
6313
- /**
6314
- * Start the Gateway WebSocket connection with automatic reconnect support.
6315
- */
6316
5935
  async function startGateway$1(ctx) {
6317
5936
  const { account, log, runtime, adapters } = ctx;
6318
5937
  setOutboundAudioPort(adapters.outboundAudio);
@@ -6435,6 +6054,7 @@ async function startGateway$1(ctx) {
6435
6054
  cfg: ctx.cfg,
6436
6055
  log,
6437
6056
  runtime,
6057
+ adapters,
6438
6058
  onReady: ctx.onReady,
6439
6059
  onResumed: ctx.onResumed,
6440
6060
  onError: ctx.onError,
@@ -6572,24 +6192,14 @@ function readQQBotVersionFromManifest(manifestPath) {
6572
6192
  }
6573
6193
  //#endregion
6574
6194
  //#region extensions/qqbot/src/bridge/sdk-adapter.ts
6575
- /**
6576
- * SDK adapter — binds engine port interfaces to the framework's shared
6577
- * SDK implementations.
6578
- *
6579
- * This file lives in bridge/ (not engine/) because it imports from
6580
- * `openclaw/plugin-sdk/*`. The engine layer stays zero-SDK-dependency;
6581
- * only the bridge layer couples to the framework.
6582
- */
6195
+ const qqbotIngressIdentity = defineStableChannelIngressIdentity({
6196
+ key: "sender-id",
6197
+ normalize: normalizeQQBotSenderId,
6198
+ isWildcardEntry: (entry) => normalizeQQBotSenderId(entry) === "*"
6199
+ });
6583
6200
  function asSdkMap(map) {
6584
6201
  return map;
6585
6202
  }
6586
- /**
6587
- * History adapter backed by SDK `reply-history`.
6588
- *
6589
- * Delegates record/build/clear to the SDK's shared implementation so
6590
- * the engine benefits from SDK improvements (e.g. future visibility
6591
- * filtering) without code duplication.
6592
- */
6593
6203
  function createSdkHistoryAdapter() {
6594
6204
  return {
6595
6205
  recordPendingHistoryEntry(params) {
@@ -6619,38 +6229,69 @@ function createSdkHistoryAdapter() {
6619
6229
  }
6620
6230
  };
6621
6231
  }
6622
- /**
6623
- * MentionGate adapter backed by SDK `channel-mention-gating`.
6624
- *
6625
- * Maps the engine's mention facts/policy to the SDK's
6626
- * `resolveInboundMentionDecision` call, normalizing the implicit
6627
- * mention boolean into the SDK's typed `ImplicitMentionKind[]`.
6628
- */
6629
6232
  function createSdkMentionGateAdapter() {
6630
6233
  return { resolveInboundMentionDecision(params) {
6631
- const result = resolveInboundMentionDecision({
6632
- facts: {
6633
- canDetectMention: params.facts.canDetectMention,
6634
- wasMentioned: params.facts.wasMentioned,
6635
- hasAnyMention: params.facts.hasAnyMention,
6636
- implicitMentionKinds: params.facts.implicitMentionKinds ?? implicitMentionKindWhen("reply_to_bot", false)
6637
- },
6638
- policy: {
6639
- isGroup: params.policy.isGroup,
6640
- requireMention: params.policy.requireMention,
6641
- allowTextCommands: params.policy.allowTextCommands,
6642
- hasControlCommand: params.policy.hasControlCommand,
6643
- commandAuthorized: params.policy.commandAuthorized
6644
- }
6645
- });
6646
- return {
6647
- effectiveWasMentioned: result.effectiveWasMentioned,
6648
- shouldSkip: result.shouldSkip,
6649
- shouldBypassMention: result.shouldBypassMention,
6650
- implicitMention: result.implicitMention
6651
- };
6234
+ return resolveInboundMentionDecision(params);
6652
6235
  } };
6653
6236
  }
6237
+ function createSdkAccessAdapter() {
6238
+ return {
6239
+ async resolveInboundAccess(input) {
6240
+ const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input);
6241
+ const rawGroupAllowFrom = input.groupAllowFrom && input.groupAllowFrom.length > 0 ? input.groupAllowFrom : input.allowFrom ?? [];
6242
+ const normalizedAllowFrom = normalizeQQBotAllowFrom(input.allowFrom);
6243
+ const dmAllowFromForIngress = dmPolicy === "open" && normalizedAllowFrom.length === 0 ? ["*"] : input.allowFrom ?? [];
6244
+ const commandOwnerAllowFrom = input.isGroup ? [] : input.allowFrom && input.allowFrom.length > 0 ? input.allowFrom : ["*"];
6245
+ return await createChannelIngressResolver({
6246
+ channelId: "qqbot",
6247
+ accountId: input.accountId,
6248
+ identity: qqbotIngressIdentity,
6249
+ cfg: input.cfg
6250
+ }).message({
6251
+ subject: { stableId: input.senderId },
6252
+ conversation: {
6253
+ kind: input.isGroup ? "group" : "direct",
6254
+ id: input.conversationId
6255
+ },
6256
+ event: { mayPair: false },
6257
+ dmPolicy,
6258
+ groupPolicy,
6259
+ policy: { groupAllowFromFallbackToAllowFrom: false },
6260
+ allowFrom: dmAllowFromForIngress,
6261
+ groupAllowFrom: rawGroupAllowFrom,
6262
+ command: { commandOwnerAllowFrom }
6263
+ });
6264
+ },
6265
+ async resolveSlashCommandAuthorization(input) {
6266
+ return await resolveQQBotSlashCommandAuthorized(input);
6267
+ }
6268
+ };
6269
+ }
6270
+ async function resolveQQBotSlashCommandAuthorized(params) {
6271
+ const explicitAllowFrom = normalizeQQBotAllowFrom(params.commandsAllowFrom ?? (params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0 ? params.groupAllowFrom : params.allowFrom)).filter((entry) => entry !== "*");
6272
+ if (explicitAllowFrom.length === 0) return false;
6273
+ return (await createChannelIngressResolver({
6274
+ channelId: "qqbot",
6275
+ accountId: params.accountId,
6276
+ identity: qqbotIngressIdentity,
6277
+ cfg: params.cfg
6278
+ }).message({
6279
+ subject: { stableId: params.senderId },
6280
+ conversation: {
6281
+ kind: params.isGroup ? "group" : "direct",
6282
+ id: params.conversationId
6283
+ },
6284
+ event: {
6285
+ kind: "slash-command",
6286
+ authMode: "none",
6287
+ mayPair: false
6288
+ },
6289
+ dmPolicy: "allowlist",
6290
+ groupPolicy: "open",
6291
+ allowFrom: explicitAllowFrom,
6292
+ command: { modeWhenAccessGroupsOff: "configured" }
6293
+ })).commandAccess.authorized;
6294
+ }
6654
6295
  //#endregion
6655
6296
  //#region extensions/qqbot/src/bridge/gateway.ts
6656
6297
  /**
@@ -6676,6 +6317,7 @@ function createEngineAdapters(_runtime) {
6676
6317
  return {
6677
6318
  history: createSdkHistoryAdapter(),
6678
6319
  mentionGate: createSdkMentionGateAdapter(),
6320
+ access: createSdkAccessAdapter(),
6679
6321
  audioConvert: {
6680
6322
  convertSilkToWav,
6681
6323
  isVoiceAttachment,