@ryantest/openclaw-qqbot 1.6.6-alpha.4 → 1.6.7-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +24 -15
  2. package/README.zh.md +24 -15
  3. package/dist/src/api.d.ts +32 -5
  4. package/dist/src/api.js +111 -12
  5. package/dist/src/channel.d.ts +18 -0
  6. package/dist/src/channel.js +85 -2
  7. package/dist/src/config.d.ts +33 -2
  8. package/dist/src/config.js +125 -1
  9. package/dist/src/gateway.js +566 -24
  10. package/dist/src/group-history.d.ts +136 -0
  11. package/dist/src/group-history.js +226 -0
  12. package/dist/src/message-gating.d.ts +53 -0
  13. package/dist/src/message-gating.js +107 -0
  14. package/dist/src/message-queue.d.ts +36 -0
  15. package/dist/src/message-queue.js +164 -22
  16. package/dist/src/outbound.d.ts +4 -4
  17. package/dist/src/outbound.js +18 -6
  18. package/dist/src/ref-index-store.js +5 -28
  19. package/dist/src/request-context.d.ts +7 -0
  20. package/dist/src/request-context.js +7 -0
  21. package/dist/src/slash-commands.d.ts +6 -0
  22. package/dist/src/slash-commands.js +3 -3
  23. package/dist/src/tools/remind.js +17 -9
  24. package/dist/src/types.d.ts +90 -2
  25. package/dist/src/utils/audio-convert.d.ts +1 -1
  26. package/dist/src/utils/audio-convert.js +1 -1
  27. package/dist/src/utils/chunked-upload.d.ts +11 -2
  28. package/dist/src/utils/chunked-upload.js +63 -11
  29. package/dist/src/utils/media-send.js +1 -1
  30. package/dist/src/utils/text-parsing.js +7 -18
  31. package/package.json +1 -1
  32. package/scripts/postinstall-link-sdk.js +22 -9
  33. package/scripts/upgrade-via-npm.sh +11 -3
  34. package/scripts/upgrade-via-source.sh +63 -15
  35. package/skills/qqbot-remind/SKILL.md +21 -11
  36. package/src/api.ts +135 -7
  37. package/src/channel.ts +85 -2
  38. package/src/config.ts +170 -3
  39. package/src/gateway.ts +662 -29
  40. package/src/group-history.ts +328 -0
  41. package/src/message-gating.ts +190 -0
  42. package/src/message-queue.ts +201 -21
  43. package/src/openclaw-plugin-sdk.d.ts +65 -0
  44. package/src/outbound.ts +18 -6
  45. package/src/ref-index-store.ts +5 -27
  46. package/src/request-context.ts +10 -0
  47. package/src/slash-commands.ts +3 -3
  48. package/src/tools/remind.ts +17 -9
  49. package/src/types.ts +94 -2
  50. package/src/utils/audio-convert.ts +1 -1
  51. package/src/utils/chunked-upload.ts +76 -12
  52. package/src/utils/media-send.ts +1 -2
  53. package/src/utils/text-parsing.ts +7 -14
@@ -1,11 +1,15 @@
1
1
  import WebSocket from "ws";
2
2
  import path from "node:path";
3
- import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT } from "./api.js";
3
+ import fs from "node:fs";
4
+ import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, acknowledgeInteraction, getApiPluginVersion } from "./api.js";
4
5
  import { loadSession, saveSession, clearSession } from "./session-store.js";
5
6
  import { recordKnownUser, flushKnownUsers } from "./known-users.js";
6
7
  import { getQQBotRuntime } from "./runtime.js";
8
+ import { isGroupAllowed, resolveGroupName, resolveGroupPrompt, resolveHistoryLimit, resolveGroupPolicy, resolveGroupConfig, resolveIgnoreOtherMentions, resolveMentionPatterns } from "./config.js";
9
+ import { qqbotPlugin, stripMentionText, detectWasMentioned } from "./channel.js";
10
+ import { recordPendingHistoryEntry, buildPendingHistoryContext, buildMergedMessageContext, clearPendingHistory, formatAttachmentTags, formatMessageContent, toAttachmentSummaries, } from "./group-history.js";
7
11
  import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex } from "./ref-index-store.js";
8
- import { matchSlashCommand } from "./slash-commands.js";
12
+ import { matchSlashCommand, getFrameworkVersion, parseFrameworkDateVersion } from "./slash-commands.js";
9
13
  import { createMessageQueue } from "./message-queue.js";
10
14
  import { triggerUpdateCheck } from "./update-checker.js";
11
15
  import { startImageServer, isImageServerRunning } from "./image-server.js";
@@ -21,6 +25,232 @@ import { parseAndSendMediaTags, sendPlainReply } from "./outbound-deliver.js";
21
25
  import { createDeliverDebouncer } from "./deliver-debounce.js";
22
26
  import { runWithRequestContext } from "./request-context.js";
23
27
  import { StreamingController, shouldUseStreaming } from "./streaming.js";
28
+ import { resolveGroupMessageGate } from "./message-gating.js";
29
+ // ============ Interaction 处理 ============
30
+ /** 配置查询交互类型 */
31
+ const INTERACTION_TYPE_CONFIG_QUERY = 2001;
32
+ /** 配置更新交互类型 */
33
+ const INTERACTION_TYPE_CONFIG_UPDATE = 2002;
34
+ /** 处理 INTERACTION_CREATE 事件 */
35
+ async function handleInteractionCreate(params) {
36
+ const { event, account, cfg, log } = params;
37
+ const token = await getAccessToken(account.appId, account.clientSecret);
38
+ if (event.data?.type === INTERACTION_TYPE_CONFIG_QUERY) {
39
+ // 从框架 configApi 读取最新配置(而非闭包中的旧 cfg),确保配置查询返回的数据与磁盘一致
40
+ const runtime = getQQBotRuntime();
41
+ const configApi = runtime.config;
42
+ const latestCfg = configApi.loadConfig();
43
+ const groupOpenid = event.group_openid ?? "";
44
+ const groupCfg = groupOpenid ? resolveGroupConfig(latestCfg, groupOpenid, account.accountId) : null;
45
+ const groupPolicy = resolveGroupPolicy(latestCfg, account.accountId);
46
+ // require_mention 协议:字符串 "mention" | "always"(mention=@机器人时激活,always=总是激活)
47
+ const configRequireMention = groupCfg?.requireMention ?? true;
48
+ const requireMentionMode = configRequireMention ? "mention" : "always";
49
+ const pluginVersion = getApiPluginVersion();
50
+ const fwVersionRaw = getFrameworkVersion();
51
+ const clawVer = parseFrameworkDateVersion(fwVersionRaw) ?? fwVersionRaw;
52
+ // 通过路由解析 agentId(与消息处理流程一致),用于 agent-aware 的 mentionPatterns
53
+ const interactionAgentId = groupOpenid
54
+ ? runtime.channel?.routing?.resolveAgentRoute?.({
55
+ cfg: latestCfg,
56
+ channel: "qqbot",
57
+ accountId: account.accountId,
58
+ peer: { kind: "group", id: groupOpenid },
59
+ })?.agentId
60
+ : undefined;
61
+ // mention_patterns 协议:逗号分隔的字符串(@文本的名称提及BOT名,多个使用,分隔)
62
+ const mentionPatternsArr = resolveMentionPatterns(latestCfg, interactionAgentId);
63
+ const mentionPatterns = mentionPatternsArr.join(",");
64
+ const clawCfg = {
65
+ channel_type: "qqbot",
66
+ channel_ver: pluginVersion,
67
+ claw_type: "openclaw",
68
+ claw_ver: clawVer,
69
+ require_mention: requireMentionMode,
70
+ group_policy: groupPolicy,
71
+ mention_patterns: mentionPatterns,
72
+ online_state: "online",
73
+ };
74
+ await acknowledgeInteraction(token, event.id, 0, { claw_cfg: clawCfg });
75
+ log?.info(`[qqbot:${account.accountId}] Interaction ACK (type=${INTERACTION_TYPE_CONFIG_QUERY}) sent: ${event.id}, claw_cfg=${JSON.stringify(clawCfg)}`);
76
+ }
77
+ else if (event.data?.type === INTERACTION_TYPE_CONFIG_UPDATE) {
78
+ // type=2002: 配置更新交互,从 resolved.claw_cfg 获取更新信息并写入本地配置
79
+ const resolved = event.data.resolved;
80
+ const clawCfgUpdate = resolved?.claw_cfg;
81
+ const groupOpenid = event.group_openid ?? "";
82
+ const runtime = getQQBotRuntime();
83
+ const configApi = runtime.config;
84
+ const currentCfg = structuredClone(configApi.loadConfig());
85
+ const qqbot = (currentCfg.channels ?? {}).qqbot;
86
+ let changed = false;
87
+ if (clawCfgUpdate) {
88
+ // 更新 require_mention(群级别)——协议为 "mention" | "always",写回配置时转为 boolean
89
+ if (clawCfgUpdate.require_mention !== undefined && groupOpenid && qqbot) {
90
+ const requireMentionBool = clawCfgUpdate.require_mention === "mention";
91
+ const accountId = account.accountId;
92
+ const isNamedAccount = accountId !== "default" && qqbot.accounts?.[accountId];
93
+ if (isNamedAccount) {
94
+ const accounts = qqbot.accounts;
95
+ const acct = accounts[accountId] ?? {};
96
+ const groups = (acct.groups ?? {});
97
+ groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
98
+ acct.groups = groups;
99
+ accounts[accountId] = acct;
100
+ qqbot.accounts = accounts;
101
+ }
102
+ else {
103
+ const groups = (qqbot.groups ?? {});
104
+ groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
105
+ qqbot.groups = groups;
106
+ }
107
+ changed = true;
108
+ }
109
+ }
110
+ if (changed) {
111
+ await configApi.writeConfigFile(currentCfg);
112
+ log?.info(`[qqbot:${account.accountId}] Config updated via interaction ${event.id}: ${JSON.stringify({
113
+ require_mention: clawCfgUpdate?.require_mention,
114
+ group_openid: groupOpenid || undefined,
115
+ })}`);
116
+ }
117
+ // 无论更新是否成功,ACK 都上报最新的 claw_cfg 快照(写入后重新读取确保一致)
118
+ const latestCfg = changed ? configApi.loadConfig() : currentCfg;
119
+ const updatedGroupCfg = groupOpenid ? resolveGroupConfig(latestCfg, groupOpenid, account.accountId) : null;
120
+ const updatedRequireMention = updatedGroupCfg?.requireMention ?? true;
121
+ const updatedRequireMentionMode = updatedRequireMention ? "mention" : "always";
122
+ const pluginVersion = getApiPluginVersion();
123
+ const fwVersionRaw = getFrameworkVersion();
124
+ const clawVer = parseFrameworkDateVersion(fwVersionRaw) ?? fwVersionRaw;
125
+ const ackClawCfg = {
126
+ channel_type: "qqbot",
127
+ channel_ver: pluginVersion,
128
+ claw_type: "openclaw",
129
+ claw_ver: clawVer,
130
+ require_mention: updatedRequireMentionMode,
131
+ online_state: "online",
132
+ };
133
+ await acknowledgeInteraction(token, event.id, 0, { claw_cfg: ackClawCfg });
134
+ log?.info(`[qqbot:${account.accountId}] Interaction ACK (type=${INTERACTION_TYPE_CONFIG_UPDATE}) sent: ${event.id}, claw_cfg=${JSON.stringify(ackClawCfg)}`);
135
+ }
136
+ else {
137
+ // 其他类型:普通 ACK
138
+ await acknowledgeInteraction(token, event.id);
139
+ log?.debug?.(`[qqbot:${account.accountId}] Interaction ACK sent: ${event.id}`);
140
+ }
141
+ }
142
+ /** 解析 session store 文件路径 */
143
+ function resolveSessionStorePath(cfg, agentId) {
144
+ const sessionCfg = cfg?.session;
145
+ const store = sessionCfg?.store;
146
+ const resolvedAgentId = agentId || "default";
147
+ if (store) {
148
+ let expanded = store;
149
+ if (expanded.includes("{agentId}")) {
150
+ expanded = expanded.replaceAll("{agentId}", resolvedAgentId);
151
+ }
152
+ if (expanded.startsWith("~")) {
153
+ const home = process.env.HOME || process.env.USERPROFILE || "";
154
+ expanded = expanded.replace(/^~/, home);
155
+ }
156
+ return path.resolve(expanded);
157
+ }
158
+ // 默认路径: ~/.openclaw/agents/{agentId}/sessions/sessions.json
159
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim()
160
+ || process.env.CLAWDBOT_STATE_DIR?.trim()
161
+ || path.join(process.env.HOME || process.env.USERPROFILE || "", ".openclaw");
162
+ return path.join(stateDir, "agents", resolvedAgentId, "sessions", "sessions.json");
163
+ }
164
+ // ============ Mention Gating — 已抽取到 message-gating.ts ============
165
+ // ============ Command Detection(委托框架运行时 commands-registry) ============
166
+ /**
167
+ * 检测消息是否包含框架控制命令(如 /activation、/status 等)。
168
+ *
169
+ * 不再使用静态 KNOWN_CONTROL_COMMANDS 列表,而是委托给框架运行时
170
+ * pluginRuntime.channel.text.hasControlCommand(),确保框架新增命令时
171
+ * 无需手动同步。
172
+ *
173
+ * 如果 pluginRuntime 尚未初始化(极端边界),回退到简单的 "/" 前缀检测。
174
+ */
175
+ function hasControlCommand(text) {
176
+ if (!text || !text.startsWith("/"))
177
+ return false;
178
+ try {
179
+ const runtime = getQQBotRuntime();
180
+ const runtimeHasControlCommand = runtime?.channel?.text?.hasControlCommand;
181
+ if (typeof runtimeHasControlCommand === "function") {
182
+ return runtimeHasControlCommand(text);
183
+ }
184
+ }
185
+ catch {
186
+ // runtime 未初始化,fallback
187
+ }
188
+ // fallback:简单的 "/" + word 检测(宁可误判为 true 也不漏掉命令)
189
+ return /^\/[a-z][a-z0-9_-]*/i.test(text);
190
+ }
191
+ // ============ Text Command Gating ============
192
+ /**
193
+ * 判断文本命令是否启用。
194
+ * 当 cfg.commands.text === false 时禁用;QQ Bot 仅支持文本命令(无 native slash command)。
195
+ */
196
+ function shouldHandleTextCommands(cfg) {
197
+ const commands = cfg.commands;
198
+ // 仅当显式设置为 false 时禁用(默认启用)
199
+ return commands?.text !== false;
200
+ }
201
+ // ============ hasAnyMention 检测 ============
202
+ /**
203
+ * 检测消息中是否包含任何 @mention(不限于 @bot)。
204
+ * 如果消息 @ 了任何人,即使是控制命令也不应该 bypass mention 门控。
205
+ */
206
+ function hasAnyMention(params) {
207
+ // QQ 事件中 mentions 数组包含了消息中所有被 @ 的用户(含 bot)
208
+ if (params.mentions && params.mentions.length > 0)
209
+ return true;
210
+ // 兜底:检查文本中是否有 <@xxx> 格式的 mention
211
+ if (params.content && /<@!?\w+>/.test(params.content))
212
+ return true;
213
+ return false;
214
+ }
215
+ // ============ implicitMention 检测 ============
216
+ /**
217
+ * 检测引用回复是否构成隐式 mention。
218
+ * 如果用户回复的是 bot 发出的消息,视为隐式 mention。
219
+ */
220
+ function resolveImplicitMention(params) {
221
+ if (!params.refMsgIdx)
222
+ return false;
223
+ const refEntry = params.getRefEntry(params.refMsgIdx);
224
+ return refEntry?.isBot === true;
225
+ }
226
+ /**
227
+ * 解析 groupActivation(session store > 配置 requireMention > 默认值)
228
+ * @returns "mention" | "always"
229
+ */
230
+ function resolveGroupActivation(params) {
231
+ const defaultActivation = params.configRequireMention ? "mention" : "always";
232
+ try {
233
+ const storePath = resolveSessionStorePath(params.cfg, params.agentId);
234
+ if (!fs.existsSync(storePath)) {
235
+ return defaultActivation;
236
+ }
237
+ const raw = fs.readFileSync(storePath, "utf-8");
238
+ const store = JSON.parse(raw);
239
+ const entry = store[params.sessionKey];
240
+ if (!entry?.groupActivation) {
241
+ return defaultActivation;
242
+ }
243
+ const normalized = entry.groupActivation.trim().toLowerCase();
244
+ if (normalized === "mention" || normalized === "always") {
245
+ return normalized;
246
+ }
247
+ return defaultActivation;
248
+ }
249
+ catch {
250
+ // session store 读取失败时 fallback 到配置文件
251
+ return defaultActivation;
252
+ }
253
+ }
24
254
  // QQ Bot intents - 按权限级别分组
25
255
  const INTENTS = {
26
256
  // 基础权限(默认有)
@@ -30,10 +260,11 @@ const INTENTS = {
30
260
  // 需要申请的权限
31
261
  DIRECT_MESSAGE: 1 << 12, // 频道私信
32
262
  GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
263
+ INTERACTION: 1 << 26, // 按钮交互回调
33
264
  };
34
- // 固定使用完整权限(群聊 + 私信 + 频道),不做降级
35
- const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C;
36
- const FULL_INTENTS_DESC = "群聊+私信+频道";
265
+ // 固定使用完整权限(群聊 + 私信 + 频道 + 交互),不做降级
266
+ const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C | INTENTS.INTERACTION;
267
+ const FULL_INTENTS_DESC = "群聊+私信+频道+交互";
37
268
  // 重连配置
38
269
  const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
39
270
  const RATE_LIMIT_DELAY = 60000; // 遇到频率限制时等待 60 秒
@@ -189,7 +420,7 @@ export async function startGateway(ctx) {
189
420
  lastSeq = savedSession.lastSeq;
190
421
  log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`);
191
422
  }
192
- // ============ 按用户并发的消息队列 ============
423
+ // ============ 消息队列(复用 createMessageQueue,内置群消息合并/淘汰策略) ============
193
424
  const msgQueue = createMessageQueue({
194
425
  accountId: account.accountId,
195
426
  log,
@@ -358,6 +589,8 @@ export async function startGateway(ctx) {
358
589
  const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": PLUGIN_USER_AGENT } });
359
590
  currentWs = ws;
360
591
  const pluginRuntime = getQQBotRuntime();
592
+ // 群历史消息缓存:非@消息写入此 Map,被@时一次性注入上下文后清空
593
+ const groupHistories = new Map();
361
594
  // 处理收到的消息
362
595
  const handleMessage = async (event) => {
363
596
  log?.debug?.(`[qqbot:${account.accountId}] Received message: ${JSON.stringify(event)}`);
@@ -443,7 +676,7 @@ export async function startGateway(ctx) {
443
676
  const hasAsrReferFallback = voiceTranscriptSources.includes("asr");
444
677
  // 解析 QQ 表情标签,将 <faceType=...,ext="base64"> 替换为 【表情: 中文名】
445
678
  const parsedContent = parseFaceTags(event.content);
446
- const userContent = voiceText
679
+ let userContent = voiceText
447
680
  ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
448
681
  : parsedContent + attachmentInfo;
449
682
  // ============ 引用消息处理 ============
@@ -544,8 +777,8 @@ export async function startGateway(ctx) {
544
777
  }
545
778
  }
546
779
  // ============ 构建 contextInfo(静态/动态分离) ============
547
- // 设计原则(参考 Telegram/Discord 做法):
548
- // - 静态指引:每条消息不变的能力声明,
780
+ // 设计原则:
781
+ // - 静态指引:每条消息不变的内容(场景锚定、投递地址、能力说明),
549
782
  // 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
550
783
  // 且保证长 session 窗口截断后仍可见。
551
784
  // - 动态标签:每条消息变化的数据(时间、附件、ASR),
@@ -563,7 +796,7 @@ export async function startGateway(ctx) {
563
796
  const staticInstruction = staticParts.join(" | ");
564
797
  systemPrompts.unshift(staticInstruction);
565
798
  }
566
- // --- 动态上下文(仅框架信封未覆盖的附件信息) ---
799
+ // --- 动态上下文 ---
567
800
  const dynLines = [];
568
801
  if (imageUrls.length > 0) {
569
802
  dynLines.push(`- 图片: ${imageUrls.join(", ")}`);
@@ -574,22 +807,241 @@ export async function startGateway(ctx) {
574
807
  if (uniqueVoiceAsrReferTexts.length > 0) {
575
808
  dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`);
576
809
  }
577
- const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n" : "";
578
- // 命令直接透传,不注入上下文
579
- const userMessage = `${quotePart}${userContent}`;
580
- const agentBody = userContent.startsWith("/")
810
+ const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n\n" : "";
811
+ // --- 命令授权(所有消息类型共用,群消息门控也需要) ---
812
+ // allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
813
+ const allowFromList = account.config?.allowFrom ?? [];
814
+ const allowAll = allowFromList.length === 0 || allowFromList.some((entry) => entry === "*");
815
+ const commandAuthorized = allowAll || allowFromList.some((entry) => entry.toUpperCase() === event.senderId.toUpperCase());
816
+ // --- 群消息上下文:插件只提供策略,框架自动组装 hint ---
817
+ let groupSystemPrompt = "";
818
+ let wasMentioned = false;
819
+ let groupSubject = "";
820
+ let senderLabel = "";
821
+ if (event.type === "group" && event.groupOpenid) {
822
+ // 1. 群策略检查(直接用 config 工具函数,与 Discord 的 allow-list.ts 同理)
823
+ if (!isGroupAllowed(cfg, event.groupOpenid, account.accountId)) {
824
+ log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid} not allowed by groupPolicy, skipping`);
825
+ return;
826
+ }
827
+ // 2. @检测(委托 mentions 适配器)
828
+ const mentionPatternsForDetect = resolveMentionPatterns(cfg, route.agentId);
829
+ wasMentioned = detectWasMentioned({
830
+ eventType: event.eventType,
831
+ mentions: event.mentions,
832
+ content: event.content,
833
+ mentionPatterns: mentionPatternsForDetect,
834
+ });
835
+ // 3. requireMention 门控
836
+ // 优先级:session store 中的 /activation 命令 > 配置文件 requireMention > 默认值
837
+ // 未被 @ 时:消息仍写入上下文(让 bot 拥有完整对话记忆),但不触发 AI 回复
838
+ const configRequireMention = qqbotPlugin.groups?.resolveRequireMention?.({
839
+ cfg: cfg,
840
+ accountId: account.accountId,
841
+ groupId: event.groupOpenid,
842
+ }) ?? true;
843
+ const activation = resolveGroupActivation({
844
+ cfg: cfg,
845
+ agentId: route.agentId,
846
+ sessionKey: route.sessionKey,
847
+ configRequireMention,
848
+ });
849
+ const requireMention = activation === "mention";
850
+ // 4. 隐式 mention:引用回复 bot 的消息视为隐式 mention
851
+ const implicitMention = resolveImplicitMention({
852
+ refMsgIdx: event.refMsgIdx,
853
+ getRefEntry: getRefIndex,
854
+ });
855
+ // 4.5 统一门控:ignoreOtherMentions → shouldBlock → mention 门控
856
+ // 三层判断收敛到 resolveGroupMessageGate()
857
+ const contentForCommand = event.content?.trim() ?? "";
858
+ const allowTextCommands = shouldHandleTextCommands(cfg);
859
+ const gate = resolveGroupMessageGate({
860
+ ignoreOtherMentions: resolveIgnoreOtherMentions(cfg, event.groupOpenid, account.accountId),
861
+ hasAnyMention: hasAnyMention({ mentions: event.mentions, content: event.content }),
862
+ wasMentioned,
863
+ implicitMention,
864
+ allowTextCommands,
865
+ isControlCommand: hasControlCommand(contentForCommand),
866
+ commandAuthorized,
867
+ requireMention,
868
+ canDetectMention: true,
869
+ });
870
+ if (gate.action === "drop_other_mention") {
871
+ // @了其他人但未 @bot:记录历史后丢弃
872
+ const historyLimit = resolveHistoryLimit(cfg, event.groupOpenid, account.accountId);
873
+ const senderForHistory = event.senderName
874
+ ? `${event.senderName} (${event.senderId})`
875
+ : event.senderId;
876
+ const historyAttachments = toAttachmentSummaries(event.attachments);
877
+ recordPendingHistoryEntry({
878
+ historyMap: groupHistories,
879
+ historyKey: event.groupOpenid,
880
+ limit: historyLimit,
881
+ entry: {
882
+ sender: senderForHistory,
883
+ body: parseFaceTags(event.content),
884
+ timestamp: new Date(event.timestamp).getTime(),
885
+ messageId: event.messageId,
886
+ attachments: historyAttachments,
887
+ },
888
+ });
889
+ log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid}: drop message (ignoreOtherMentions=true, other user mentioned, bot not mentioned)`);
890
+ return;
891
+ }
892
+ if (gate.action === "block_unauthorized_command") {
893
+ // 未授权控制命令:静默拦截,不交给 AI
894
+ log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid}: blocked unauthorized control command from ${event.senderId}: ${contentForCommand.slice(0, 50)}`);
895
+ return;
896
+ }
897
+ if (gate.action === "skip_no_mention") {
898
+ // 非 @bot 消息:记录到群历史缓存后跳过 AI
899
+ const historyLimit = resolveHistoryLimit(cfg, event.groupOpenid, account.accountId);
900
+ const senderForHistory = event.senderName
901
+ ? `${event.senderName} (${event.senderId})`
902
+ : event.senderId;
903
+ const historyAttachments = toAttachmentSummaries(event.attachments);
904
+ recordPendingHistoryEntry({
905
+ historyMap: groupHistories,
906
+ historyKey: event.groupOpenid,
907
+ limit: historyLimit,
908
+ entry: {
909
+ sender: senderForHistory,
910
+ body: parseFaceTags(event.content),
911
+ timestamp: new Date(event.timestamp).getTime(),
912
+ messageId: event.messageId,
913
+ attachments: historyAttachments,
914
+ },
915
+ });
916
+ log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid}: activation=${activation} (configRequireMention=${configRequireMention}) not mentioned, recorded to history (limit=${historyLimit}, cached=${(groupHistories.get(event.groupOpenid) ?? []).length}${historyAttachments ? `, attachments=${historyAttachments.length}` : ""})`);
917
+ return;
918
+ }
919
+ // gate.action === "pass" — 更新 wasMentioned 为 effectiveWasMentioned(含 implicit + bypass)
920
+ wasMentioned = gate.effectiveWasMentioned;
921
+ // 5. 发送者标签
922
+ senderLabel = event.senderName
923
+ ? `${event.senderName} (${event.senderId})`
924
+ : event.senderId;
925
+ // 6. 群名称(从 config 中读取,fallback 为 openid 前 8 位)
926
+ groupSubject = resolveGroupName(cfg, event.groupOpenid, account.accountId);
927
+ // 7. GroupSystemPrompt — 根据消息来源(机器人/人类)和 @状态 注入差异化 PE
928
+ // 基础提示从 resolveGroupIntroHint 获取(群名称、平台限制等静态信息),
929
+ // 然后根据运行时状态追加针对性行为指引。
930
+ const baseHint = qqbotPlugin.groups?.resolveGroupIntroHint?.({
931
+ cfg: cfg,
932
+ accountId: account.accountId,
933
+ groupId: event.groupOpenid,
934
+ }) ?? "";
935
+ let behaviorPrompt = "";
936
+ // 从配置读取群行为 PE
937
+ behaviorPrompt = resolveGroupPrompt(cfg, event.groupOpenid, account.accountId);
938
+ groupSystemPrompt = [baseHint, behaviorPrompt].filter(Boolean).join("\n");
939
+ }
940
+ const mergedCount = event._mergedCount;
941
+ // 将 <@member_openid> 替换为 @username(使用 mentions 适配器)
942
+ if (event.type === "group" && event.mentions?.length) {
943
+ userContent = stripMentionText(userContent, event.mentions) ?? userContent;
944
+ }
945
+ else if (event.mentions?.length) {
946
+ for (const m of event.mentions) {
947
+ if (m.member_openid && m.username) {
948
+ userContent = userContent.replace(new RegExp(`<@${m.member_openid}>`, "g"), `@${m.username}`);
949
+ }
950
+ }
951
+ }
952
+ // 群消息 user prompt 带上发送者昵称(合并消息已内嵌发送者前缀,不再重复添加)
953
+ const isMergedMsg = mergedCount && mergedCount > 1;
954
+ const senderPrefix = (event.type === "group" && !isMergedMsg)
955
+ ? `[${event.senderName ? `${event.senderName} (${event.senderId})` : event.senderId}] `
956
+ : "";
957
+ const isAtYouTag = event.type === "group"
958
+ ? (wasMentioned ? " (@你)" : "")
959
+ : "";
960
+ // 合并消息:前面的消息用 envelope 历史格式,最后一条用当前消息格式(与 mention 单条回复对齐)
961
+ // BodyForAgent 只包含动态上下文 + 用户消息,不拼入 systemPrompts。
962
+ // systemPrompts([QQBot] to=...、TTS 能力声明等)通过 GroupSystemPrompt 注入到
963
+ // 框架的 extraSystemPrompt 中,不会存入 transcript 的 user turn content,
964
+ // 避免 Web UI 不显示用户 query 的问题。
965
+ let userMessage;
966
+ const mergedMessages = event._mergedMessages;
967
+ if (isMergedMsg && mergedMessages?.length) {
968
+ // --- 辅助:格式化单条子消息内容(表情解析 + mention 清理 + 附件标签) ---
969
+ const formatSubMsgContent = (m) => formatMessageContent({
970
+ content: m.content ?? "",
971
+ chatType: m.type,
972
+ mentions: m.mentions,
973
+ attachments: m.attachments,
974
+ parseFaceTags,
975
+ stripMentionText: (text, mentions) => stripMentionText(text, mentions) ?? text,
976
+ });
977
+ // 前面的消息使用 envelope 历史格式
978
+ const preceding = mergedMessages.slice(0, -1);
979
+ const lastMsg = mergedMessages[mergedMessages.length - 1];
980
+ const envelopeParts = preceding.map((m) => {
981
+ const msgContent = formatSubMsgContent(m);
982
+ const senderName = m.senderName
983
+ ? (m.senderName.includes(m.senderId) ? m.senderName : `${m.senderName} (${m.senderId})`)
984
+ : m.senderId;
985
+ return pluginRuntime.channel.reply.formatInboundEnvelope({
986
+ channel: "qqbot",
987
+ from: senderName,
988
+ timestamp: new Date(m.timestamp).getTime(),
989
+ body: msgContent,
990
+ chatType: "group",
991
+ envelope: envelopeOptions,
992
+ });
993
+ });
994
+ // 最后一条消息使用简洁格式:[发送者]: 内容 (@你)
995
+ const lastContent = formatSubMsgContent(lastMsg);
996
+ const lastSenderName = lastMsg.senderName
997
+ ? (lastMsg.senderName.includes(lastMsg.senderId) ? lastMsg.senderName : `${lastMsg.senderName} (${lastMsg.senderId})`)
998
+ : lastMsg.senderId;
999
+ const lastPart = `[${lastSenderName}] ${lastContent}${isAtYouTag}`;
1000
+ // 前置消息用段落标签包裹(类似引用消息的 [引用消息开始]...[引用消息结束])
1001
+ userMessage = buildMergedMessageContext({
1002
+ precedingParts: envelopeParts,
1003
+ currentMessage: lastPart,
1004
+ });
1005
+ }
1006
+ else {
1007
+ // 命令直接透传,不注入上下文
1008
+ userMessage = senderPrefix ? `${senderPrefix}${quotePart}${userContent}${isAtYouTag}` : `${quotePart}${userContent}`;
1009
+ }
1010
+ let agentBody = userContent.startsWith("/")
581
1011
  ? userContent
582
- : `${systemPrompts.join("\n")}\n\n${dynamicCtx}${userMessage}`;
1012
+ : `${dynamicCtx}${userMessage}`;
1013
+ // 被@时:将累积的非@历史消息注入上下文
1014
+ // 消息格式使用 formatInboundEnvelope 与正常消息保持一致
1015
+ if (event.type === "group" && event.groupOpenid) {
1016
+ const historyLimit = resolveHistoryLimit(cfg, event.groupOpenid, account.accountId);
1017
+ const envelopeOpts = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
1018
+ agentBody = buildPendingHistoryContext({
1019
+ historyMap: groupHistories,
1020
+ historyKey: event.groupOpenid,
1021
+ limit: historyLimit,
1022
+ currentMessage: agentBody,
1023
+ formatEntry: (entry) => {
1024
+ // 将附件描述追加到消息 body 末尾,确保富媒体上下文不丢失
1025
+ const attachmentDesc = formatAttachmentTags(entry.attachments);
1026
+ const bodyWithAttachments = attachmentDesc
1027
+ ? `${entry.body} ${attachmentDesc}`
1028
+ : entry.body;
1029
+ return pluginRuntime.channel.reply.formatInboundEnvelope({
1030
+ channel: "qqbot",
1031
+ from: entry.sender,
1032
+ timestamp: entry.timestamp,
1033
+ body: bodyWithAttachments,
1034
+ chatType: "group",
1035
+ envelope: envelopeOpts,
1036
+ });
1037
+ },
1038
+ });
1039
+ }
583
1040
  log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`);
584
1041
  const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
585
1042
  : event.type === "group" ? `qqbot:group:${event.groupOpenid}`
586
1043
  : `qqbot:c2c:${event.senderId}`;
587
1044
  const toAddress = fromAddress;
588
- // 计算命令授权状态
589
- // allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
590
- const allowFromList = account.config?.allowFrom ?? [];
591
- const allowAll = allowFromList.length === 0 || allowFromList.some((entry) => entry === "*");
592
- const commandAuthorized = allowAll || allowFromList.some((entry) => entry.toUpperCase() === event.senderId.toUpperCase());
593
1045
  // 分离 imageUrls 为本地路径和远程 URL,供 openclaw 原生媒体处理
594
1046
  const localMediaPaths = [];
595
1047
  const localMediaTypes = [];
@@ -607,6 +1059,11 @@ export async function startGateway(ctx) {
607
1059
  localMediaTypes.push(t);
608
1060
  }
609
1061
  }
1062
+ // QQBot 静态系统提示(投递地址、TTS 能力等)合并到 GroupSystemPrompt,
1063
+ // 通过框架的 extraSystemPrompt 机制注入 AI system prompt,
1064
+ // 不会存入 transcript 的 user turn content。
1065
+ const qqbotSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : "";
1066
+ const mergedGroupSystemPrompt = [qqbotSystemInstruction, groupSystemPrompt].filter(Boolean).join("\n") || undefined;
610
1067
  const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
611
1068
  Body: body,
612
1069
  BodyForAgent: agentBody,
@@ -617,6 +1074,11 @@ export async function startGateway(ctx) {
617
1074
  SessionKey: route.sessionKey,
618
1075
  AccountId: route.accountId,
619
1076
  ChatType: isGroupChat ? "group" : "direct",
1077
+ GroupSystemPrompt: mergedGroupSystemPrompt,
1078
+ // 群消息元数据(框架级字段)
1079
+ WasMentioned: isGroupChat ? wasMentioned : undefined,
1080
+ SenderLabel: isGroupChat ? senderLabel : undefined,
1081
+ GroupSubject: isGroupChat ? groupSubject : undefined,
620
1082
  SenderId: event.senderId,
621
1083
  SenderName: event.senderName,
622
1084
  Provider: "qqbot",
@@ -646,7 +1108,7 @@ export async function startGateway(ctx) {
646
1108
  MediaUrls: remoteMediaUrls,
647
1109
  MediaUrl: remoteMediaUrls[0],
648
1110
  } : {}),
649
- // 引用消息上下文(对齐 Telegram/Discord 的 ReplyTo 字段)
1111
+ // 引用消息上下文
650
1112
  ...(replyToId ? {
651
1113
  ReplyToId: replyToId,
652
1114
  ReplyToBody: replyToBody,
@@ -669,7 +1131,7 @@ export async function startGateway(ctx) {
669
1131
  const sendErrorMessage = (errorText) => sendErrorToTarget(replyCtx, errorText);
670
1132
  // 使用 AsyncLocalStorage 建立请求级上下文,作用域内所有异步代码
671
1133
  // (包括 AI agent 调用、tool execute)都能安全获取当前会话信息,无并发竞态。
672
- await runWithRequestContext({ target: qualifiedTarget }, async () => {
1134
+ await runWithRequestContext({ target: qualifiedTarget, accountId: account.accountId }, async () => {
673
1135
  try {
674
1136
  const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
675
1137
  // 追踪是否有响应
@@ -896,6 +1358,14 @@ export async function startGateway(ctx) {
896
1358
  // StreamingController 内部已有重试,这里只打日志
897
1359
  log?.error(`[qqbot:${account.accountId}] Streaming deliver error: ${err}`);
898
1360
  }
1361
+ let replyText = payload.text ?? "";
1362
+ // 群消息:模型回复 NO_REPLY 表示无需回复,跳过发送
1363
+ // 注意:核心框架的 reply-delivery 已会拦截 NO_REPLY,此处为双重保险
1364
+ const trimmedReply = replyText.trim();
1365
+ if (event.type === "group" && (trimmedReply === "NO_REPLY" || trimmedReply === "[SKIP]")) {
1366
+ log?.info(`[qqbot:${account.accountId}] Model decided to skip group message (token=${trimmedReply}) from ${event.senderId}: ${event.content?.slice(0, 50)}`);
1367
+ return;
1368
+ }
899
1369
  // 检查是否因流式 API 不可用而需要降级(ensureStreamingStarted 全部失败)
900
1370
  // 如果需要降级,不 return,让本次 deliver 的 payload.text(全量文本)继续走普通发送逻辑
901
1371
  if (streamingController.shouldFallbackToStatic) {
@@ -1092,6 +1562,15 @@ export async function startGateway(ctx) {
1092
1562
  if (streamingController?.shouldFallbackToStatic) {
1093
1563
  log?.debug?.(`[qqbot:${account.accountId}] Streaming was degraded to static mode (no chunk sent successfully)`);
1094
1564
  }
1565
+ // 回复完成后清空群历史缓存(每次回复后重新累积)
1566
+ if (event.type === "group" && event.groupOpenid) {
1567
+ const historyLimit = resolveHistoryLimit(cfg, event.groupOpenid, account.accountId);
1568
+ clearPendingHistory({
1569
+ historyMap: groupHistories,
1570
+ historyKey: event.groupOpenid,
1571
+ limit: historyLimit,
1572
+ });
1573
+ }
1095
1574
  }
1096
1575
  }
1097
1576
  catch (err) {
@@ -1301,10 +1780,11 @@ export async function startGateway(ctx) {
1301
1780
  }
1302
1781
  else if (t === "GROUP_AT_MESSAGE_CREATE") {
1303
1782
  const event = d;
1304
- // P1-3: 记录已知用户(群组用户)
1783
+ // @ 的消息,直接入队回复
1305
1784
  recordKnownUser({
1306
1785
  openid: event.author.member_openid,
1307
1786
  type: "group",
1787
+ nickname: event.author.username,
1308
1788
  groupOpenid: event.group_openid,
1309
1789
  accountId: account.accountId,
1310
1790
  });
@@ -1312,6 +1792,7 @@ export async function startGateway(ctx) {
1312
1792
  trySlashCommandOrEnqueue({
1313
1793
  type: "group",
1314
1794
  senderId: event.author.member_openid,
1795
+ senderName: event.author.username,
1315
1796
  content: event.content,
1316
1797
  messageId: event.id,
1317
1798
  timestamp: event.timestamp,
@@ -1319,6 +1800,67 @@ export async function startGateway(ctx) {
1319
1800
  attachments: event.attachments,
1320
1801
  refMsgIdx: groupRefs.refMsgIdx,
1321
1802
  msgIdx: groupRefs.msgIdx,
1803
+ eventType: "GROUP_AT_MESSAGE_CREATE",
1804
+ mentions: event.mentions,
1805
+ messageScene: event.message_scene,
1806
+ });
1807
+ }
1808
+ else if (t === "GROUP_MESSAGE_CREATE") {
1809
+ const event = d;
1810
+ recordKnownUser({
1811
+ openid: event.author.member_openid,
1812
+ type: "group",
1813
+ nickname: event.author.username,
1814
+ groupOpenid: event.group_openid,
1815
+ accountId: account.accountId,
1816
+ });
1817
+ const groupRefs = parseRefIndices(event.message_scene?.ext);
1818
+ trySlashCommandOrEnqueue({
1819
+ type: "group",
1820
+ senderId: event.author.member_openid,
1821
+ senderName: event.author.username,
1822
+ senderIsBot: event.author.bot,
1823
+ content: event.content,
1824
+ messageId: event.id,
1825
+ timestamp: event.timestamp,
1826
+ groupOpenid: event.group_openid,
1827
+ attachments: event.attachments,
1828
+ refMsgIdx: groupRefs.refMsgIdx,
1829
+ msgIdx: groupRefs.msgIdx,
1830
+ eventType: "GROUP_MESSAGE_CREATE",
1831
+ mentions: event.mentions,
1832
+ messageScene: event.message_scene,
1833
+ });
1834
+ }
1835
+ else if (t === "GROUP_ADD_ROBOT") {
1836
+ const event = d;
1837
+ log?.info(`[qqbot:${account.accountId}] Bot added to group: ${event.group_openid} by ${event.op_member_openid}`);
1838
+ recordKnownUser({
1839
+ openid: event.op_member_openid,
1840
+ type: "group",
1841
+ groupOpenid: event.group_openid,
1842
+ accountId: account.accountId,
1843
+ });
1844
+ }
1845
+ else if (t === "GROUP_DEL_ROBOT") {
1846
+ const event = d;
1847
+ log?.info(`[qqbot:${account.accountId}] Bot removed from group: ${event.group_openid} by ${event.op_member_openid}`);
1848
+ }
1849
+ else if (t === "GROUP_MSG_REJECT") {
1850
+ const event = d;
1851
+ log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} rejected bot proactive messages (by ${event.op_member_openid})`);
1852
+ }
1853
+ else if (t === "GROUP_MSG_RECEIVE") {
1854
+ const event = d;
1855
+ log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} accepted bot proactive messages (by ${event.op_member_openid})`);
1856
+ }
1857
+ else if (t === "INTERACTION_CREATE") {
1858
+ const event = d;
1859
+ const resolved = event.data?.resolved;
1860
+ const sceneDesc = event.scene ?? (event.chat_type === 0 ? "guild" : event.chat_type === 1 ? "group" : "c2c");
1861
+ log?.info(`[qqbot:${account.accountId}] Interaction: scene=${sceneDesc}, type=${event.data?.type}, button_id=${resolved?.button_id}, button_data=${resolved?.button_data}, user=${event.group_member_openid || event.user_openid || resolved?.user_id || "unknown"}`);
1862
+ handleInteractionCreate({ event, account, cfg, log }).catch((err) => {
1863
+ log?.error(`[qqbot:${account.accountId}] Failed to handle interaction ${event.id}: ${err}`);
1322
1864
  });
1323
1865
  }
1324
1866
  break;
@@ -1354,7 +1896,7 @@ export async function startGateway(ctx) {
1354
1896
  ws.on("close", (code, reason) => {
1355
1897
  log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
1356
1898
  isConnecting = false; // 释放锁
1357
- // 根据错误码处理(参考 QQ 官方文档)
1899
+ // 根据错误码处理(见 QQ 官方文档)
1358
1900
  // 4004: CODE_INVALID_TOKEN - Token 无效,需刷新 token 重新连接
1359
1901
  // 4006: CODE_SESSION_NO_LONGER_VALID - 会话失效,需重新 identify
1360
1902
  // 4007: CODE_INVALID_SEQ - Resume 时 seq 无效,需重新 identify