@tencent-connect/openclaw-qqbot 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +393 -0
  3. package/README.zh.md +390 -0
  4. package/bin/qqbot-cli.js +243 -0
  5. package/clawdbot.plugin.json +16 -0
  6. package/dist/index.d.ts +17 -0
  7. package/dist/index.js +22 -0
  8. package/dist/src/api.d.ts +138 -0
  9. package/dist/src/api.js +523 -0
  10. package/dist/src/channel.d.ts +3 -0
  11. package/dist/src/channel.js +337 -0
  12. package/dist/src/config.d.ts +25 -0
  13. package/dist/src/config.js +156 -0
  14. package/dist/src/gateway.d.ts +18 -0
  15. package/dist/src/gateway.js +2315 -0
  16. package/dist/src/image-server.d.ts +62 -0
  17. package/dist/src/image-server.js +401 -0
  18. package/dist/src/known-users.d.ts +100 -0
  19. package/dist/src/known-users.js +263 -0
  20. package/dist/src/onboarding.d.ts +10 -0
  21. package/dist/src/onboarding.js +203 -0
  22. package/dist/src/outbound.d.ts +150 -0
  23. package/dist/src/outbound.js +1175 -0
  24. package/dist/src/proactive.d.ts +170 -0
  25. package/dist/src/proactive.js +399 -0
  26. package/dist/src/runtime.d.ts +3 -0
  27. package/dist/src/runtime.js +10 -0
  28. package/dist/src/session-store.d.ts +52 -0
  29. package/dist/src/session-store.js +254 -0
  30. package/dist/src/types.d.ts +145 -0
  31. package/dist/src/types.js +1 -0
  32. package/dist/src/utils/audio-convert.d.ts +73 -0
  33. package/dist/src/utils/audio-convert.js +645 -0
  34. package/dist/src/utils/file-utils.d.ts +46 -0
  35. package/dist/src/utils/file-utils.js +107 -0
  36. package/dist/src/utils/image-size.d.ts +51 -0
  37. package/dist/src/utils/image-size.js +234 -0
  38. package/dist/src/utils/media-tags.d.ts +14 -0
  39. package/dist/src/utils/media-tags.js +120 -0
  40. package/dist/src/utils/payload.d.ts +112 -0
  41. package/dist/src/utils/payload.js +186 -0
  42. package/dist/src/utils/platform.d.ts +126 -0
  43. package/dist/src/utils/platform.js +358 -0
  44. package/dist/src/utils/upload-cache.d.ts +34 -0
  45. package/dist/src/utils/upload-cache.js +93 -0
  46. package/index.ts +27 -0
  47. package/moltbot.plugin.json +16 -0
  48. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  49. package/node_modules/@eshaz/web-worker/README.md +134 -0
  50. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  51. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  52. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  53. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  54. package/node_modules/@eshaz/web-worker/node.js +223 -0
  55. package/node_modules/@eshaz/web-worker/package.json +54 -0
  56. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  57. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  58. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  59. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  60. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  61. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  62. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  63. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  64. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  65. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  66. package/node_modules/mpg123-decoder/README.md +265 -0
  67. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  68. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  69. package/node_modules/mpg123-decoder/index.js +8 -0
  70. package/node_modules/mpg123-decoder/package.json +58 -0
  71. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  72. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  73. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  74. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  75. package/node_modules/silk-wasm/LICENSE +21 -0
  76. package/node_modules/silk-wasm/README.md +85 -0
  77. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  78. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  79. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  80. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  81. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  82. package/node_modules/silk-wasm/package.json +39 -0
  83. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  84. package/node_modules/simple-yenc/.prettierignore +1 -0
  85. package/node_modules/simple-yenc/LICENSE +7 -0
  86. package/node_modules/simple-yenc/README.md +163 -0
  87. package/node_modules/simple-yenc/dist/esm.js +1 -0
  88. package/node_modules/simple-yenc/dist/index.js +1 -0
  89. package/node_modules/simple-yenc/package.json +50 -0
  90. package/node_modules/simple-yenc/rollup.config.js +27 -0
  91. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  92. package/node_modules/ws/LICENSE +20 -0
  93. package/node_modules/ws/README.md +548 -0
  94. package/node_modules/ws/browser.js +8 -0
  95. package/node_modules/ws/index.js +13 -0
  96. package/node_modules/ws/lib/buffer-util.js +131 -0
  97. package/node_modules/ws/lib/constants.js +19 -0
  98. package/node_modules/ws/lib/event-target.js +292 -0
  99. package/node_modules/ws/lib/extension.js +203 -0
  100. package/node_modules/ws/lib/limiter.js +55 -0
  101. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  102. package/node_modules/ws/lib/receiver.js +706 -0
  103. package/node_modules/ws/lib/sender.js +602 -0
  104. package/node_modules/ws/lib/stream.js +161 -0
  105. package/node_modules/ws/lib/subprotocol.js +62 -0
  106. package/node_modules/ws/lib/validation.js +152 -0
  107. package/node_modules/ws/lib/websocket-server.js +554 -0
  108. package/node_modules/ws/lib/websocket.js +1393 -0
  109. package/node_modules/ws/package.json +69 -0
  110. package/node_modules/ws/wrapper.mjs +8 -0
  111. package/openclaw.plugin.json +16 -0
  112. package/package.json +76 -0
  113. package/scripts/proactive-api-server.ts +356 -0
  114. package/scripts/pull-latest.sh +316 -0
  115. package/scripts/send-proactive.ts +273 -0
  116. package/scripts/set-markdown.sh +156 -0
  117. package/scripts/upgrade-and-run.sh +525 -0
  118. package/scripts/upgrade.sh +127 -0
  119. package/skills/qqbot-cron/SKILL.md +513 -0
  120. package/skills/qqbot-media/SKILL.md +194 -0
  121. package/src/api.ts +704 -0
  122. package/src/channel.ts +368 -0
  123. package/src/config.ts +182 -0
  124. package/src/gateway.ts +2459 -0
  125. package/src/image-server.ts +474 -0
  126. package/src/known-users.ts +353 -0
  127. package/src/onboarding.ts +274 -0
  128. package/src/openclaw-plugin-sdk.d.ts +483 -0
  129. package/src/outbound.ts +1301 -0
  130. package/src/proactive.ts +530 -0
  131. package/src/runtime.ts +14 -0
  132. package/src/session-store.ts +303 -0
  133. package/src/types.ts +153 -0
  134. package/src/utils/audio-convert.ts +738 -0
  135. package/src/utils/file-utils.ts +122 -0
  136. package/src/utils/image-size.ts +266 -0
  137. package/src/utils/media-tags.ts +134 -0
  138. package/src/utils/payload.ts +265 -0
  139. package/src/utils/platform.ts +404 -0
  140. package/src/utils/upload-cache.ts +128 -0
  141. package/tsconfig.json +16 -0
package/src/channel.ts ADDED
@@ -0,0 +1,368 @@
1
+ import {
2
+ type ChannelPlugin,
3
+ type OpenClawConfig,
4
+ applyAccountNameToChannelSection,
5
+ deleteAccountFromConfigSection,
6
+ setAccountEnabledInConfigSection,
7
+ } from "openclaw/plugin-sdk";
8
+
9
+ import type { ResolvedQQBotAccount } from "./types.js";
10
+ import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } 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
+
16
+ /**
17
+ * 简单的文本分块函数
18
+ * 用于预先分块长文本
19
+ */
20
+ function chunkText(text: string, limit: number): string[] {
21
+ if (text.length <= limit) return [text];
22
+
23
+ const chunks: string[] = [];
24
+ let remaining = text;
25
+
26
+ while (remaining.length > 0) {
27
+ if (remaining.length <= limit) {
28
+ chunks.push(remaining);
29
+ break;
30
+ }
31
+
32
+ // 尝试在换行处分割
33
+ let splitAt = remaining.lastIndexOf("\n", limit);
34
+ if (splitAt <= 0 || splitAt < limit * 0.5) {
35
+ // 没找到合适的换行,尝试在空格处分割
36
+ splitAt = remaining.lastIndexOf(" ", limit);
37
+ }
38
+ if (splitAt <= 0 || splitAt < limit * 0.5) {
39
+ // 还是没找到,强制在 limit 处分割
40
+ splitAt = limit;
41
+ }
42
+
43
+ chunks.push(remaining.slice(0, splitAt));
44
+ remaining = remaining.slice(splitAt).trimStart();
45
+ }
46
+
47
+ return chunks;
48
+ }
49
+
50
+ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
51
+ id: "qqbot",
52
+ meta: {
53
+ id: "qqbot",
54
+ label: "QQ Bot",
55
+ selectionLabel: "QQ Bot",
56
+ docsPath: "/docs/channels/qqbot",
57
+ blurb: "Connect to QQ via official QQ Bot API",
58
+ order: 50,
59
+ },
60
+ capabilities: {
61
+ chatTypes: ["direct", "group"],
62
+ media: true,
63
+ reactions: false,
64
+ threads: false,
65
+ /**
66
+ * blockStreaming: true 表示该 Channel 支持块流式
67
+ * 框架会收集流式响应,然后通过 deliver 回调发送
68
+ */
69
+ blockStreaming: false,
70
+ },
71
+ reload: { configPrefixes: ["channels.qqbot"] },
72
+ // CLI onboarding wizard
73
+ onboarding: qqbotOnboardingAdapter,
74
+
75
+ config: {
76
+ listAccountIds: (cfg) => listQQBotAccountIds(cfg),
77
+ resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
78
+ defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg),
79
+ // 新增:设置账户启用状态
80
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
81
+ setAccountEnabledInConfigSection({
82
+ cfg,
83
+ sectionKey: "qqbot",
84
+ accountId,
85
+ enabled,
86
+ allowTopLevel: true,
87
+ }),
88
+ // 新增:删除账户
89
+ deleteAccount: ({ cfg, accountId }) =>
90
+ deleteAccountFromConfigSection({
91
+ cfg,
92
+ sectionKey: "qqbot",
93
+ accountId,
94
+ clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
95
+ }),
96
+ isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
97
+ describeAccount: (account) => ({
98
+ accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
99
+ name: account?.name,
100
+ enabled: account?.enabled ?? false,
101
+ configured: Boolean(account?.appId && account?.clientSecret),
102
+ tokenSource: account?.secretSource,
103
+ }),
104
+ // 关键:解析 allowFrom 配置,用于命令授权
105
+ resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
106
+ const account = resolveQQBotAccount(cfg, accountId);
107
+ const allowFrom = account.config?.allowFrom ?? [];
108
+ console.log(`[qqbot] resolveAllowFrom: accountId=${accountId}, allowFrom=${JSON.stringify(allowFrom)}`);
109
+ return allowFrom.map((entry: string | number) => String(entry));
110
+ },
111
+ // 格式化 allowFrom 条目(移除 qqbot: 前缀,统一大写)
112
+ formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
113
+ allowFrom
114
+ .map((entry: string | number) => String(entry).trim())
115
+ .filter(Boolean)
116
+ .map((entry: string) => entry.replace(/^qqbot:/i, ""))
117
+ .map((entry: string) => entry.toUpperCase()), // QQ openid 是大写的
118
+ },
119
+ setup: {
120
+ // 新增:规范化账户 ID
121
+ resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
122
+ // 新增:应用账户名称
123
+ applyAccountName: ({ cfg, accountId, name }) =>
124
+ applyAccountNameToChannelSection({
125
+ cfg,
126
+ channelKey: "qqbot",
127
+ accountId,
128
+ name,
129
+ }),
130
+ validateInput: ({ input }) => {
131
+ if (!input.token && !input.tokenFile && !input.useEnv) {
132
+ return "QQBot requires --token (format: appId:clientSecret) or --use-env";
133
+ }
134
+ return null;
135
+ },
136
+ applyAccountConfig: ({ cfg, accountId, input }) => {
137
+ let appId = "";
138
+ let clientSecret = "";
139
+
140
+ if (input.token) {
141
+ const parts = input.token.split(":");
142
+ if (parts.length === 2) {
143
+ appId = parts[0];
144
+ clientSecret = parts[1];
145
+ }
146
+ }
147
+
148
+ return applyQQBotAccountConfig(cfg, accountId, {
149
+ appId,
150
+ clientSecret,
151
+ clientSecretFile: input.tokenFile,
152
+ name: input.name,
153
+ imageServerBaseUrl: input.imageServerBaseUrl,
154
+ });
155
+ },
156
+ },
157
+ // Messaging 配置:用于解析目标地址
158
+ messaging: {
159
+ /**
160
+ * 规范化目标地址
161
+ * 支持以下格式:
162
+ * - qqbot:c2c:openid -> 私聊
163
+ * - qqbot:group:groupid -> 群聊
164
+ * - qqbot:channel:channelid -> 频道
165
+ * - c2c:openid -> 私聊
166
+ * - group:groupid -> 群聊
167
+ * - channel:channelid -> 频道
168
+ * - 纯 openid(32位十六进制)-> 私聊
169
+ */
170
+ normalizeTarget: (target: string) => {
171
+ // 去掉 qqbot: 前缀(如果有)
172
+ const id = target.replace(/^qqbot:/i, "");
173
+
174
+ // 检查是否是已知格式
175
+ if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) {
176
+ return { ok: true, to: `qqbot:${id}` };
177
+ }
178
+
179
+ // 检查是否是纯 openid(32位十六进制,不带连字符)
180
+ // QQ Bot OpenID 格式类似: 207A5B8339D01F6582911C014668B77B
181
+ const openIdHexPattern = /^[0-9a-fA-F]{32}$/;
182
+ if (openIdHexPattern.test(id)) {
183
+ return { ok: true, to: `qqbot:c2c:${id}` };
184
+ }
185
+
186
+ // 检查是否是 UUID 格式的 openid(带连字符)
187
+ 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}$/;
188
+ if (openIdUuidPattern.test(id)) {
189
+ return { ok: true, to: `qqbot:c2c:${id}` };
190
+ }
191
+
192
+ // 不认识的格式
193
+ return { ok: false, error: `unrecognized target format: ${target}` };
194
+ },
195
+ /**
196
+ * 目标解析器配置
197
+ * 用于判断一个目标 ID 是否看起来像 QQ Bot 的格式
198
+ */
199
+ targetResolver: {
200
+ /**
201
+ * 判断目标 ID 是否可能是 QQ Bot 格式
202
+ * 支持以下格式:
203
+ * - qqbot:c2c:xxx
204
+ * - qqbot:group:xxx
205
+ * - qqbot:channel:xxx
206
+ * - c2c:xxx
207
+ * - group:xxx
208
+ * - channel:xxx
209
+ * - UUID 格式的 openid
210
+ */
211
+ looksLikeId: (id: string): boolean => {
212
+ // 带 qqbot: 前缀的格式
213
+ if (/^qqbot:(c2c|group|channel):/i.test(id)) {
214
+ return true;
215
+ }
216
+ // 不带前缀但有类型标识
217
+ if (/^(c2c|group|channel):/i.test(id)) {
218
+ return true;
219
+ }
220
+ // 32位十六进制 openid(不带连字符)
221
+ if (/^[0-9a-fA-F]{32}$/.test(id)) {
222
+ return true;
223
+ }
224
+ // UUID 格式的 openid(带连字符)
225
+ 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}$/;
226
+ return openIdPattern.test(id);
227
+ },
228
+ hint: "QQ Bot 目标格式: qqbot:c2c:openid (私聊) 或 qqbot:group:groupid (群聊)",
229
+ },
230
+ },
231
+ outbound: {
232
+ deliveryMode: "direct",
233
+ chunker: chunkText,
234
+ chunkerMode: "markdown",
235
+ textChunkLimit: 2000,
236
+ sendText: async ({ to, text, accountId, replyToId, cfg }) => {
237
+ console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
238
+ console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
239
+ const account = resolveQQBotAccount(cfg, accountId);
240
+ console.log(`[qqbot:channel] sendText resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
241
+ const result = await sendText({ to, text, accountId, replyToId, account });
242
+ console.log(`[qqbot:channel] sendText result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
243
+ return {
244
+ channel: "qqbot",
245
+ messageId: result.messageId,
246
+ error: result.error ? new Error(result.error) : undefined,
247
+ };
248
+ },
249
+ sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
250
+ console.log(`[qqbot:channel] sendMedia called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, mediaUrl=${mediaUrl?.slice(0, 80)}, text.length=${text?.length ?? 0}`);
251
+ const account = resolveQQBotAccount(cfg, accountId);
252
+ console.log(`[qqbot:channel] sendMedia resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
253
+ const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
254
+ console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
255
+ return {
256
+ channel: "qqbot",
257
+ messageId: result.messageId,
258
+ error: result.error ? new Error(result.error) : undefined,
259
+ };
260
+ },
261
+ },
262
+ gateway: {
263
+ startAccount: async (ctx) => {
264
+ const { account, abortSignal, log, cfg } = ctx;
265
+
266
+ log?.info(`[qqbot:${account.accountId}] Starting gateway — appId=${account.appId}, enabled=${account.enabled}, name=${account.name ?? "unnamed"}`);
267
+ console.log(`[qqbot:channel] startAccount: accountId=${account.accountId}, appId=${account.appId}, secretSource=${account.secretSource}`);
268
+
269
+ await startGateway({
270
+ account,
271
+ abortSignal,
272
+ cfg,
273
+ log,
274
+ onReady: () => {
275
+ log?.info(`[qqbot:${account.accountId}] Gateway ready`);
276
+ ctx.setStatus({
277
+ ...ctx.getStatus(),
278
+ running: true,
279
+ connected: true,
280
+ lastConnectedAt: Date.now(),
281
+ });
282
+ },
283
+ onError: (error) => {
284
+ log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
285
+ ctx.setStatus({
286
+ ...ctx.getStatus(),
287
+ lastError: error.message,
288
+ });
289
+ },
290
+ });
291
+ },
292
+ // 新增:登出账户(清除配置中的凭证)
293
+ logoutAccount: async ({ accountId, cfg }) => {
294
+ const nextCfg = { ...cfg } as OpenClawConfig;
295
+ const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
296
+ let cleared = false;
297
+ let changed = false;
298
+
299
+ if (nextQQBot) {
300
+ const qqbot = nextQQBot as Record<string, unknown>;
301
+ if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
302
+ delete qqbot.clientSecret;
303
+ cleared = true;
304
+ changed = true;
305
+ }
306
+ const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
307
+ if (accounts && accountId in accounts) {
308
+ const entry = accounts[accountId] as Record<string, unknown> | undefined;
309
+ if (entry && "clientSecret" in entry) {
310
+ delete entry.clientSecret;
311
+ cleared = true;
312
+ changed = true;
313
+ }
314
+ if (entry && Object.keys(entry).length === 0) {
315
+ delete accounts[accountId];
316
+ changed = true;
317
+ }
318
+ }
319
+ }
320
+
321
+ if (changed && nextQQBot) {
322
+ nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
323
+ const runtime = getQQBotRuntime();
324
+ const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
325
+ await configApi.writeConfigFile(nextCfg);
326
+ }
327
+
328
+ const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
329
+ const loggedOut = resolved.secretSource === "none";
330
+ const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
331
+
332
+ return { ok: true, cleared, envToken, loggedOut };
333
+ },
334
+ },
335
+ status: {
336
+ defaultRuntime: {
337
+ accountId: DEFAULT_ACCOUNT_ID,
338
+ running: false,
339
+ connected: false,
340
+ lastConnectedAt: null,
341
+ lastError: null,
342
+ lastInboundAt: null,
343
+ lastOutboundAt: null,
344
+ },
345
+ // 新增:构建通道摘要
346
+ buildChannelSummary: ({ snapshot }: { snapshot: Record<string, unknown> }) => ({
347
+ configured: snapshot.configured ?? false,
348
+ tokenSource: snapshot.tokenSource ?? "none",
349
+ running: snapshot.running ?? false,
350
+ connected: snapshot.connected ?? false,
351
+ lastConnectedAt: snapshot.lastConnectedAt ?? null,
352
+ lastError: snapshot.lastError ?? null,
353
+ }),
354
+ buildAccountSnapshot: ({ account, runtime }: { account?: ResolvedQQBotAccount; runtime?: Record<string, unknown> }) => ({
355
+ accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
356
+ name: account?.name,
357
+ enabled: account?.enabled ?? false,
358
+ configured: Boolean(account?.appId && account?.clientSecret),
359
+ tokenSource: account?.secretSource,
360
+ running: runtime?.running ?? false,
361
+ connected: runtime?.connected ?? false,
362
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
363
+ lastError: runtime?.lastError ?? null,
364
+ lastInboundAt: runtime?.lastInboundAt ?? null,
365
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
366
+ }),
367
+ },
368
+ };
package/src/config.ts ADDED
@@ -0,0 +1,182 @@
1
+ import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+
4
+ export const DEFAULT_ACCOUNT_ID = "default";
5
+
6
+ interface QQBotChannelConfig extends QQBotAccountConfig {
7
+ accounts?: Record<string, QQBotAccountConfig>;
8
+ }
9
+
10
+ /**
11
+ * 列出所有 QQBot 账户 ID
12
+ */
13
+ export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
14
+ const ids = new Set<string>();
15
+ const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
16
+
17
+ if (qqbot?.appId) {
18
+ ids.add(DEFAULT_ACCOUNT_ID);
19
+ }
20
+
21
+ if (qqbot?.accounts) {
22
+ for (const accountId of Object.keys(qqbot.accounts)) {
23
+ if (qqbot.accounts[accountId]?.appId) {
24
+ ids.add(accountId);
25
+ }
26
+ }
27
+ }
28
+
29
+ return Array.from(ids);
30
+ }
31
+
32
+ /**
33
+ * 获取默认账户 ID
34
+ */
35
+ export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
36
+ const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
37
+ // 如果有默认账户配置,返回 default
38
+ if (qqbot?.appId) {
39
+ return DEFAULT_ACCOUNT_ID;
40
+ }
41
+ // 否则返回第一个配置的账户
42
+ if (qqbot?.accounts) {
43
+ const ids = Object.keys(qqbot.accounts);
44
+ if (ids.length > 0) {
45
+ return ids[0];
46
+ }
47
+ }
48
+ return DEFAULT_ACCOUNT_ID;
49
+ }
50
+
51
+ /**
52
+ * 解析 QQBot 账户配置
53
+ */
54
+ export function resolveQQBotAccount(
55
+ cfg: OpenClawConfig,
56
+ accountId?: string | null
57
+ ): ResolvedQQBotAccount {
58
+ const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
59
+ const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
60
+
61
+ // 基础配置
62
+ let accountConfig: QQBotAccountConfig = {};
63
+ let appId = "";
64
+ let clientSecret = "";
65
+ let secretSource: "config" | "file" | "env" | "none" = "none";
66
+
67
+ if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
68
+ // 默认账户从顶层读取
69
+ accountConfig = {
70
+ enabled: qqbot?.enabled,
71
+ name: qqbot?.name,
72
+ appId: qqbot?.appId,
73
+ clientSecret: qqbot?.clientSecret,
74
+ clientSecretFile: qqbot?.clientSecretFile,
75
+ dmPolicy: qqbot?.dmPolicy,
76
+ allowFrom: qqbot?.allowFrom,
77
+ systemPrompt: qqbot?.systemPrompt,
78
+ imageServerBaseUrl: qqbot?.imageServerBaseUrl,
79
+ markdownSupport: qqbot?.markdownSupport ?? true,
80
+ };
81
+ appId = qqbot?.appId ?? "";
82
+ } else {
83
+ // 命名账户从 accounts 读取
84
+ const account = qqbot?.accounts?.[resolvedAccountId];
85
+ accountConfig = account ?? {};
86
+ appId = account?.appId ?? "";
87
+ }
88
+
89
+ // 解析 clientSecret
90
+ if (accountConfig.clientSecret) {
91
+ clientSecret = accountConfig.clientSecret;
92
+ secretSource = "config";
93
+ } else if (accountConfig.clientSecretFile) {
94
+ // 从文件读取(运行时处理)
95
+ secretSource = "file";
96
+ } else if (process.env.QQBOT_CLIENT_SECRET && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
97
+ clientSecret = process.env.QQBOT_CLIENT_SECRET;
98
+ secretSource = "env";
99
+ }
100
+
101
+ // AppId 也可以从环境变量读取
102
+ if (!appId && process.env.QQBOT_APP_ID && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
103
+ appId = process.env.QQBOT_APP_ID;
104
+ }
105
+
106
+ return {
107
+ accountId: resolvedAccountId,
108
+ name: accountConfig.name,
109
+ enabled: accountConfig.enabled !== false,
110
+ appId,
111
+ clientSecret,
112
+ secretSource,
113
+ systemPrompt: accountConfig.systemPrompt,
114
+ imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
115
+ markdownSupport: accountConfig.markdownSupport !== false,
116
+ config: accountConfig,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * 应用账户配置
122
+ */
123
+ export function applyQQBotAccountConfig(
124
+ cfg: OpenClawConfig,
125
+ accountId: string,
126
+ input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerBaseUrl?: string }
127
+ ): OpenClawConfig {
128
+ const next = { ...cfg };
129
+
130
+ if (accountId === DEFAULT_ACCOUNT_ID) {
131
+ // 如果没有设置过 allowFrom,默认设置为 ["*"]
132
+ const existingConfig = (next.channels?.qqbot as QQBotChannelConfig) || {};
133
+ const allowFrom = existingConfig.allowFrom ?? ["*"];
134
+
135
+ next.channels = {
136
+ ...next.channels,
137
+ qqbot: {
138
+ ...(next.channels?.qqbot as Record<string, unknown> || {}),
139
+ enabled: true,
140
+ allowFrom,
141
+ ...(input.appId ? { appId: input.appId } : {}),
142
+ ...(input.clientSecret
143
+ ? { clientSecret: input.clientSecret }
144
+ : input.clientSecretFile
145
+ ? { clientSecretFile: input.clientSecretFile }
146
+ : {}),
147
+ ...(input.name ? { name: input.name } : {}),
148
+ ...(input.imageServerBaseUrl ? { imageServerBaseUrl: input.imageServerBaseUrl } : {}),
149
+ },
150
+ };
151
+ } else {
152
+ // 如果没有设置过 allowFrom,默认设置为 ["*"]
153
+ const existingAccountConfig = (next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {};
154
+ const allowFrom = existingAccountConfig.allowFrom ?? ["*"];
155
+
156
+ next.channels = {
157
+ ...next.channels,
158
+ qqbot: {
159
+ ...(next.channels?.qqbot as Record<string, unknown> || {}),
160
+ enabled: true,
161
+ accounts: {
162
+ ...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
163
+ [accountId]: {
164
+ ...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
165
+ enabled: true,
166
+ allowFrom,
167
+ ...(input.appId ? { appId: input.appId } : {}),
168
+ ...(input.clientSecret
169
+ ? { clientSecret: input.clientSecret }
170
+ : input.clientSecretFile
171
+ ? { clientSecretFile: input.clientSecretFile }
172
+ : {}),
173
+ ...(input.name ? { name: input.name } : {}),
174
+ ...(input.imageServerBaseUrl ? { imageServerBaseUrl: input.imageServerBaseUrl } : {}),
175
+ },
176
+ },
177
+ },
178
+ };
179
+ }
180
+
181
+ return next;
182
+ }