@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.
- package/README.md +24 -15
- package/README.zh.md +24 -15
- package/dist/src/api.d.ts +32 -5
- package/dist/src/api.js +111 -12
- package/dist/src/channel.d.ts +18 -0
- package/dist/src/channel.js +85 -2
- package/dist/src/config.d.ts +33 -2
- package/dist/src/config.js +125 -1
- package/dist/src/gateway.js +566 -24
- package/dist/src/group-history.d.ts +136 -0
- package/dist/src/group-history.js +226 -0
- package/dist/src/message-gating.d.ts +53 -0
- package/dist/src/message-gating.js +107 -0
- package/dist/src/message-queue.d.ts +36 -0
- package/dist/src/message-queue.js +164 -22
- package/dist/src/outbound.d.ts +4 -4
- package/dist/src/outbound.js +18 -6
- package/dist/src/ref-index-store.js +5 -28
- package/dist/src/request-context.d.ts +7 -0
- package/dist/src/request-context.js +7 -0
- package/dist/src/slash-commands.d.ts +6 -0
- package/dist/src/slash-commands.js +3 -3
- package/dist/src/tools/remind.js +17 -9
- package/dist/src/types.d.ts +90 -2
- package/dist/src/utils/audio-convert.d.ts +1 -1
- package/dist/src/utils/audio-convert.js +1 -1
- package/dist/src/utils/chunked-upload.d.ts +11 -2
- package/dist/src/utils/chunked-upload.js +63 -11
- package/dist/src/utils/media-send.js +1 -1
- package/dist/src/utils/text-parsing.js +7 -18
- package/package.json +1 -1
- package/scripts/postinstall-link-sdk.js +22 -9
- package/scripts/upgrade-via-npm.sh +11 -3
- package/scripts/upgrade-via-source.sh +63 -15
- package/skills/qqbot-remind/SKILL.md +21 -11
- package/src/api.ts +135 -7
- package/src/channel.ts +85 -2
- package/src/config.ts +170 -3
- package/src/gateway.ts +662 -29
- package/src/group-history.ts +328 -0
- package/src/message-gating.ts +190 -0
- package/src/message-queue.ts +201 -21
- package/src/openclaw-plugin-sdk.d.ts +65 -0
- package/src/outbound.ts +18 -6
- package/src/ref-index-store.ts +5 -27
- package/src/request-context.ts +10 -0
- package/src/slash-commands.ts +3 -3
- package/src/tools/remind.ts +17 -9
- package/src/types.ts +94 -2
- package/src/utils/audio-convert.ts +1 -1
- package/src/utils/chunked-upload.ts +76 -12
- package/src/utils/media-send.ts +1 -2
- package/src/utils/text-parsing.ts +7 -14
package/src/gateway.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent, InteractionEvent } from "./types.js";
|
|
5
|
+
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, sendProactiveGroupMessage, acknowledgeInteraction, getApiPluginVersion } from "./api.js";
|
|
5
6
|
import { loadSession, saveSession, clearSession } from "./session-store.js";
|
|
6
7
|
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
|
7
8
|
import { getQQBotRuntime } from "./runtime.js";
|
|
9
|
+
import { isGroupAllowed, resolveGroupName, resolveGroupPrompt, resolveHistoryLimit, resolveGroupPolicy, resolveGroupConfig, resolveIgnoreOtherMentions, resolveMentionPatterns } from "./config.js";
|
|
10
|
+
import { qqbotPlugin, stripMentionText, detectWasMentioned } from "./channel.js";
|
|
11
|
+
import {
|
|
12
|
+
recordPendingHistoryEntry,
|
|
13
|
+
buildPendingHistoryContext,
|
|
14
|
+
buildMergedMessageContext,
|
|
15
|
+
clearPendingHistory,
|
|
16
|
+
formatAttachmentTags,
|
|
17
|
+
formatMessageContent,
|
|
18
|
+
toAttachmentSummaries,
|
|
19
|
+
type HistoryEntry,
|
|
20
|
+
} from "./group-history.js";
|
|
21
|
+
|
|
8
22
|
import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex, type RefAttachmentSummary } from "./ref-index-store.js";
|
|
9
|
-
import { matchSlashCommand, type SlashCommandContext, type SlashCommandFileResult } from "./slash-commands.js";
|
|
23
|
+
import { matchSlashCommand, getFrameworkVersion, parseFrameworkDateVersion, type SlashCommandContext, type SlashCommandFileResult } from "./slash-commands.js";
|
|
10
24
|
import { createMessageQueue, type QueuedMessage } from "./message-queue.js";
|
|
11
25
|
import { triggerUpdateCheck } from "./update-checker.js";
|
|
12
26
|
import { startImageServer, isImageServerRunning, type ImageServerConfig } from "./image-server.js";
|
|
@@ -23,6 +37,283 @@ import { parseAndSendMediaTags, sendPlainReply, type DeliverEventContext, type D
|
|
|
23
37
|
import { createDeliverDebouncer, type DeliverDebouncer } from "./deliver-debounce.js";
|
|
24
38
|
import { runWithRequestContext } from "./request-context.js";
|
|
25
39
|
import { StreamingController, shouldUseStreaming } from "./streaming.js";
|
|
40
|
+
import { resolveGroupMessageGate } from "./message-gating.js";
|
|
41
|
+
|
|
42
|
+
// ============ Interaction 处理 ============
|
|
43
|
+
|
|
44
|
+
/** 配置查询交互类型 */
|
|
45
|
+
const INTERACTION_TYPE_CONFIG_QUERY = 2001;
|
|
46
|
+
|
|
47
|
+
/** 配置更新交互类型 */
|
|
48
|
+
const INTERACTION_TYPE_CONFIG_UPDATE = 2002;
|
|
49
|
+
|
|
50
|
+
/** 处理 INTERACTION_CREATE 事件 */
|
|
51
|
+
async function handleInteractionCreate(params: {
|
|
52
|
+
event: InteractionEvent;
|
|
53
|
+
account: ResolvedQQBotAccount;
|
|
54
|
+
cfg: unknown;
|
|
55
|
+
log?: { info: (msg: string) => void; warn?: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void };
|
|
56
|
+
}): Promise<void> {
|
|
57
|
+
const { event, account, cfg, log } = params;
|
|
58
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
59
|
+
|
|
60
|
+
if (event.data?.type === INTERACTION_TYPE_CONFIG_QUERY) {
|
|
61
|
+
// 从框架 configApi 读取最新配置(而非闭包中的旧 cfg),确保配置查询返回的数据与磁盘一致
|
|
62
|
+
const runtime = getQQBotRuntime();
|
|
63
|
+
const configApi = runtime.config as {
|
|
64
|
+
loadConfig: () => Record<string, unknown>;
|
|
65
|
+
writeConfigFile: (cfg: unknown) => Promise<void>;
|
|
66
|
+
};
|
|
67
|
+
const latestCfg = configApi.loadConfig() as Record<string, unknown>;
|
|
68
|
+
|
|
69
|
+
const groupOpenid = event.group_openid ?? "";
|
|
70
|
+
const groupCfg = groupOpenid ? resolveGroupConfig(latestCfg as any, groupOpenid, account.accountId) : null;
|
|
71
|
+
const groupPolicy = resolveGroupPolicy(latestCfg as any, account.accountId);
|
|
72
|
+
// require_mention 协议:字符串 "mention" | "always"(mention=@机器人时激活,always=总是激活)
|
|
73
|
+
const configRequireMention = groupCfg?.requireMention ?? true;
|
|
74
|
+
const requireMentionMode: GroupActivationMode = configRequireMention ? "mention" : "always";
|
|
75
|
+
const pluginVersion = getApiPluginVersion();
|
|
76
|
+
const fwVersionRaw = getFrameworkVersion();
|
|
77
|
+
const clawVer = parseFrameworkDateVersion(fwVersionRaw) ?? fwVersionRaw;
|
|
78
|
+
|
|
79
|
+
// 通过路由解析 agentId(与消息处理流程一致),用于 agent-aware 的 mentionPatterns
|
|
80
|
+
const interactionAgentId = groupOpenid
|
|
81
|
+
? (runtime.channel?.routing?.resolveAgentRoute?.({
|
|
82
|
+
cfg: latestCfg,
|
|
83
|
+
channel: "qqbot",
|
|
84
|
+
accountId: account.accountId,
|
|
85
|
+
peer: { kind: "group", id: groupOpenid },
|
|
86
|
+
}) as { agentId?: string } | undefined)?.agentId
|
|
87
|
+
: undefined;
|
|
88
|
+
|
|
89
|
+
// mention_patterns 协议:逗号分隔的字符串(@文本的名称提及BOT名,多个使用,分隔)
|
|
90
|
+
const mentionPatternsArr: string[] = resolveMentionPatterns(latestCfg as any, interactionAgentId);
|
|
91
|
+
const mentionPatterns = mentionPatternsArr.join(",");
|
|
92
|
+
|
|
93
|
+
const clawCfg = {
|
|
94
|
+
channel_type: "qqbot",
|
|
95
|
+
channel_ver: pluginVersion,
|
|
96
|
+
claw_type: "openclaw",
|
|
97
|
+
claw_ver: clawVer,
|
|
98
|
+
require_mention: requireMentionMode,
|
|
99
|
+
group_policy: groupPolicy,
|
|
100
|
+
mention_patterns: mentionPatterns,
|
|
101
|
+
online_state: "online",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await acknowledgeInteraction(token, event.id, 0, { claw_cfg: clawCfg });
|
|
105
|
+
log?.info(`[qqbot:${account.accountId}] Interaction ACK (type=${INTERACTION_TYPE_CONFIG_QUERY}) sent: ${event.id}, claw_cfg=${JSON.stringify(clawCfg)}`);
|
|
106
|
+
} else if (event.data?.type === INTERACTION_TYPE_CONFIG_UPDATE) {
|
|
107
|
+
// type=2002: 配置更新交互,从 resolved.claw_cfg 获取更新信息并写入本地配置
|
|
108
|
+
const resolved = event.data.resolved;
|
|
109
|
+
const clawCfgUpdate = (resolved as Record<string, unknown>)?.claw_cfg as Record<string, unknown> | undefined;
|
|
110
|
+
const groupOpenid = event.group_openid ?? "";
|
|
111
|
+
|
|
112
|
+
const runtime = getQQBotRuntime();
|
|
113
|
+
const configApi = runtime.config as {
|
|
114
|
+
loadConfig: () => Record<string, unknown>;
|
|
115
|
+
writeConfigFile: (cfg: unknown) => Promise<void>;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const currentCfg = structuredClone(configApi.loadConfig()) as Record<string, unknown>;
|
|
119
|
+
const qqbot = ((currentCfg.channels ?? {}) as Record<string, unknown>).qqbot as Record<string, unknown> | undefined;
|
|
120
|
+
|
|
121
|
+
let changed = false;
|
|
122
|
+
|
|
123
|
+
if (clawCfgUpdate) {
|
|
124
|
+
// 更新 require_mention(群级别)——协议为 "mention" | "always",写回配置时转为 boolean
|
|
125
|
+
if (clawCfgUpdate.require_mention !== undefined && groupOpenid && qqbot) {
|
|
126
|
+
const requireMentionBool = clawCfgUpdate.require_mention === "mention";
|
|
127
|
+
const accountId = account.accountId;
|
|
128
|
+
const isNamedAccount = accountId !== "default" && (qqbot.accounts as Record<string, Record<string, unknown>> | undefined)?.[accountId];
|
|
129
|
+
|
|
130
|
+
if (isNamedAccount) {
|
|
131
|
+
const accounts = qqbot.accounts as Record<string, Record<string, unknown>>;
|
|
132
|
+
const acct = accounts[accountId] ?? {};
|
|
133
|
+
const groups = (acct.groups ?? {}) as Record<string, Record<string, unknown>>;
|
|
134
|
+
groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
|
|
135
|
+
acct.groups = groups;
|
|
136
|
+
accounts[accountId] = acct;
|
|
137
|
+
qqbot.accounts = accounts;
|
|
138
|
+
} else {
|
|
139
|
+
const groups = (qqbot.groups ?? {}) as Record<string, Record<string, unknown>>;
|
|
140
|
+
groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
|
|
141
|
+
qqbot.groups = groups;
|
|
142
|
+
}
|
|
143
|
+
changed = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (changed) {
|
|
148
|
+
await configApi.writeConfigFile(currentCfg);
|
|
149
|
+
log?.info(`[qqbot:${account.accountId}] Config updated via interaction ${event.id}: ${JSON.stringify({
|
|
150
|
+
require_mention: clawCfgUpdate?.require_mention,
|
|
151
|
+
group_openid: groupOpenid || undefined,
|
|
152
|
+
})}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 无论更新是否成功,ACK 都上报最新的 claw_cfg 快照(写入后重新读取确保一致)
|
|
156
|
+
const latestCfg = changed ? (configApi.loadConfig() as Record<string, unknown>) : currentCfg;
|
|
157
|
+
const updatedGroupCfg = groupOpenid ? resolveGroupConfig(latestCfg as any, groupOpenid, account.accountId) : null;
|
|
158
|
+
const updatedRequireMention = updatedGroupCfg?.requireMention ?? true;
|
|
159
|
+
const updatedRequireMentionMode: GroupActivationMode = updatedRequireMention ? "mention" : "always";
|
|
160
|
+
const pluginVersion = getApiPluginVersion();
|
|
161
|
+
const fwVersionRaw = getFrameworkVersion();
|
|
162
|
+
const clawVer = parseFrameworkDateVersion(fwVersionRaw) ?? fwVersionRaw;
|
|
163
|
+
|
|
164
|
+
const ackClawCfg = {
|
|
165
|
+
channel_type: "qqbot",
|
|
166
|
+
channel_ver: pluginVersion,
|
|
167
|
+
claw_type: "openclaw",
|
|
168
|
+
claw_ver: clawVer,
|
|
169
|
+
require_mention: updatedRequireMentionMode,
|
|
170
|
+
online_state: "online",
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
await acknowledgeInteraction(token, event.id, 0, { claw_cfg: ackClawCfg });
|
|
174
|
+
log?.info(`[qqbot:${account.accountId}] Interaction ACK (type=${INTERACTION_TYPE_CONFIG_UPDATE}) sent: ${event.id}, claw_cfg=${JSON.stringify(ackClawCfg)}`);
|
|
175
|
+
} else {
|
|
176
|
+
// 其他类型:普通 ACK
|
|
177
|
+
await acknowledgeInteraction(token, event.id);
|
|
178
|
+
log?.debug?.(`[qqbot:${account.accountId}] Interaction ACK sent: ${event.id}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// /activation 命令支持:读取 session store 中的 groupActivation 值
|
|
183
|
+
// plugin-sdk 未导出 loadSessionStore,插件侧内联实现(只读)
|
|
184
|
+
|
|
185
|
+
type GroupActivationMode = "mention" | "always";
|
|
186
|
+
|
|
187
|
+
/** 解析 session store 文件路径 */
|
|
188
|
+
function resolveSessionStorePath(cfg: Record<string, unknown>, agentId?: string): string {
|
|
189
|
+
const sessionCfg = (cfg as any)?.session;
|
|
190
|
+
const store: string | undefined = sessionCfg?.store;
|
|
191
|
+
const resolvedAgentId = agentId || "default";
|
|
192
|
+
|
|
193
|
+
if (store) {
|
|
194
|
+
let expanded = store;
|
|
195
|
+
if (expanded.includes("{agentId}")) {
|
|
196
|
+
expanded = expanded.replaceAll("{agentId}", resolvedAgentId);
|
|
197
|
+
}
|
|
198
|
+
if (expanded.startsWith("~")) {
|
|
199
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
200
|
+
expanded = expanded.replace(/^~/, home);
|
|
201
|
+
}
|
|
202
|
+
return path.resolve(expanded);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 默认路径: ~/.openclaw/agents/{agentId}/sessions/sessions.json
|
|
206
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim()
|
|
207
|
+
|| process.env.CLAWDBOT_STATE_DIR?.trim()
|
|
208
|
+
|| path.join(process.env.HOME || process.env.USERPROFILE || "", ".openclaw");
|
|
209
|
+
return path.join(stateDir, "agents", resolvedAgentId, "sessions", "sessions.json");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ============ Mention Gating — 已抽取到 message-gating.ts ============
|
|
213
|
+
|
|
214
|
+
// ============ Command Detection(委托框架运行时 commands-registry) ============
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 检测消息是否包含框架控制命令(如 /activation、/status 等)。
|
|
218
|
+
*
|
|
219
|
+
* 不再使用静态 KNOWN_CONTROL_COMMANDS 列表,而是委托给框架运行时
|
|
220
|
+
* pluginRuntime.channel.text.hasControlCommand(),确保框架新增命令时
|
|
221
|
+
* 无需手动同步。
|
|
222
|
+
*
|
|
223
|
+
* 如果 pluginRuntime 尚未初始化(极端边界),回退到简单的 "/" 前缀检测。
|
|
224
|
+
*/
|
|
225
|
+
function hasControlCommand(text: string): boolean {
|
|
226
|
+
if (!text || !text.startsWith("/")) return false;
|
|
227
|
+
try {
|
|
228
|
+
const runtime = getQQBotRuntime();
|
|
229
|
+
const runtimeHasControlCommand = runtime?.channel?.text?.hasControlCommand;
|
|
230
|
+
if (typeof runtimeHasControlCommand === "function") {
|
|
231
|
+
return runtimeHasControlCommand(text);
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// runtime 未初始化,fallback
|
|
235
|
+
}
|
|
236
|
+
// fallback:简单的 "/" + word 检测(宁可误判为 true 也不漏掉命令)
|
|
237
|
+
return /^\/[a-z][a-z0-9_-]*/i.test(text);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============ Text Command Gating ============
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 判断文本命令是否启用。
|
|
244
|
+
* 当 cfg.commands.text === false 时禁用;QQ Bot 仅支持文本命令(无 native slash command)。
|
|
245
|
+
*/
|
|
246
|
+
function shouldHandleTextCommands(cfg: Record<string, unknown>): boolean {
|
|
247
|
+
const commands = cfg.commands as { text?: boolean } | undefined;
|
|
248
|
+
// 仅当显式设置为 false 时禁用(默认启用)
|
|
249
|
+
return commands?.text !== false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ============ hasAnyMention 检测 ============
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 检测消息中是否包含任何 @mention(不限于 @bot)。
|
|
256
|
+
* 如果消息 @ 了任何人,即使是控制命令也不应该 bypass mention 门控。
|
|
257
|
+
*/
|
|
258
|
+
function hasAnyMention(params: {
|
|
259
|
+
mentions?: Array<{ is_you?: boolean; bot?: boolean; [key: string]: unknown }>;
|
|
260
|
+
content?: string;
|
|
261
|
+
}): boolean {
|
|
262
|
+
// QQ 事件中 mentions 数组包含了消息中所有被 @ 的用户(含 bot)
|
|
263
|
+
if (params.mentions && params.mentions.length > 0) return true;
|
|
264
|
+
// 兜底:检查文本中是否有 <@xxx> 格式的 mention
|
|
265
|
+
if (params.content && /<@!?\w+>/.test(params.content)) return true;
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============ implicitMention 检测 ============
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 检测引用回复是否构成隐式 mention。
|
|
273
|
+
* 如果用户回复的是 bot 发出的消息,视为隐式 mention。
|
|
274
|
+
*/
|
|
275
|
+
function resolveImplicitMention(params: {
|
|
276
|
+
refMsgIdx?: string;
|
|
277
|
+
getRefEntry: (idx: string) => { isBot?: boolean } | null;
|
|
278
|
+
}): boolean {
|
|
279
|
+
if (!params.refMsgIdx) return false;
|
|
280
|
+
const refEntry = params.getRefEntry(params.refMsgIdx);
|
|
281
|
+
return refEntry?.isBot === true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 解析 groupActivation(session store > 配置 requireMention > 默认值)
|
|
286
|
+
* @returns "mention" | "always"
|
|
287
|
+
*/
|
|
288
|
+
function resolveGroupActivation(params: {
|
|
289
|
+
cfg: Record<string, unknown>;
|
|
290
|
+
agentId: string;
|
|
291
|
+
sessionKey: string;
|
|
292
|
+
configRequireMention: boolean;
|
|
293
|
+
}): GroupActivationMode {
|
|
294
|
+
const defaultActivation: GroupActivationMode = params.configRequireMention ? "mention" : "always";
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const storePath = resolveSessionStorePath(params.cfg, params.agentId);
|
|
298
|
+
if (!fs.existsSync(storePath)) {
|
|
299
|
+
return defaultActivation;
|
|
300
|
+
}
|
|
301
|
+
const raw = fs.readFileSync(storePath, "utf-8");
|
|
302
|
+
const store = JSON.parse(raw) as Record<string, { groupActivation?: string }>;
|
|
303
|
+
const entry = store[params.sessionKey];
|
|
304
|
+
if (!entry?.groupActivation) {
|
|
305
|
+
return defaultActivation;
|
|
306
|
+
}
|
|
307
|
+
const normalized = entry.groupActivation.trim().toLowerCase();
|
|
308
|
+
if (normalized === "mention" || normalized === "always") {
|
|
309
|
+
return normalized;
|
|
310
|
+
}
|
|
311
|
+
return defaultActivation;
|
|
312
|
+
} catch {
|
|
313
|
+
// session store 读取失败时 fallback 到配置文件
|
|
314
|
+
return defaultActivation;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
26
317
|
|
|
27
318
|
// QQ Bot intents - 按权限级别分组
|
|
28
319
|
const INTENTS = {
|
|
@@ -33,11 +324,12 @@ const INTENTS = {
|
|
|
33
324
|
// 需要申请的权限
|
|
34
325
|
DIRECT_MESSAGE: 1 << 12, // 频道私信
|
|
35
326
|
GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
|
|
327
|
+
INTERACTION: 1 << 26, // 按钮交互回调
|
|
36
328
|
};
|
|
37
329
|
|
|
38
|
-
// 固定使用完整权限(群聊 + 私信 +
|
|
39
|
-
const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C;
|
|
40
|
-
const FULL_INTENTS_DESC = "
|
|
330
|
+
// 固定使用完整权限(群聊 + 私信 + 频道 + 交互),不做降级
|
|
331
|
+
const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C | INTENTS.INTERACTION;
|
|
332
|
+
const FULL_INTENTS_DESC = "群聊+私信+频道+交互";
|
|
41
333
|
|
|
42
334
|
// 重连配置
|
|
43
335
|
const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
|
|
@@ -220,7 +512,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
220
512
|
log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`);
|
|
221
513
|
}
|
|
222
514
|
|
|
223
|
-
// ============
|
|
515
|
+
// ============ 消息队列(复用 createMessageQueue,内置群消息合并/淘汰策略) ============
|
|
224
516
|
const msgQueue = createMessageQueue({
|
|
225
517
|
accountId: account.accountId,
|
|
226
518
|
log,
|
|
@@ -409,11 +701,15 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
409
701
|
|
|
410
702
|
const pluginRuntime = getQQBotRuntime();
|
|
411
703
|
|
|
704
|
+
// 群历史消息缓存:非@消息写入此 Map,被@时一次性注入上下文后清空
|
|
705
|
+
const groupHistories = new Map<string, HistoryEntry[]>();
|
|
706
|
+
|
|
412
707
|
// 处理收到的消息
|
|
413
708
|
const handleMessage = async (event: {
|
|
414
709
|
type: "c2c" | "guild" | "dm" | "group";
|
|
415
710
|
senderId: string;
|
|
416
711
|
senderName?: string;
|
|
712
|
+
senderIsBot?: boolean;
|
|
417
713
|
content: string;
|
|
418
714
|
messageId: string;
|
|
419
715
|
timestamp: string;
|
|
@@ -423,6 +719,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
423
719
|
attachments?: Array<{ content_type: string; url: string; filename?: string; voice_wav_url?: string; asr_refer_text?: string }>;
|
|
424
720
|
refMsgIdx?: string;
|
|
425
721
|
msgIdx?: string;
|
|
722
|
+
eventType?: string;
|
|
723
|
+
mentions?: Array<{ scope?: "all" | "single"; id?: string; user_openid?: string; member_openid?: string; username?: string; bot?: boolean; is_you?: boolean }>;
|
|
724
|
+
messageScene?: { source?: string; ext?: string[] };
|
|
426
725
|
}) => {
|
|
427
726
|
|
|
428
727
|
log?.debug?.(`[qqbot:${account.accountId}] Received message: ${JSON.stringify(event)}`);
|
|
@@ -530,7 +829,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
530
829
|
|
|
531
830
|
// 解析 QQ 表情标签,将 <faceType=...,ext="base64"> 替换为 【表情: 中文名】
|
|
532
831
|
const parsedContent = parseFaceTags(event.content);
|
|
533
|
-
|
|
832
|
+
let userContent = voiceText
|
|
534
833
|
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
|
|
535
834
|
: parsedContent + attachmentInfo;
|
|
536
835
|
|
|
@@ -640,8 +939,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
640
939
|
}
|
|
641
940
|
|
|
642
941
|
// ============ 构建 contextInfo(静态/动态分离) ============
|
|
643
|
-
//
|
|
644
|
-
// -
|
|
942
|
+
// 设计原则:
|
|
943
|
+
// - 静态指引:每条消息不变的内容(场景锚定、投递地址、能力说明),
|
|
645
944
|
// 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
|
|
646
945
|
// 且保证长 session 窗口截断后仍可见。
|
|
647
946
|
// - 动态标签:每条消息变化的数据(时间、附件、ASR),
|
|
@@ -661,7 +960,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
661
960
|
systemPrompts.unshift(staticInstruction);
|
|
662
961
|
}
|
|
663
962
|
|
|
664
|
-
// ---
|
|
963
|
+
// --- 动态上下文 ---
|
|
665
964
|
const dynLines: string[] = [];
|
|
666
965
|
if (imageUrls.length > 0) {
|
|
667
966
|
dynLines.push(`- 图片: ${imageUrls.join(", ")}`);
|
|
@@ -672,14 +971,266 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
672
971
|
if (uniqueVoiceAsrReferTexts.length > 0) {
|
|
673
972
|
dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`);
|
|
674
973
|
}
|
|
675
|
-
const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n" : "";
|
|
974
|
+
const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n\n" : "";
|
|
975
|
+
|
|
976
|
+
// --- 命令授权(所有消息类型共用,群消息门控也需要) ---
|
|
977
|
+
// allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
|
|
978
|
+
const allowFromList = account.config?.allowFrom ?? [];
|
|
979
|
+
const allowAll = allowFromList.length === 0 || allowFromList.some((entry: string) => entry === "*");
|
|
980
|
+
const commandAuthorized = allowAll || allowFromList.some((entry: string) =>
|
|
981
|
+
entry.toUpperCase() === event.senderId.toUpperCase()
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
// --- 群消息上下文:插件只提供策略,框架自动组装 hint ---
|
|
985
|
+
let groupSystemPrompt = "";
|
|
986
|
+
let wasMentioned = false;
|
|
987
|
+
let groupSubject = "";
|
|
988
|
+
let senderLabel = "";
|
|
989
|
+
|
|
990
|
+
if (event.type === "group" && event.groupOpenid) {
|
|
991
|
+
// 1. 群策略检查(直接用 config 工具函数,与 Discord 的 allow-list.ts 同理)
|
|
992
|
+
if (!isGroupAllowed(cfg as any, event.groupOpenid, account.accountId)) {
|
|
993
|
+
log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid} not allowed by groupPolicy, skipping`);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// 2. @检测(委托 mentions 适配器)
|
|
998
|
+
const mentionPatternsForDetect: string[] = resolveMentionPatterns(cfg as any, route.agentId);
|
|
999
|
+
wasMentioned = detectWasMentioned({
|
|
1000
|
+
eventType: event.eventType,
|
|
1001
|
+
mentions: event.mentions,
|
|
1002
|
+
content: event.content,
|
|
1003
|
+
mentionPatterns: mentionPatternsForDetect,
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
// 3. requireMention 门控
|
|
1007
|
+
// 优先级:session store 中的 /activation 命令 > 配置文件 requireMention > 默认值
|
|
1008
|
+
// 未被 @ 时:消息仍写入上下文(让 bot 拥有完整对话记忆),但不触发 AI 回复
|
|
1009
|
+
const configRequireMention = qqbotPlugin.groups?.resolveRequireMention?.({
|
|
1010
|
+
cfg: cfg as any,
|
|
1011
|
+
accountId: account.accountId,
|
|
1012
|
+
groupId: event.groupOpenid,
|
|
1013
|
+
}) ?? true;
|
|
1014
|
+
|
|
1015
|
+
const activation = resolveGroupActivation({
|
|
1016
|
+
cfg: cfg as any,
|
|
1017
|
+
agentId: route.agentId,
|
|
1018
|
+
sessionKey: route.sessionKey,
|
|
1019
|
+
configRequireMention,
|
|
1020
|
+
});
|
|
1021
|
+
const requireMention = activation === "mention";
|
|
1022
|
+
|
|
1023
|
+
// 4. 隐式 mention:引用回复 bot 的消息视为隐式 mention
|
|
1024
|
+
const implicitMention = resolveImplicitMention({
|
|
1025
|
+
refMsgIdx: event.refMsgIdx,
|
|
1026
|
+
getRefEntry: getRefIndex,
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// 4.5 统一门控:ignoreOtherMentions → shouldBlock → mention 门控
|
|
1030
|
+
// 三层判断收敛到 resolveGroupMessageGate()
|
|
1031
|
+
const contentForCommand = event.content?.trim() ?? "";
|
|
1032
|
+
const allowTextCommands = shouldHandleTextCommands(cfg as Record<string, unknown>);
|
|
1033
|
+
const gate = resolveGroupMessageGate({
|
|
1034
|
+
ignoreOtherMentions: resolveIgnoreOtherMentions(cfg as any, event.groupOpenid, account.accountId),
|
|
1035
|
+
hasAnyMention: hasAnyMention({ mentions: event.mentions, content: event.content }),
|
|
1036
|
+
wasMentioned,
|
|
1037
|
+
implicitMention,
|
|
1038
|
+
allowTextCommands,
|
|
1039
|
+
isControlCommand: hasControlCommand(contentForCommand),
|
|
1040
|
+
commandAuthorized,
|
|
1041
|
+
requireMention,
|
|
1042
|
+
canDetectMention: true,
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
if (gate.action === "drop_other_mention") {
|
|
1046
|
+
// @了其他人但未 @bot:记录历史后丢弃
|
|
1047
|
+
const historyLimit = resolveHistoryLimit(cfg as any, event.groupOpenid, account.accountId);
|
|
1048
|
+
const senderForHistory = event.senderName
|
|
1049
|
+
? `${event.senderName} (${event.senderId})`
|
|
1050
|
+
: event.senderId;
|
|
1051
|
+
const historyAttachments = toAttachmentSummaries(event.attachments);
|
|
1052
|
+
recordPendingHistoryEntry({
|
|
1053
|
+
historyMap: groupHistories,
|
|
1054
|
+
historyKey: event.groupOpenid,
|
|
1055
|
+
limit: historyLimit,
|
|
1056
|
+
entry: {
|
|
1057
|
+
sender: senderForHistory,
|
|
1058
|
+
body: parseFaceTags(event.content),
|
|
1059
|
+
timestamp: new Date(event.timestamp).getTime(),
|
|
1060
|
+
messageId: event.messageId,
|
|
1061
|
+
attachments: historyAttachments,
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid}: drop message (ignoreOtherMentions=true, other user mentioned, bot not mentioned)`);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (gate.action === "block_unauthorized_command") {
|
|
1069
|
+
// 未授权控制命令:静默拦截,不交给 AI
|
|
1070
|
+
log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid}: blocked unauthorized control command from ${event.senderId}: ${contentForCommand.slice(0, 50)}`);
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (gate.action === "skip_no_mention") {
|
|
1075
|
+
// 非 @bot 消息:记录到群历史缓存后跳过 AI
|
|
1076
|
+
const historyLimit = resolveHistoryLimit(cfg as any, event.groupOpenid, account.accountId);
|
|
1077
|
+
const senderForHistory = event.senderName
|
|
1078
|
+
? `${event.senderName} (${event.senderId})`
|
|
1079
|
+
: event.senderId;
|
|
1080
|
+
const historyAttachments = toAttachmentSummaries(event.attachments);
|
|
1081
|
+
recordPendingHistoryEntry({
|
|
1082
|
+
historyMap: groupHistories,
|
|
1083
|
+
historyKey: event.groupOpenid,
|
|
1084
|
+
limit: historyLimit,
|
|
1085
|
+
entry: {
|
|
1086
|
+
sender: senderForHistory,
|
|
1087
|
+
body: parseFaceTags(event.content),
|
|
1088
|
+
timestamp: new Date(event.timestamp).getTime(),
|
|
1089
|
+
messageId: event.messageId,
|
|
1090
|
+
attachments: historyAttachments,
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
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}` : ""})`);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// gate.action === "pass" — 更新 wasMentioned 为 effectiveWasMentioned(含 implicit + bypass)
|
|
1098
|
+
wasMentioned = gate.effectiveWasMentioned;
|
|
1099
|
+
|
|
1100
|
+
// 5. 发送者标签
|
|
1101
|
+
senderLabel = event.senderName
|
|
1102
|
+
? `${event.senderName} (${event.senderId})`
|
|
1103
|
+
: event.senderId;
|
|
1104
|
+
|
|
1105
|
+
// 6. 群名称(从 config 中读取,fallback 为 openid 前 8 位)
|
|
1106
|
+
groupSubject = resolveGroupName(cfg as any, event.groupOpenid, account.accountId);
|
|
1107
|
+
|
|
1108
|
+
// 7. GroupSystemPrompt — 根据消息来源(机器人/人类)和 @状态 注入差异化 PE
|
|
1109
|
+
// 基础提示从 resolveGroupIntroHint 获取(群名称、平台限制等静态信息),
|
|
1110
|
+
// 然后根据运行时状态追加针对性行为指引。
|
|
1111
|
+
const baseHint = qqbotPlugin.groups?.resolveGroupIntroHint?.({
|
|
1112
|
+
cfg: cfg as any,
|
|
1113
|
+
accountId: account.accountId,
|
|
1114
|
+
groupId: event.groupOpenid,
|
|
1115
|
+
}) ?? "";
|
|
1116
|
+
|
|
1117
|
+
let behaviorPrompt = "";
|
|
1118
|
+
|
|
1119
|
+
// 从配置读取群行为 PE
|
|
1120
|
+
behaviorPrompt = resolveGroupPrompt(cfg as any, event.groupOpenid, account.accountId);
|
|
1121
|
+
|
|
1122
|
+
groupSystemPrompt = [baseHint, behaviorPrompt].filter(Boolean).join("\n");
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const mergedCount = (event as QueuedMessage)._mergedCount;
|
|
1126
|
+
|
|
1127
|
+
// 将 <@member_openid> 替换为 @username(使用 mentions 适配器)
|
|
1128
|
+
if (event.type === "group" && event.mentions?.length) {
|
|
1129
|
+
userContent = stripMentionText(userContent, event.mentions as any) ?? userContent;
|
|
1130
|
+
} else if (event.mentions?.length) {
|
|
1131
|
+
for (const m of event.mentions) {
|
|
1132
|
+
if (m.member_openid && m.username) {
|
|
1133
|
+
userContent = userContent.replace(new RegExp(`<@${m.member_openid}>`, "g"), `@${m.username}`);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// 群消息 user prompt 带上发送者昵称(合并消息已内嵌发送者前缀,不再重复添加)
|
|
1139
|
+
const isMergedMsg = mergedCount && mergedCount > 1;
|
|
1140
|
+
const senderPrefix = (event.type === "group" && !isMergedMsg)
|
|
1141
|
+
? `[${event.senderName ? `${event.senderName} (${event.senderId})` : event.senderId}] `
|
|
1142
|
+
: "";
|
|
1143
|
+
const isAtYouTag = event.type === "group"
|
|
1144
|
+
? (wasMentioned ? " (@你)" : "")
|
|
1145
|
+
: "";
|
|
1146
|
+
|
|
1147
|
+
// 合并消息:前面的消息用 envelope 历史格式,最后一条用当前消息格式(与 mention 单条回复对齐)
|
|
1148
|
+
// BodyForAgent 只包含动态上下文 + 用户消息,不拼入 systemPrompts。
|
|
1149
|
+
// systemPrompts([QQBot] to=...、TTS 能力声明等)通过 GroupSystemPrompt 注入到
|
|
1150
|
+
// 框架的 extraSystemPrompt 中,不会存入 transcript 的 user turn content,
|
|
1151
|
+
// 避免 Web UI 不显示用户 query 的问题。
|
|
1152
|
+
let userMessage: string;
|
|
1153
|
+
const mergedMessages = (event as QueuedMessage)._mergedMessages;
|
|
1154
|
+
if (isMergedMsg && mergedMessages?.length) {
|
|
1155
|
+
// --- 辅助:格式化单条子消息内容(表情解析 + mention 清理 + 附件标签) ---
|
|
1156
|
+
const formatSubMsgContent = (m: QueuedMessage): string =>
|
|
1157
|
+
formatMessageContent({
|
|
1158
|
+
content: m.content ?? "",
|
|
1159
|
+
chatType: m.type,
|
|
1160
|
+
mentions: m.mentions as unknown[],
|
|
1161
|
+
attachments: m.attachments,
|
|
1162
|
+
parseFaceTags,
|
|
1163
|
+
stripMentionText: (text, mentions) =>
|
|
1164
|
+
stripMentionText(text, mentions as any) ?? text,
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// 前面的消息使用 envelope 历史格式
|
|
1168
|
+
const preceding = mergedMessages.slice(0, -1);
|
|
1169
|
+
const lastMsg = mergedMessages[mergedMessages.length - 1];
|
|
1170
|
+
|
|
1171
|
+
const envelopeParts = preceding.map((m) => {
|
|
1172
|
+
const msgContent = formatSubMsgContent(m);
|
|
1173
|
+
const senderName = m.senderName
|
|
1174
|
+
? (m.senderName.includes(m.senderId) ? m.senderName : `${m.senderName} (${m.senderId})`)
|
|
1175
|
+
: m.senderId;
|
|
1176
|
+
return pluginRuntime.channel.reply.formatInboundEnvelope({
|
|
1177
|
+
channel: "qqbot",
|
|
1178
|
+
from: senderName,
|
|
1179
|
+
timestamp: new Date(m.timestamp).getTime(),
|
|
1180
|
+
body: msgContent,
|
|
1181
|
+
chatType: "group",
|
|
1182
|
+
envelope: envelopeOptions,
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
676
1185
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1186
|
+
// 最后一条消息使用简洁格式:[发送者]: 内容 (@你)
|
|
1187
|
+
const lastContent = formatSubMsgContent(lastMsg);
|
|
1188
|
+
const lastSenderName = lastMsg.senderName
|
|
1189
|
+
? (lastMsg.senderName.includes(lastMsg.senderId) ? lastMsg.senderName : `${lastMsg.senderName} (${lastMsg.senderId})`)
|
|
1190
|
+
: lastMsg.senderId;
|
|
1191
|
+
const lastPart = `[${lastSenderName}] ${lastContent}${isAtYouTag}`;
|
|
1192
|
+
|
|
1193
|
+
// 前置消息用段落标签包裹(类似引用消息的 [引用消息开始]...[引用消息结束])
|
|
1194
|
+
userMessage = buildMergedMessageContext({
|
|
1195
|
+
precedingParts: envelopeParts,
|
|
1196
|
+
currentMessage: lastPart,
|
|
1197
|
+
});
|
|
1198
|
+
} else {
|
|
1199
|
+
// 命令直接透传,不注入上下文
|
|
1200
|
+
userMessage = senderPrefix ? `${senderPrefix}${quotePart}${userContent}${isAtYouTag}` : `${quotePart}${userContent}`;
|
|
1201
|
+
}
|
|
1202
|
+
let agentBody = userContent.startsWith("/")
|
|
680
1203
|
? userContent
|
|
681
|
-
: `${
|
|
682
|
-
|
|
1204
|
+
: `${dynamicCtx}${userMessage}`;
|
|
1205
|
+
|
|
1206
|
+
// 被@时:将累积的非@历史消息注入上下文
|
|
1207
|
+
// 消息格式使用 formatInboundEnvelope 与正常消息保持一致
|
|
1208
|
+
if (event.type === "group" && event.groupOpenid) {
|
|
1209
|
+
const historyLimit = resolveHistoryLimit(cfg as any, event.groupOpenid, account.accountId);
|
|
1210
|
+
const envelopeOpts = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
1211
|
+
agentBody = buildPendingHistoryContext({
|
|
1212
|
+
historyMap: groupHistories,
|
|
1213
|
+
historyKey: event.groupOpenid,
|
|
1214
|
+
limit: historyLimit,
|
|
1215
|
+
currentMessage: agentBody,
|
|
1216
|
+
formatEntry: (entry) => {
|
|
1217
|
+
// 将附件描述追加到消息 body 末尾,确保富媒体上下文不丢失
|
|
1218
|
+
const attachmentDesc = formatAttachmentTags(entry.attachments);
|
|
1219
|
+
const bodyWithAttachments = attachmentDesc
|
|
1220
|
+
? `${entry.body} ${attachmentDesc}`
|
|
1221
|
+
: entry.body;
|
|
1222
|
+
return pluginRuntime.channel.reply.formatInboundEnvelope({
|
|
1223
|
+
channel: "qqbot",
|
|
1224
|
+
from: entry.sender,
|
|
1225
|
+
timestamp: entry.timestamp,
|
|
1226
|
+
body: bodyWithAttachments,
|
|
1227
|
+
chatType: "group",
|
|
1228
|
+
envelope: envelopeOpts,
|
|
1229
|
+
});
|
|
1230
|
+
},
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
683
1234
|
log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`);
|
|
684
1235
|
|
|
685
1236
|
const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
|
|
@@ -687,14 +1238,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
687
1238
|
: `qqbot:c2c:${event.senderId}`;
|
|
688
1239
|
const toAddress = fromAddress;
|
|
689
1240
|
|
|
690
|
-
// 计算命令授权状态
|
|
691
|
-
// allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
|
|
692
|
-
const allowFromList = account.config?.allowFrom ?? [];
|
|
693
|
-
const allowAll = allowFromList.length === 0 || allowFromList.some((entry: string) => entry === "*");
|
|
694
|
-
const commandAuthorized = allowAll || allowFromList.some((entry: string) =>
|
|
695
|
-
entry.toUpperCase() === event.senderId.toUpperCase()
|
|
696
|
-
);
|
|
697
|
-
|
|
698
1241
|
// 分离 imageUrls 为本地路径和远程 URL,供 openclaw 原生媒体处理
|
|
699
1242
|
const localMediaPaths: string[] = [];
|
|
700
1243
|
const localMediaTypes: string[] = [];
|
|
@@ -712,6 +1255,12 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
712
1255
|
}
|
|
713
1256
|
}
|
|
714
1257
|
|
|
1258
|
+
// QQBot 静态系统提示(投递地址、TTS 能力等)合并到 GroupSystemPrompt,
|
|
1259
|
+
// 通过框架的 extraSystemPrompt 机制注入 AI system prompt,
|
|
1260
|
+
// 不会存入 transcript 的 user turn content。
|
|
1261
|
+
const qqbotSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : "";
|
|
1262
|
+
const mergedGroupSystemPrompt = [qqbotSystemInstruction, groupSystemPrompt].filter(Boolean).join("\n") || undefined;
|
|
1263
|
+
|
|
715
1264
|
const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
|
|
716
1265
|
Body: body,
|
|
717
1266
|
BodyForAgent: agentBody,
|
|
@@ -722,6 +1271,11 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
722
1271
|
SessionKey: route.sessionKey,
|
|
723
1272
|
AccountId: route.accountId,
|
|
724
1273
|
ChatType: isGroupChat ? "group" : "direct",
|
|
1274
|
+
GroupSystemPrompt: mergedGroupSystemPrompt,
|
|
1275
|
+
// 群消息元数据(框架级字段)
|
|
1276
|
+
WasMentioned: isGroupChat ? wasMentioned : undefined,
|
|
1277
|
+
SenderLabel: isGroupChat ? senderLabel : undefined,
|
|
1278
|
+
GroupSubject: isGroupChat ? groupSubject : undefined,
|
|
725
1279
|
SenderId: event.senderId,
|
|
726
1280
|
SenderName: event.senderName,
|
|
727
1281
|
Provider: "qqbot",
|
|
@@ -751,7 +1305,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
751
1305
|
MediaUrls: remoteMediaUrls,
|
|
752
1306
|
MediaUrl: remoteMediaUrls[0],
|
|
753
1307
|
} : {}),
|
|
754
|
-
//
|
|
1308
|
+
// 引用消息上下文
|
|
755
1309
|
...(replyToId ? {
|
|
756
1310
|
ReplyToId: replyToId,
|
|
757
1311
|
ReplyToBody: replyToBody,
|
|
@@ -779,7 +1333,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
779
1333
|
|
|
780
1334
|
// 使用 AsyncLocalStorage 建立请求级上下文,作用域内所有异步代码
|
|
781
1335
|
// (包括 AI agent 调用、tool execute)都能安全获取当前会话信息,无并发竞态。
|
|
782
|
-
await runWithRequestContext({ target: qualifiedTarget }, async () => {
|
|
1336
|
+
await runWithRequestContext({ target: qualifiedTarget, accountId: account.accountId }, async () => {
|
|
783
1337
|
try {
|
|
784
1338
|
const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
|
785
1339
|
|
|
@@ -1020,6 +1574,16 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1020
1574
|
log?.error(`[qqbot:${account.accountId}] Streaming deliver error: ${err}`);
|
|
1021
1575
|
}
|
|
1022
1576
|
|
|
1577
|
+
let replyText = payload.text ?? "";
|
|
1578
|
+
|
|
1579
|
+
// 群消息:模型回复 NO_REPLY 表示无需回复,跳过发送
|
|
1580
|
+
// 注意:核心框架的 reply-delivery 已会拦截 NO_REPLY,此处为双重保险
|
|
1581
|
+
const trimmedReply = replyText.trim();
|
|
1582
|
+
if (event.type === "group" && (trimmedReply === "NO_REPLY" || trimmedReply === "[SKIP]")) {
|
|
1583
|
+
log?.info(`[qqbot:${account.accountId}] Model decided to skip group message (token=${trimmedReply}) from ${event.senderId}: ${event.content?.slice(0, 50)}`);
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1023
1587
|
// 检查是否因流式 API 不可用而需要降级(ensureStreamingStarted 全部失败)
|
|
1024
1588
|
// 如果需要降级,不 return,让本次 deliver 的 payload.text(全量文本)继续走普通发送逻辑
|
|
1025
1589
|
if (streamingController.shouldFallbackToStatic) {
|
|
@@ -1228,6 +1792,16 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1228
1792
|
if (streamingController?.shouldFallbackToStatic) {
|
|
1229
1793
|
log?.debug?.(`[qqbot:${account.accountId}] Streaming was degraded to static mode (no chunk sent successfully)`);
|
|
1230
1794
|
}
|
|
1795
|
+
|
|
1796
|
+
// 回复完成后清空群历史缓存(每次回复后重新累积)
|
|
1797
|
+
if (event.type === "group" && event.groupOpenid) {
|
|
1798
|
+
const historyLimit = resolveHistoryLimit(cfg as any, event.groupOpenid, account.accountId);
|
|
1799
|
+
clearPendingHistory({
|
|
1800
|
+
historyMap: groupHistories,
|
|
1801
|
+
historyKey: event.groupOpenid,
|
|
1802
|
+
limit: historyLimit,
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1231
1805
|
}
|
|
1232
1806
|
} catch (err) {
|
|
1233
1807
|
const errStr = String(err);
|
|
@@ -1435,10 +2009,11 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1435
2009
|
});
|
|
1436
2010
|
} else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
|
1437
2011
|
const event = d as GroupMessageEvent;
|
|
1438
|
-
//
|
|
2012
|
+
// 被 @ 的消息,直接入队回复
|
|
1439
2013
|
recordKnownUser({
|
|
1440
2014
|
openid: event.author.member_openid,
|
|
1441
2015
|
type: "group",
|
|
2016
|
+
nickname: event.author.username,
|
|
1442
2017
|
groupOpenid: event.group_openid,
|
|
1443
2018
|
accountId: account.accountId,
|
|
1444
2019
|
});
|
|
@@ -1446,6 +2021,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1446
2021
|
trySlashCommandOrEnqueue({
|
|
1447
2022
|
type: "group",
|
|
1448
2023
|
senderId: event.author.member_openid,
|
|
2024
|
+
senderName: event.author.username,
|
|
1449
2025
|
content: event.content,
|
|
1450
2026
|
messageId: event.id,
|
|
1451
2027
|
timestamp: event.timestamp,
|
|
@@ -1453,6 +2029,63 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1453
2029
|
attachments: event.attachments,
|
|
1454
2030
|
refMsgIdx: groupRefs.refMsgIdx,
|
|
1455
2031
|
msgIdx: groupRefs.msgIdx,
|
|
2032
|
+
eventType: "GROUP_AT_MESSAGE_CREATE",
|
|
2033
|
+
mentions: event.mentions,
|
|
2034
|
+
messageScene: event.message_scene,
|
|
2035
|
+
});
|
|
2036
|
+
} else if (t === "GROUP_MESSAGE_CREATE") {
|
|
2037
|
+
const event = d as GroupMessageEvent;
|
|
2038
|
+
recordKnownUser({
|
|
2039
|
+
openid: event.author.member_openid,
|
|
2040
|
+
type: "group",
|
|
2041
|
+
nickname: event.author.username,
|
|
2042
|
+
groupOpenid: event.group_openid,
|
|
2043
|
+
accountId: account.accountId,
|
|
2044
|
+
});
|
|
2045
|
+
const groupRefs = parseRefIndices(event.message_scene?.ext);
|
|
2046
|
+
trySlashCommandOrEnqueue({
|
|
2047
|
+
type: "group",
|
|
2048
|
+
senderId: event.author.member_openid,
|
|
2049
|
+
senderName: event.author.username,
|
|
2050
|
+
senderIsBot: event.author.bot,
|
|
2051
|
+
content: event.content,
|
|
2052
|
+
messageId: event.id,
|
|
2053
|
+
timestamp: event.timestamp,
|
|
2054
|
+
groupOpenid: event.group_openid,
|
|
2055
|
+
attachments: event.attachments,
|
|
2056
|
+
refMsgIdx: groupRefs.refMsgIdx,
|
|
2057
|
+
msgIdx: groupRefs.msgIdx,
|
|
2058
|
+
eventType: "GROUP_MESSAGE_CREATE",
|
|
2059
|
+
mentions: event.mentions,
|
|
2060
|
+
messageScene: event.message_scene,
|
|
2061
|
+
});
|
|
2062
|
+
} else if (t === "GROUP_ADD_ROBOT") {
|
|
2063
|
+
const event = d as { timestamp: string; group_openid: string; op_member_openid: string };
|
|
2064
|
+
log?.info(`[qqbot:${account.accountId}] Bot added to group: ${event.group_openid} by ${event.op_member_openid}`);
|
|
2065
|
+
recordKnownUser({
|
|
2066
|
+
openid: event.op_member_openid,
|
|
2067
|
+
type: "group",
|
|
2068
|
+
groupOpenid: event.group_openid,
|
|
2069
|
+
accountId: account.accountId,
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
} else if (t === "GROUP_DEL_ROBOT") {
|
|
2073
|
+
const event = d as { timestamp: string; group_openid: string; op_member_openid: string };
|
|
2074
|
+
log?.info(`[qqbot:${account.accountId}] Bot removed from group: ${event.group_openid} by ${event.op_member_openid}`);
|
|
2075
|
+
} else if (t === "GROUP_MSG_REJECT") {
|
|
2076
|
+
const event = d as { timestamp: number; group_openid: string; op_member_openid: string };
|
|
2077
|
+
log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} rejected bot proactive messages (by ${event.op_member_openid})`);
|
|
2078
|
+
} else if (t === "GROUP_MSG_RECEIVE") {
|
|
2079
|
+
const event = d as { timestamp: number; group_openid: string; op_member_openid: string };
|
|
2080
|
+
log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} accepted bot proactive messages (by ${event.op_member_openid})`);
|
|
2081
|
+
} else if (t === "INTERACTION_CREATE") {
|
|
2082
|
+
const event = d as InteractionEvent;
|
|
2083
|
+
const resolved = event.data?.resolved;
|
|
2084
|
+
const sceneDesc = event.scene ?? (event.chat_type === 0 ? "guild" : event.chat_type === 1 ? "group" : "c2c");
|
|
2085
|
+
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"}`);
|
|
2086
|
+
|
|
2087
|
+
handleInteractionCreate({ event, account, cfg, log }).catch((err) => {
|
|
2088
|
+
log?.error(`[qqbot:${account.accountId}] Failed to handle interaction ${event.id}: ${err}`);
|
|
1456
2089
|
});
|
|
1457
2090
|
}
|
|
1458
2091
|
break;
|
|
@@ -1493,7 +2126,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1493
2126
|
log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
|
|
1494
2127
|
isConnecting = false; // 释放锁
|
|
1495
2128
|
|
|
1496
|
-
//
|
|
2129
|
+
// 根据错误码处理(见 QQ 官方文档)
|
|
1497
2130
|
// 4004: CODE_INVALID_TOKEN - Token 无效,需刷新 token 重新连接
|
|
1498
2131
|
// 4006: CODE_SESSION_NO_LONGER_VALID - 会话失效,需重新 identify
|
|
1499
2132
|
// 4007: CODE_INVALID_SEQ - Resume 时 seq 无效,需重新 identify
|