@cxyhhhhh/openclaw-qqbot 1.6.7-alpha.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.
Files changed (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +470 -0
  3. package/README.zh.md +465 -0
  4. package/bin/qqbot-cli.js +243 -0
  5. package/dist/index.d.ts +17 -0
  6. package/dist/index.js +26 -0
  7. package/dist/src/admin-resolver.d.ts +33 -0
  8. package/dist/src/admin-resolver.js +157 -0
  9. package/dist/src/api.d.ts +264 -0
  10. package/dist/src/api.js +777 -0
  11. package/dist/src/channel.d.ts +29 -0
  12. package/dist/src/channel.js +452 -0
  13. package/dist/src/config.d.ts +56 -0
  14. package/dist/src/config.js +278 -0
  15. package/dist/src/credential-backup.d.ts +31 -0
  16. package/dist/src/credential-backup.js +66 -0
  17. package/dist/src/deliver-debounce.d.ts +74 -0
  18. package/dist/src/deliver-debounce.js +174 -0
  19. package/dist/src/gateway.d.ts +18 -0
  20. package/dist/src/gateway.js +2021 -0
  21. package/dist/src/group-history.d.ts +136 -0
  22. package/dist/src/group-history.js +226 -0
  23. package/dist/src/image-server.d.ts +87 -0
  24. package/dist/src/image-server.js +570 -0
  25. package/dist/src/inbound-attachments.d.ts +60 -0
  26. package/dist/src/inbound-attachments.js +248 -0
  27. package/dist/src/known-users.d.ts +100 -0
  28. package/dist/src/known-users.js +263 -0
  29. package/dist/src/message-gating.d.ts +53 -0
  30. package/dist/src/message-gating.js +107 -0
  31. package/dist/src/message-queue.d.ts +86 -0
  32. package/dist/src/message-queue.js +257 -0
  33. package/dist/src/onboarding.d.ts +10 -0
  34. package/dist/src/onboarding.js +203 -0
  35. package/dist/src/outbound-deliver.d.ts +48 -0
  36. package/dist/src/outbound-deliver.js +392 -0
  37. package/dist/src/outbound.d.ts +205 -0
  38. package/dist/src/outbound.js +926 -0
  39. package/dist/src/proactive.d.ts +170 -0
  40. package/dist/src/proactive.js +399 -0
  41. package/dist/src/ref-index-store.d.ts +70 -0
  42. package/dist/src/ref-index-store.js +250 -0
  43. package/dist/src/reply-dispatcher.d.ts +35 -0
  44. package/dist/src/reply-dispatcher.js +311 -0
  45. package/dist/src/request-context.d.ts +18 -0
  46. package/dist/src/request-context.js +30 -0
  47. package/dist/src/runtime.d.ts +3 -0
  48. package/dist/src/runtime.js +10 -0
  49. package/dist/src/session-store.d.ts +52 -0
  50. package/dist/src/session-store.js +254 -0
  51. package/dist/src/slash-commands.d.ts +77 -0
  52. package/dist/src/slash-commands.js +1461 -0
  53. package/dist/src/startup-greeting.d.ts +30 -0
  54. package/dist/src/startup-greeting.js +97 -0
  55. package/dist/src/streaming.d.ts +250 -0
  56. package/dist/src/streaming.js +914 -0
  57. package/dist/src/stt.d.ts +21 -0
  58. package/dist/src/stt.js +70 -0
  59. package/dist/src/tools/channel.d.ts +16 -0
  60. package/dist/src/tools/channel.js +234 -0
  61. package/dist/src/tools/remind.d.ts +2 -0
  62. package/dist/src/tools/remind.js +248 -0
  63. package/dist/src/types.d.ts +364 -0
  64. package/dist/src/types.js +17 -0
  65. package/dist/src/typing-keepalive.d.ts +27 -0
  66. package/dist/src/typing-keepalive.js +64 -0
  67. package/dist/src/update-checker.d.ts +34 -0
  68. package/dist/src/update-checker.js +160 -0
  69. package/dist/src/utils/audio-convert.d.ts +98 -0
  70. package/dist/src/utils/audio-convert.js +755 -0
  71. package/dist/src/utils/chunked-upload.d.ts +59 -0
  72. package/dist/src/utils/chunked-upload.js +289 -0
  73. package/dist/src/utils/file-utils.d.ts +61 -0
  74. package/dist/src/utils/file-utils.js +172 -0
  75. package/dist/src/utils/image-size.d.ts +51 -0
  76. package/dist/src/utils/image-size.js +234 -0
  77. package/dist/src/utils/media-send.d.ts +148 -0
  78. package/dist/src/utils/media-send.js +456 -0
  79. package/dist/src/utils/media-tags.d.ts +14 -0
  80. package/dist/src/utils/media-tags.js +164 -0
  81. package/dist/src/utils/payload.d.ts +112 -0
  82. package/dist/src/utils/payload.js +186 -0
  83. package/dist/src/utils/pkg-version.d.ts +5 -0
  84. package/dist/src/utils/pkg-version.js +51 -0
  85. package/dist/src/utils/platform.d.ts +137 -0
  86. package/dist/src/utils/platform.js +390 -0
  87. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  88. package/dist/src/utils/ssrf-guard.js +91 -0
  89. package/dist/src/utils/text-parsing.d.ts +32 -0
  90. package/dist/src/utils/text-parsing.js +69 -0
  91. package/dist/src/utils/upload-cache.d.ts +34 -0
  92. package/dist/src/utils/upload-cache.js +93 -0
  93. package/index.ts +31 -0
  94. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  95. package/node_modules/@eshaz/web-worker/README.md +134 -0
  96. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  97. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  98. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  99. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  100. package/node_modules/@eshaz/web-worker/node.js +223 -0
  101. package/node_modules/@eshaz/web-worker/package.json +54 -0
  102. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  103. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  104. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  105. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  106. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  107. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  108. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  109. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  110. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  111. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  112. package/node_modules/mpg123-decoder/README.md +265 -0
  113. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  114. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  115. package/node_modules/mpg123-decoder/index.js +8 -0
  116. package/node_modules/mpg123-decoder/package.json +58 -0
  117. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  118. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  119. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  120. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  121. package/node_modules/silk-wasm/LICENSE +21 -0
  122. package/node_modules/silk-wasm/README.md +85 -0
  123. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  124. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  125. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  126. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  127. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  128. package/node_modules/silk-wasm/package.json +39 -0
  129. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  130. package/node_modules/simple-yenc/.prettierignore +1 -0
  131. package/node_modules/simple-yenc/LICENSE +7 -0
  132. package/node_modules/simple-yenc/README.md +163 -0
  133. package/node_modules/simple-yenc/dist/esm.js +1 -0
  134. package/node_modules/simple-yenc/dist/index.js +1 -0
  135. package/node_modules/simple-yenc/package.json +50 -0
  136. package/node_modules/simple-yenc/rollup.config.js +27 -0
  137. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  138. package/node_modules/ws/LICENSE +20 -0
  139. package/node_modules/ws/README.md +548 -0
  140. package/node_modules/ws/browser.js +8 -0
  141. package/node_modules/ws/index.js +13 -0
  142. package/node_modules/ws/lib/buffer-util.js +131 -0
  143. package/node_modules/ws/lib/constants.js +19 -0
  144. package/node_modules/ws/lib/event-target.js +292 -0
  145. package/node_modules/ws/lib/extension.js +203 -0
  146. package/node_modules/ws/lib/limiter.js +55 -0
  147. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  148. package/node_modules/ws/lib/receiver.js +706 -0
  149. package/node_modules/ws/lib/sender.js +602 -0
  150. package/node_modules/ws/lib/stream.js +161 -0
  151. package/node_modules/ws/lib/subprotocol.js +62 -0
  152. package/node_modules/ws/lib/validation.js +152 -0
  153. package/node_modules/ws/lib/websocket-server.js +554 -0
  154. package/node_modules/ws/lib/websocket.js +1393 -0
  155. package/node_modules/ws/package.json +69 -0
  156. package/node_modules/ws/wrapper.mjs +8 -0
  157. package/openclaw.plugin.json +17 -0
  158. package/package.json +67 -0
  159. package/preload.cjs +33 -0
  160. package/scripts/cleanup-legacy-plugins.sh +124 -0
  161. package/scripts/link-sdk-core.cjs +185 -0
  162. package/scripts/postinstall-link-sdk.js +113 -0
  163. package/scripts/proactive-api-server.ts +369 -0
  164. package/scripts/send-proactive.ts +293 -0
  165. package/scripts/set-markdown.sh +156 -0
  166. package/scripts/test-sendmedia.ts +116 -0
  167. package/scripts/upgrade-via-npm.ps1 +451 -0
  168. package/scripts/upgrade-via-npm.sh +528 -0
  169. package/scripts/upgrade-via-source.sh +916 -0
  170. package/skills/qqbot-channel/SKILL.md +263 -0
  171. package/skills/qqbot-channel/references/api_references.md +521 -0
  172. package/skills/qqbot-media/SKILL.md +60 -0
  173. package/skills/qqbot-remind/SKILL.md +149 -0
  174. package/src/admin-resolver.ts +181 -0
  175. package/src/api.ts +1138 -0
  176. package/src/channel.ts +477 -0
  177. package/src/config.ts +347 -0
  178. package/src/credential-backup.ts +72 -0
  179. package/src/deliver-debounce.ts +229 -0
  180. package/src/gateway.ts +2257 -0
  181. package/src/group-history.ts +328 -0
  182. package/src/image-server.ts +675 -0
  183. package/src/inbound-attachments.ts +321 -0
  184. package/src/known-users.ts +353 -0
  185. package/src/message-gating.ts +190 -0
  186. package/src/message-queue.ts +349 -0
  187. package/src/onboarding.ts +274 -0
  188. package/src/openclaw-plugin-sdk.d.ts +587 -0
  189. package/src/outbound-deliver.ts +473 -0
  190. package/src/outbound.ts +1119 -0
  191. package/src/proactive.ts +530 -0
  192. package/src/ref-index-store.ts +335 -0
  193. package/src/reply-dispatcher.ts +334 -0
  194. package/src/request-context.ts +39 -0
  195. package/src/runtime.ts +14 -0
  196. package/src/session-store.ts +303 -0
  197. package/src/slash-commands.ts +1615 -0
  198. package/src/startup-greeting.ts +120 -0
  199. package/src/streaming.ts +1102 -0
  200. package/src/stt.ts +86 -0
  201. package/src/tools/channel.ts +281 -0
  202. package/src/tools/remind.ts +300 -0
  203. package/src/types.ts +386 -0
  204. package/src/typing-keepalive.ts +59 -0
  205. package/src/update-checker.ts +174 -0
  206. package/src/utils/audio-convert.ts +859 -0
  207. package/src/utils/chunked-upload.ts +419 -0
  208. package/src/utils/file-utils.ts +193 -0
  209. package/src/utils/image-size.ts +266 -0
  210. package/src/utils/media-send.ts +585 -0
  211. package/src/utils/media-tags.ts +182 -0
  212. package/src/utils/payload.ts +265 -0
  213. package/src/utils/pkg-version.ts +54 -0
  214. package/src/utils/platform.ts +435 -0
  215. package/src/utils/ssrf-guard.ts +102 -0
  216. package/src/utils/text-parsing.ts +75 -0
  217. package/src/utils/upload-cache.ts +128 -0
  218. package/tsconfig.json +16 -0
package/src/channel.ts ADDED
@@ -0,0 +1,477 @@
1
+ import {
2
+ type ChannelPlugin,
3
+ type OpenClawConfig,
4
+ applyAccountNameToChannelSection,
5
+ deleteAccountFromConfigSection,
6
+ setAccountEnabledInConfigSection,
7
+ } from "openclaw/plugin-sdk/core";
8
+
9
+ import type { ResolvedQQBotAccount } from "./types.js";
10
+ import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId, resolveRequireMention, resolveToolPolicy, resolveGroupConfig } from "./config.js";
11
+ import { sendText, sendMedia } from "./outbound.js";
12
+ import { startGateway } from "./gateway.js";
13
+ import { qqbotOnboardingAdapter } from "./onboarding.js";
14
+ import { getQQBotRuntime } from "./runtime.js";
15
+ import { saveCredentialBackup, loadCredentialBackup } from "./credential-backup.js";
16
+ import { initApiConfig } from "./api.js";
17
+
18
+ /** QQ Bot 单条消息文本长度上限 */
19
+ export const TEXT_CHUNK_LIMIT = 5000;
20
+
21
+ /**
22
+ * Markdown 感知的文本分块函数
23
+ * 委托给 SDK 内置的 channel.text.chunkMarkdownText
24
+ * 支持代码块自动关闭/重开、括号感知等
25
+ */
26
+ export function chunkText(text: string, limit: number): string[] {
27
+ const runtime = getQQBotRuntime();
28
+ return runtime.channel.text.chunkMarkdownText(text, limit);
29
+ }
30
+
31
+ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
32
+ id: "qqbot",
33
+ meta: {
34
+ id: "qqbot",
35
+ label: "QQ Bot",
36
+ selectionLabel: "QQ Bot",
37
+ docsPath: "/docs/channels/qqbot",
38
+ blurb: "Connect to QQ via official QQ Bot API",
39
+ order: 50,
40
+ },
41
+ capabilities: {
42
+ chatTypes: ["direct", "group"],
43
+ media: true,
44
+ reactions: false,
45
+ threads: false,
46
+ /**
47
+ * blockStreaming: true 表示该 Channel 支持块流式
48
+ * 框架会收集流式响应,然后通过 deliver 回调发送
49
+ */
50
+ blockStreaming: true,
51
+ },
52
+ reload: { configPrefixes: ["channels.qqbot"] },
53
+
54
+ // ============ 群消息策略适配器 ============
55
+ groups: {
56
+ /** 是否需要 @机器人才响应 */
57
+ resolveRequireMention: ({ cfg, accountId, groupId }) => {
58
+ if (!groupId) return undefined;
59
+ return resolveRequireMention(cfg, groupId, accountId ?? undefined);
60
+ },
61
+
62
+ /** 群聊工具范围 */
63
+ resolveToolPolicy: ({ cfg, accountId, groupId }) => {
64
+ if (!groupId) return undefined;
65
+ const policy = resolveToolPolicy(cfg, groupId, accountId ?? undefined);
66
+ // 将简单字符串策略映射为 GroupToolPolicyConfig 对象
67
+ if (policy === "full") return undefined; // full = 默认不限制
68
+ if (policy === "none") return { allow: [], deny: ["*"] };
69
+ // restricted: 默认空 allow(框架会使用内置 restricted 列表)
70
+ return { allow: [] };
71
+ },
72
+
73
+ /** QQ Bot 平台特有的群聊行为提示 */
74
+ resolveGroupIntroHint: ({ cfg, accountId, groupId }) => {
75
+ if (!groupId) return undefined;
76
+ const groupCfg = resolveGroupConfig(cfg, groupId, accountId ?? undefined);
77
+ const hints: string[] = [];
78
+ if (groupCfg.name) {
79
+ hints.push(`当前群: ${groupCfg.name}`);
80
+ }
81
+ // bot 互聊防护、@状态行为指引在 gateway.ts 动态注入
82
+ return hints.join(" ") || undefined;
83
+ },
84
+ },
85
+
86
+ // ============ @mention 检测与清理 ============
87
+ mentions: {
88
+ /** 清理 @mention 文本(SDK ChannelMentionAdapter 接口) */
89
+ stripMentions: ({ text, ctx }) => {
90
+ const mentions = (ctx as any)?.mentions as Array<{ member_openid?: string; id?: string; user_openid?: string; is_you?: boolean; nickname?: string; username?: string }> | undefined;
91
+ return stripMentionText(text, mentions);
92
+ },
93
+ },
94
+ // CLI onboarding wizard
95
+ // @ts-ignore onboarding removed from ChannelPlugin type in 2026.3.23 but still supported at runtime
96
+ onboarding: qqbotOnboardingAdapter,
97
+
98
+ config: {
99
+ listAccountIds: (cfg) => listQQBotAccountIds(cfg),
100
+ resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
101
+ defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg),
102
+ // 新增:设置账户启用状态
103
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
104
+ setAccountEnabledInConfigSection({
105
+ cfg,
106
+ sectionKey: "qqbot",
107
+ accountId,
108
+ enabled,
109
+ allowTopLevel: true,
110
+ }),
111
+ // 新增:删除账户
112
+ deleteAccount: ({ cfg, accountId }) =>
113
+ deleteAccountFromConfigSection({
114
+ cfg,
115
+ sectionKey: "qqbot",
116
+ accountId,
117
+ clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
118
+ }),
119
+ isConfigured: (account) => {
120
+ if (account?.appId && account?.clientSecret) return true;
121
+ // 配置为空但有凭证备份时仍返回 true,让 startAccount 有机会恢复凭证
122
+ const backup = loadCredentialBackup(account?.accountId);
123
+ return backup !== null;
124
+ },
125
+ describeAccount: (account) => ({
126
+ accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
127
+ name: account?.name,
128
+ enabled: account?.enabled ?? false,
129
+ configured: Boolean(account?.appId && account?.clientSecret),
130
+ tokenSource: account?.secretSource,
131
+ }),
132
+ // 关键:解析 allowFrom 配置,用于命令授权
133
+ resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => {
134
+ const account = resolveQQBotAccount(cfg, accountId ?? undefined);
135
+ const allowFrom = account.config?.allowFrom ?? [];
136
+ console.log(`[qqbot] resolveAllowFrom: accountId=${accountId}, allowFrom=${JSON.stringify(allowFrom)}`);
137
+ return allowFrom.map((entry: string | number) => String(entry)) as (string | number)[];
138
+ },
139
+ // 格式化 allowFrom 条目(移除 qqbot: 前缀,统一大写)
140
+ formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
141
+ allowFrom
142
+ .map((entry: string | number) => String(entry).trim())
143
+ .filter(Boolean)
144
+ .map((entry: string) => entry.replace(/^qqbot:/i, ""))
145
+ .map((entry: string) => entry.toUpperCase()), // QQ openid 是大写的
146
+ },
147
+ setup: {
148
+ // 新增:规范化账户 ID
149
+ resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
150
+ // 新增:应用账户名称
151
+ applyAccountName: ({ cfg, accountId, name }) =>
152
+ applyAccountNameToChannelSection({
153
+ cfg,
154
+ channelKey: "qqbot",
155
+ accountId,
156
+ name,
157
+ }),
158
+ validateInput: ({ input }) => {
159
+ if (!input.token && !input.tokenFile && !input.useEnv) {
160
+ return "QQBot requires --token (format: appId:clientSecret) or --use-env";
161
+ }
162
+ return null;
163
+ },
164
+ applyAccountConfig: ({ cfg, accountId, input }) => {
165
+ let appId = "";
166
+ let clientSecret = "";
167
+
168
+ if (input.token) {
169
+ const parts = input.token.split(":");
170
+ if (parts.length === 2) {
171
+ appId = parts[0];
172
+ clientSecret = parts[1];
173
+ }
174
+ }
175
+
176
+ return applyQQBotAccountConfig(cfg, accountId, {
177
+ appId,
178
+ clientSecret,
179
+ clientSecretFile: input.tokenFile,
180
+ name: input.name,
181
+ imageServerBaseUrl: (input as Record<string, unknown>).imageServerBaseUrl as string | undefined,
182
+ }) as OpenClawConfig;
183
+ },
184
+ },
185
+ // Messaging 配置:用于解析目标地址
186
+ messaging: {
187
+ /**
188
+ * 规范化目标地址
189
+ * 支持以下格式:
190
+ * - qqbot:c2c:openid -> 私聊
191
+ * - qqbot:group:groupid -> 群聊
192
+ * - qqbot:channel:channelid -> 频道
193
+ * - c2c:openid -> 私聊
194
+ * - group:groupid -> 群聊
195
+ * - channel:channelid -> 频道
196
+ * - 纯 openid(32位十六进制)-> 私聊
197
+ */
198
+ normalizeTarget: (target: string): string | undefined => {
199
+ // 去掉 qqbot: 前缀(如果有)
200
+ const id = target.replace(/^qqbot:/i, "");
201
+
202
+ // 检查是否是已知格式
203
+ if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) {
204
+ return `qqbot:${id}`;
205
+ }
206
+
207
+ // 检查是否是纯 openid(32位十六进制,不带连字符)
208
+ // QQ Bot OpenID 格式类似: 207A5B8339D01F6582911C014668B77B
209
+ const openIdHexPattern = /^[0-9a-fA-F]{32}$/;
210
+ if (openIdHexPattern.test(id)) {
211
+ return `qqbot:c2c:${id}`;
212
+ }
213
+
214
+ // 检查是否是 UUID 格式的 openid(带连字符)
215
+ const openIdUuidPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
216
+ if (openIdUuidPattern.test(id)) {
217
+ return `qqbot:c2c:${id}`;
218
+ }
219
+
220
+ // 不认识的格式,返回 undefined 让核心使用原始值
221
+ return undefined;
222
+ },
223
+ /**
224
+ * 目标解析器配置
225
+ * 用于判断一个目标 ID 是否看起来像 QQ Bot 的格式
226
+ */
227
+ targetResolver: {
228
+ /**
229
+ * 判断目标 ID 是否可能是 QQ Bot 格式
230
+ * 支持以下格式:
231
+ * - qqbot:c2c:xxx
232
+ * - qqbot:group:xxx
233
+ * - qqbot:channel:xxx
234
+ * - c2c:xxx
235
+ * - group:xxx
236
+ * - channel:xxx
237
+ * - UUID 格式的 openid
238
+ */
239
+ looksLikeId: (id: string): boolean => {
240
+ // 带 qqbot: 前缀的格式
241
+ if (/^qqbot:(c2c|group|channel):/i.test(id)) {
242
+ return true;
243
+ }
244
+ // 不带前缀但有类型标识
245
+ if (/^(c2c|group|channel):/i.test(id)) {
246
+ return true;
247
+ }
248
+ // 32位十六进制 openid(不带连字符)
249
+ if (/^[0-9a-fA-F]{32}$/.test(id)) {
250
+ return true;
251
+ }
252
+ // UUID 格式的 openid(带连字符)
253
+ const openIdPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
254
+ return openIdPattern.test(id);
255
+ },
256
+ hint: "QQ Bot 目标格式: qqbot:c2c:openid (私聊) 或 qqbot:group:groupid (群聊)",
257
+ },
258
+ },
259
+ outbound: {
260
+ deliveryMode: "direct",
261
+ chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
262
+ chunkerMode: "markdown",
263
+ textChunkLimit: 5000,
264
+ sendText: async ({ to, text, accountId, replyToId, cfg }) => {
265
+ console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
266
+ console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
267
+ const account = resolveQQBotAccount(cfg, accountId ?? undefined);
268
+ initApiConfig({ markdownSupport: account.markdownSupport });
269
+ console.log(`[qqbot:channel] sendText resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
270
+ const result = await sendText({ to, text, accountId, replyToId, account });
271
+ console.log(`[qqbot:channel] sendText result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
272
+ if (result.error) throw new Error(result.error);
273
+ return {
274
+ channel: "qqbot" as const,
275
+ messageId: result.messageId ?? "",
276
+ };
277
+ },
278
+ sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
279
+ console.log(`[qqbot:channel] sendMedia called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, mediaUrl=${mediaUrl?.slice(0, 80)}, text.length=${text?.length ?? 0}`);
280
+ const account = resolveQQBotAccount(cfg, accountId ?? undefined);
281
+ initApiConfig({ markdownSupport: account.markdownSupport });
282
+ console.log(`[qqbot:channel] sendMedia resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
283
+ const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
284
+ console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
285
+ // 此 sendMedia 是框架 Channel Plugin 的标准出站接口,
286
+ // 用于非 gateway deliver 场景(如 API 直接发送、cron 等)。
287
+ // gateway 消息响应走的是 deliver 回调 → sendPlainReply,不经过此处。
288
+ // 框架拿到 error 后不一定会给用户发文字兜底,所以这里主动发一条。
289
+ if (result.error) {
290
+ try {
291
+ const fallbackResult = await sendText({ to, text: result.error, accountId, replyToId, account });
292
+ console.log(`[qqbot:channel] sendMedia fallback text sent: messageId=${fallbackResult.messageId}, error=${fallbackResult.error ?? "none"}`);
293
+ } catch (fallbackErr) {
294
+ console.error(`[qqbot:channel] sendMedia fallback text failed: ${fallbackErr}`);
295
+ }
296
+ throw new Error(result.error);
297
+ }
298
+ return {
299
+ channel: "qqbot" as const,
300
+ messageId: result.messageId ?? "",
301
+ };
302
+ },
303
+ },
304
+ gateway: {
305
+ startAccount: async (ctx) => {
306
+ let { account } = ctx;
307
+ const { abortSignal, log, cfg } = ctx;
308
+
309
+ // 凭证恢复:如果 appId/secret 为空(热更新打断可能导致配置丢失),尝试从暂存文件恢复
310
+ if (!account.appId || !account.clientSecret) {
311
+ const backup = loadCredentialBackup(account.accountId);
312
+ if (backup) {
313
+ log?.info(`[qqbot:${account.accountId}] 配置中凭证为空,从暂存文件恢复 (appId=${backup.appId}, savedAt=${backup.savedAt})`);
314
+ try {
315
+ const runtime = getQQBotRuntime();
316
+ const restoredCfg = applyQQBotAccountConfig(cfg, account.accountId, {
317
+ appId: backup.appId,
318
+ clientSecret: backup.clientSecret,
319
+ });
320
+ const configApi = runtime.config as { writeConfigFile: (cfg: unknown) => Promise<void> };
321
+ await configApi.writeConfigFile(restoredCfg);
322
+ // 重新解析 account 以获取恢复后的值
323
+ account = resolveQQBotAccount(restoredCfg, account.accountId);
324
+ log?.info(`[qqbot:${account.accountId}] 凭证已恢复`);
325
+ } catch (e) {
326
+ log?.error(`[qqbot:${account.accountId}] 凭证恢复失败: ${e}`);
327
+ }
328
+ }
329
+ }
330
+
331
+ log?.info(`[qqbot:${account.accountId}] Starting gateway — appId=${account.appId}, enabled=${account.enabled}, name=${account.name ?? "unnamed"}`);
332
+ console.log(`[qqbot:channel] startAccount: accountId=${account.accountId}, appId=${account.appId}, secretSource=${account.secretSource}`);
333
+
334
+ await startGateway({
335
+ account,
336
+ abortSignal,
337
+ cfg,
338
+ log,
339
+ onReady: () => {
340
+ log?.info(`[qqbot:${account.accountId}] Gateway ready`);
341
+ // 启动成功,保存凭证快照供后续恢复使用
342
+ saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
343
+ ctx.setStatus({
344
+ ...ctx.getStatus(),
345
+ running: true,
346
+ connected: true,
347
+ lastConnectedAt: Date.now(),
348
+ });
349
+ },
350
+ onError: (error) => {
351
+ log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
352
+ ctx.setStatus({
353
+ ...ctx.getStatus(),
354
+ lastError: error.message,
355
+ });
356
+ },
357
+ });
358
+ },
359
+ // 新增:登出账户(清除配置中的凭证)
360
+ logoutAccount: async ({ accountId, cfg }) => {
361
+ const nextCfg = { ...cfg } as OpenClawConfig;
362
+ const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
363
+ let cleared = false;
364
+ let changed = false;
365
+
366
+ if (nextQQBot) {
367
+ const qqbot = nextQQBot as Record<string, unknown>;
368
+ if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
369
+ delete qqbot.clientSecret;
370
+ cleared = true;
371
+ changed = true;
372
+ }
373
+ const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
374
+ if (accounts && accountId in accounts) {
375
+ const entry = accounts[accountId] as Record<string, unknown> | undefined;
376
+ if (entry && "clientSecret" in entry) {
377
+ delete entry.clientSecret;
378
+ cleared = true;
379
+ changed = true;
380
+ }
381
+ if (entry && Object.keys(entry).length === 0) {
382
+ delete accounts[accountId];
383
+ changed = true;
384
+ }
385
+ }
386
+ }
387
+
388
+ if (changed && nextQQBot) {
389
+ nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
390
+ const runtime = getQQBotRuntime();
391
+ const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
392
+ await configApi.writeConfigFile(nextCfg);
393
+ }
394
+
395
+ const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
396
+ const loggedOut = resolved.secretSource === "none";
397
+ const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
398
+
399
+ return { ok: true, cleared, envToken, loggedOut };
400
+ },
401
+ },
402
+ status: {
403
+ defaultRuntime: {
404
+ accountId: DEFAULT_ACCOUNT_ID,
405
+ running: false,
406
+ connected: false,
407
+ lastConnectedAt: null,
408
+ lastError: null,
409
+ lastInboundAt: null,
410
+ lastOutboundAt: null,
411
+ },
412
+ // 新增:构建通道摘要
413
+ buildChannelSummary: ({ snapshot }) => ({
414
+ configured: snapshot.configured ?? false,
415
+ tokenSource: snapshot.tokenSource ?? "none",
416
+ running: snapshot.running ?? false,
417
+ connected: snapshot.connected ?? false,
418
+ lastConnectedAt: snapshot.lastConnectedAt ?? null,
419
+ lastError: snapshot.lastError ?? null,
420
+ }),
421
+ buildAccountSnapshot: ({ account, runtime }) => ({
422
+ accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
423
+ name: account?.name,
424
+ enabled: account?.enabled ?? false,
425
+ configured: Boolean(account?.appId && account?.clientSecret),
426
+ tokenSource: account?.secretSource,
427
+ running: Boolean(runtime?.running ?? false),
428
+ connected: Boolean(runtime?.connected ?? false),
429
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
430
+ lastError: runtime?.lastError ?? null,
431
+ lastInboundAt: runtime?.lastInboundAt ?? null,
432
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
433
+ }),
434
+ },
435
+ };
436
+
437
+ // ============ 独立的 mention 工具函数(供 gateway.ts 等直接调用) ============
438
+
439
+ /** 清理 @mention:替换 <@openid> 为 @用户名,去除 @机器人自身 */
440
+ export function stripMentionText(text: string, mentions?: Array<{ member_openid?: string; id?: string; user_openid?: string; is_you?: boolean; nickname?: string; username?: string }>): string {
441
+ if (!text || !mentions?.length) return text;
442
+ let cleaned = text;
443
+ for (const m of mentions) {
444
+ const openid = m.member_openid ?? m.id ?? m.user_openid;
445
+ if (!openid) continue;
446
+ if (m.is_you) {
447
+ cleaned = cleaned.replace(new RegExp(`<@!?${openid}>`, "g"), "").trim();
448
+ } else {
449
+ const displayName = m.nickname ?? m.username;
450
+ if (displayName) {
451
+ cleaned = cleaned.replace(new RegExp(`<@!?${openid}>`, "g"), `@${displayName}`);
452
+ }
453
+ }
454
+ }
455
+ return cleaned;
456
+ }
457
+
458
+ /** 检测消息是否 @了机器人(mentions > eventType > mentionPatterns) */
459
+ export function detectWasMentioned({ eventType, mentions, content, mentionPatterns }: {
460
+ eventType?: string;
461
+ mentions?: Array<{ is_you?: boolean }>;
462
+ content?: string;
463
+ mentionPatterns?: string[];
464
+ }): boolean {
465
+ if (mentions?.some((m) => m.is_you)) return true;
466
+ if (eventType === "GROUP_AT_MESSAGE_CREATE") return true;
467
+ if (mentionPatterns?.length && content) {
468
+ for (const pattern of mentionPatterns) {
469
+ try {
470
+ if (new RegExp(pattern, "i").test(content)) return true;
471
+ } catch {
472
+ // 无效正则,跳过
473
+ }
474
+ }
475
+ }
476
+ return false;
477
+ }