@tencent-connect/openclaw-qqbot 1.7.2 → 2.0.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 (291) hide show
  1. package/README.md +25 -8
  2. package/README.zh.md +24 -8
  3. package/bin/qqbot-cli.js +5 -2
  4. package/dist/index.cjs +15549 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +890 -0
  7. package/index.ts +56 -19
  8. package/openclaw.plugin.json +19 -3
  9. package/package.json +14 -23
  10. package/preload.cjs +17 -14
  11. package/scripts/link-sdk-core.cjs +133 -50
  12. package/scripts/upgrade-via-npm.sh +42 -11
  13. package/scripts/upgrade-via-source.sh +4 -4
  14. package/skills/qqbot-channel/SKILL.md +34 -12
  15. package/src/adapter/contract.ts +69 -0
  16. package/src/adapter/gateway.ts +61 -0
  17. package/src/adapter/index.ts +15 -0
  18. package/src/adapter/lint.ts +144 -0
  19. package/src/adapter/media.ts +112 -0
  20. package/src/adapter/pairing.ts +99 -0
  21. package/src/adapter/resolve.ts +333 -0
  22. package/src/adapter/setup.ts +55 -0
  23. package/src/adapter/webhook.ts +248 -0
  24. package/src/adapter/workspace.ts +29 -0
  25. package/src/bot-instance.ts +60 -0
  26. package/src/channel.ts +202 -413
  27. package/src/commands/bot-approve.ts +143 -0
  28. package/src/commands/bot-clear-storage.ts +114 -0
  29. package/src/commands/bot-group-always.ts +62 -0
  30. package/src/commands/bot-help.ts +40 -0
  31. package/src/commands/bot-logs.ts +248 -0
  32. package/src/commands/bot-me.ts +18 -0
  33. package/src/commands/bot-pairing.ts +57 -0
  34. package/src/commands/bot-ping.ts +33 -0
  35. package/src/commands/bot-streaming.ts +55 -0
  36. package/src/commands/bot-upgrade.ts +56 -0
  37. package/src/commands/bot-version.ts +41 -0
  38. package/src/commands/config-util.ts +96 -0
  39. package/src/commands/index.ts +49 -0
  40. package/src/config.ts +46 -19
  41. package/src/dispatch/body-assembler.ts +356 -0
  42. package/src/dispatch/ctx-builder.ts +110 -0
  43. package/src/dispatch/dispatch.ts +352 -0
  44. package/src/dispatch/envelope-builder.ts +112 -0
  45. package/src/dispatch/index.ts +2 -0
  46. package/src/{approval-handler.ts → features/approval-handler.ts} +40 -94
  47. package/src/features/approval-utils.ts +41 -0
  48. package/src/features/credential-backup.ts +82 -0
  49. package/src/features/history-store.ts +19 -0
  50. package/src/features/msgid-cache.ts +55 -0
  51. package/src/features/onboarding.ts +38 -0
  52. package/src/{proactive.ts → features/proactive.ts} +68 -49
  53. package/src/features/ref-index-store.ts +282 -0
  54. package/src/{update-checker.ts → features/update-checker.ts} +15 -35
  55. package/src/gateway/event-handlers.ts +241 -0
  56. package/src/gateway/index.ts +3 -0
  57. package/src/gateway/lifecycle.ts +221 -0
  58. package/src/gateway/middleware-setup.ts +139 -0
  59. package/src/gateway/qqbot-gateway.ts +342 -0
  60. package/src/middleware/access-control.ts +130 -0
  61. package/src/middleware/attachment.ts +314 -0
  62. package/src/middleware/policy-injector.ts +66 -0
  63. package/src/openclaw-plugin-sdk.d.ts +81 -10
  64. package/src/outbound/cron-scheduler.ts +130 -0
  65. package/src/outbound/debounce.ts +102 -0
  66. package/src/outbound/deliver-pipeline.ts +235 -0
  67. package/src/outbound/image-size.ts +123 -0
  68. package/src/outbound/index.ts +3 -0
  69. package/src/outbound/local-file-router.ts +145 -0
  70. package/src/outbound/media-send.ts +377 -0
  71. package/src/outbound/outbound-service.ts +222 -0
  72. package/src/outbound/reply-limiter.ts +128 -0
  73. package/src/outbound/sanitize.ts +32 -0
  74. package/src/outbound/streaming-controller.ts +228 -0
  75. package/src/outbound/target.ts +63 -0
  76. package/src/outbound/tts-provider.ts +84 -0
  77. package/src/request-context.ts +9 -8
  78. package/src/runtime.ts +31 -6
  79. package/src/setup/account-key.ts +41 -0
  80. package/src/setup/finalize.ts +110 -0
  81. package/src/setup/login.ts +197 -0
  82. package/src/setup/surface.ts +40 -0
  83. package/src/tools/platform.ts +149 -0
  84. package/src/tools/remind.ts +2 -0
  85. package/src/types-augment.d.ts +54 -0
  86. package/src/types.ts +51 -9
  87. package/src/utils/mention.ts +52 -0
  88. package/src/utils/pkg-version.ts +84 -47
  89. package/src/utils/platform.ts +31 -7
  90. package/src/utils/plugin-logger.ts +104 -0
  91. package/src/utils/ssrf-guard.ts +32 -2
  92. package/src/utils/stt.ts +137 -0
  93. package/src/utils/voice-text.ts +61 -0
  94. package/tsconfig.json +2 -1
  95. package/tsup.config.ts +57 -0
  96. package/dist/index.d.ts +0 -18
  97. package/dist/index.js +0 -27
  98. package/dist/src/admin-resolver.d.ts +0 -33
  99. package/dist/src/admin-resolver.js +0 -157
  100. package/dist/src/api.d.ts +0 -309
  101. package/dist/src/api.js +0 -923
  102. package/dist/src/approval-handler.d.ts +0 -47
  103. package/dist/src/approval-handler.js +0 -372
  104. package/dist/src/channel.d.ts +0 -29
  105. package/dist/src/channel.js +0 -528
  106. package/dist/src/config.d.ts +0 -60
  107. package/dist/src/config.js +0 -288
  108. package/dist/src/credential-backup.d.ts +0 -31
  109. package/dist/src/credential-backup.js +0 -66
  110. package/dist/src/deliver-debounce.d.ts +0 -74
  111. package/dist/src/deliver-debounce.js +0 -174
  112. package/dist/src/gateway.d.ts +0 -18
  113. package/dist/src/gateway.js +0 -2025
  114. package/dist/src/group-history.d.ts +0 -136
  115. package/dist/src/group-history.js +0 -226
  116. package/dist/src/image-server.d.ts +0 -87
  117. package/dist/src/image-server.js +0 -570
  118. package/dist/src/inbound-attachments.d.ts +0 -60
  119. package/dist/src/inbound-attachments.js +0 -248
  120. package/dist/src/known-users.d.ts +0 -100
  121. package/dist/src/known-users.js +0 -263
  122. package/dist/src/message-gating.d.ts +0 -53
  123. package/dist/src/message-gating.js +0 -107
  124. package/dist/src/message-queue.d.ts +0 -91
  125. package/dist/src/message-queue.js +0 -257
  126. package/dist/src/onboarding.d.ts +0 -10
  127. package/dist/src/onboarding.js +0 -203
  128. package/dist/src/outbound-deliver.d.ts +0 -48
  129. package/dist/src/outbound-deliver.js +0 -392
  130. package/dist/src/outbound.d.ts +0 -220
  131. package/dist/src/outbound.js +0 -975
  132. package/dist/src/proactive.d.ts +0 -170
  133. package/dist/src/proactive.js +0 -399
  134. package/dist/src/ref-index-store.d.ts +0 -101
  135. package/dist/src/ref-index-store.js +0 -298
  136. package/dist/src/reply-dispatcher.d.ts +0 -35
  137. package/dist/src/reply-dispatcher.js +0 -311
  138. package/dist/src/request-context.d.ts +0 -25
  139. package/dist/src/request-context.js +0 -37
  140. package/dist/src/runtime.d.ts +0 -3
  141. package/dist/src/runtime.js +0 -13
  142. package/dist/src/session-store.d.ts +0 -52
  143. package/dist/src/session-store.js +0 -254
  144. package/dist/src/slash-commands.d.ts +0 -82
  145. package/dist/src/slash-commands.js +0 -2233
  146. package/dist/src/startup-greeting.d.ts +0 -30
  147. package/dist/src/startup-greeting.js +0 -97
  148. package/dist/src/streaming.d.ts +0 -247
  149. package/dist/src/streaming.js +0 -899
  150. package/dist/src/stt.d.ts +0 -21
  151. package/dist/src/stt.js +0 -70
  152. package/dist/src/tools/channel.d.ts +0 -16
  153. package/dist/src/tools/channel.js +0 -231
  154. package/dist/src/tools/remind.d.ts +0 -2
  155. package/dist/src/tools/remind.js +0 -255
  156. package/dist/src/transport/index.d.ts +0 -10
  157. package/dist/src/transport/index.js +0 -9
  158. package/dist/src/transport/webhook-transport.d.ts +0 -67
  159. package/dist/src/transport/webhook-transport.js +0 -245
  160. package/dist/src/transport/webhook-verify.d.ts +0 -48
  161. package/dist/src/transport/webhook-verify.js +0 -98
  162. package/dist/src/types.d.ts +0 -462
  163. package/dist/src/types.js +0 -22
  164. package/dist/src/typing-keepalive.d.ts +0 -27
  165. package/dist/src/typing-keepalive.js +0 -64
  166. package/dist/src/update-checker.d.ts +0 -36
  167. package/dist/src/update-checker.js +0 -171
  168. package/dist/src/utils/audio-convert.d.ts +0 -98
  169. package/dist/src/utils/audio-convert.js +0 -783
  170. package/dist/src/utils/chunked-upload.d.ts +0 -68
  171. package/dist/src/utils/chunked-upload.js +0 -341
  172. package/dist/src/utils/file-utils.d.ts +0 -61
  173. package/dist/src/utils/file-utils.js +0 -172
  174. package/dist/src/utils/image-size.d.ts +0 -51
  175. package/dist/src/utils/image-size.js +0 -234
  176. package/dist/src/utils/media-send.d.ts +0 -158
  177. package/dist/src/utils/media-send.js +0 -502
  178. package/dist/src/utils/media-tags.d.ts +0 -14
  179. package/dist/src/utils/media-tags.js +0 -165
  180. package/dist/src/utils/payload.d.ts +0 -112
  181. package/dist/src/utils/payload.js +0 -186
  182. package/dist/src/utils/pkg-version.d.ts +0 -5
  183. package/dist/src/utils/pkg-version.js +0 -61
  184. package/dist/src/utils/platform.d.ts +0 -137
  185. package/dist/src/utils/platform.js +0 -390
  186. package/dist/src/utils/ssrf-guard.d.ts +0 -25
  187. package/dist/src/utils/ssrf-guard.js +0 -91
  188. package/dist/src/utils/text-parsing.d.ts +0 -31
  189. package/dist/src/utils/text-parsing.js +0 -74
  190. package/dist/src/utils/upload-cache.d.ts +0 -34
  191. package/dist/src/utils/upload-cache.js +0 -93
  192. package/node_modules/@eshaz/web-worker/LICENSE +0 -201
  193. package/node_modules/@eshaz/web-worker/README.md +0 -134
  194. package/node_modules/@eshaz/web-worker/browser.js +0 -17
  195. package/node_modules/@eshaz/web-worker/cjs/browser.js +0 -16
  196. package/node_modules/@eshaz/web-worker/cjs/node.js +0 -219
  197. package/node_modules/@eshaz/web-worker/index.d.ts +0 -4
  198. package/node_modules/@eshaz/web-worker/node.js +0 -223
  199. package/node_modules/@eshaz/web-worker/package.json +0 -54
  200. package/node_modules/@wasm-audio-decoders/common/index.js +0 -5
  201. package/node_modules/@wasm-audio-decoders/common/package.json +0 -36
  202. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +0 -231
  203. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +0 -129
  204. package/node_modules/@wasm-audio-decoders/common/src/puff/README +0 -67
  205. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +0 -31
  206. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +0 -863
  207. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +0 -35
  208. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +0 -3
  209. package/node_modules/@wasm-audio-decoders/common/types.d.ts +0 -7
  210. package/node_modules/mpg123-decoder/README.md +0 -265
  211. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +0 -185
  212. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +0 -1
  213. package/node_modules/mpg123-decoder/index.js +0 -8
  214. package/node_modules/mpg123-decoder/package.json +0 -58
  215. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +0 -464
  216. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +0 -200
  217. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +0 -21
  218. package/node_modules/mpg123-decoder/types.d.ts +0 -30
  219. package/node_modules/silk-wasm/LICENSE +0 -21
  220. package/node_modules/silk-wasm/README.md +0 -85
  221. package/node_modules/silk-wasm/lib/index.cjs +0 -16
  222. package/node_modules/silk-wasm/lib/index.d.ts +0 -70
  223. package/node_modules/silk-wasm/lib/index.mjs +0 -16
  224. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  225. package/node_modules/silk-wasm/lib/utils.d.ts +0 -4
  226. package/node_modules/silk-wasm/package.json +0 -39
  227. package/node_modules/simple-yenc/.github/FUNDING.yml +0 -1
  228. package/node_modules/simple-yenc/.prettierignore +0 -1
  229. package/node_modules/simple-yenc/LICENSE +0 -7
  230. package/node_modules/simple-yenc/README.md +0 -163
  231. package/node_modules/simple-yenc/dist/esm.js +0 -1
  232. package/node_modules/simple-yenc/dist/index.js +0 -1
  233. package/node_modules/simple-yenc/package.json +0 -50
  234. package/node_modules/simple-yenc/rollup.config.js +0 -27
  235. package/node_modules/simple-yenc/src/simple-yenc.js +0 -302
  236. package/node_modules/ws/LICENSE +0 -20
  237. package/node_modules/ws/README.md +0 -548
  238. package/node_modules/ws/browser.js +0 -8
  239. package/node_modules/ws/index.js +0 -13
  240. package/node_modules/ws/lib/buffer-util.js +0 -131
  241. package/node_modules/ws/lib/constants.js +0 -19
  242. package/node_modules/ws/lib/event-target.js +0 -292
  243. package/node_modules/ws/lib/extension.js +0 -203
  244. package/node_modules/ws/lib/limiter.js +0 -55
  245. package/node_modules/ws/lib/permessage-deflate.js +0 -528
  246. package/node_modules/ws/lib/receiver.js +0 -706
  247. package/node_modules/ws/lib/sender.js +0 -602
  248. package/node_modules/ws/lib/stream.js +0 -161
  249. package/node_modules/ws/lib/subprotocol.js +0 -62
  250. package/node_modules/ws/lib/validation.js +0 -152
  251. package/node_modules/ws/lib/websocket-server.js +0 -554
  252. package/node_modules/ws/lib/websocket.js +0 -1393
  253. package/node_modules/ws/package.json +0 -69
  254. package/node_modules/ws/wrapper.mjs +0 -8
  255. package/scripts/postinstall-link-sdk.js +0 -170
  256. package/skills/qqbot-media/SKILL.md +0 -60
  257. package/src/admin-resolver.ts +0 -181
  258. package/src/api.ts +0 -1333
  259. package/src/credential-backup.ts +0 -72
  260. package/src/deliver-debounce.ts +0 -229
  261. package/src/gateway.ts +0 -2272
  262. package/src/group-history.ts +0 -328
  263. package/src/image-server.ts +0 -675
  264. package/src/inbound-attachments.ts +0 -321
  265. package/src/known-users.ts +0 -353
  266. package/src/message-gating.ts +0 -190
  267. package/src/message-queue.ts +0 -354
  268. package/src/onboarding.ts +0 -282
  269. package/src/outbound-deliver.ts +0 -486
  270. package/src/outbound.ts +0 -1179
  271. package/src/ref-index-store.ts +0 -412
  272. package/src/reply-dispatcher.ts +0 -334
  273. package/src/session-store.ts +0 -303
  274. package/src/slash-commands.ts +0 -2431
  275. package/src/startup-greeting.ts +0 -120
  276. package/src/streaming.ts +0 -1077
  277. package/src/stt.ts +0 -86
  278. package/src/tools/channel.ts +0 -275
  279. package/src/transport/index.ts +0 -11
  280. package/src/transport/webhook-transport.ts +0 -332
  281. package/src/transport/webhook-verify.ts +0 -119
  282. package/src/typing-keepalive.ts +0 -59
  283. package/src/utils/audio-convert.ts +0 -887
  284. package/src/utils/chunked-upload.ts +0 -483
  285. package/src/utils/file-utils.ts +0 -193
  286. package/src/utils/image-size.ts +0 -266
  287. package/src/utils/media-send.ts +0 -636
  288. package/src/utils/media-tags.ts +0 -183
  289. package/src/utils/payload.ts +0 -265
  290. package/src/utils/text-parsing.ts +0 -85
  291. package/src/utils/upload-cache.ts +0 -128
@@ -1,334 +0,0 @@
1
- import path from "node:path";
2
- import type { ResolvedQQBotAccount } from "./types.js";
3
- import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage } from "./api.js";
4
- import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type MediaPayload } from "./utils/payload.js";
5
- import { resolveTTSConfig, textToSilk, formatDuration } from "./utils/audio-convert.js";
6
- import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize, getMaxUploadSize } from "./utils/file-utils.js";
7
- import { getQQBotDataDir, normalizePath, sanitizeFileName } from "./utils/platform.js";
8
-
9
- export interface MessageTarget {
10
- type: "c2c" | "guild" | "dm" | "group";
11
- senderId: string;
12
- messageId: string;
13
- channelId?: string;
14
- groupOpenid?: string;
15
- }
16
-
17
- export interface ReplyContext {
18
- target: MessageTarget;
19
- account: ResolvedQQBotAccount;
20
- cfg: unknown;
21
- log?: {
22
- info: (msg: string) => void;
23
- error: (msg: string) => void;
24
- debug?: (msg: string) => void;
25
- };
26
- }
27
-
28
- /**
29
- * 带 token 过期重试的消息发送
30
- */
31
- export async function sendWithTokenRetry<T>(
32
- appId: string,
33
- clientSecret: string,
34
- sendFn: (token: string) => Promise<T>,
35
- log?: ReplyContext["log"],
36
- accountId?: string,
37
- ): Promise<T> {
38
- try {
39
- const token = await getAccessToken(appId, clientSecret);
40
- return await sendFn(token);
41
- } catch (err) {
42
- const errMsg = String(err);
43
- if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
44
- log?.info(`[qqbot:${accountId}] Token may be expired, refreshing...`);
45
- clearTokenCache(appId);
46
- const newToken = await getAccessToken(appId, clientSecret);
47
- return await sendFn(newToken);
48
- } else {
49
- throw err;
50
- }
51
- }
52
- }
53
-
54
- /**
55
- * 根据消息类型路由发送文本
56
- */
57
- export async function sendTextToTarget(
58
- ctx: ReplyContext,
59
- text: string,
60
- refIdx?: string,
61
- ): Promise<void> {
62
- const { target, account } = ctx;
63
- await sendWithTokenRetry(account.appId, account.clientSecret, async (token) => {
64
- if (target.type === "c2c") {
65
- await sendC2CMessage(token, target.senderId, text, target.messageId, refIdx);
66
- } else if (target.type === "group" && target.groupOpenid) {
67
- await sendGroupMessage(token, target.groupOpenid, text, target.messageId);
68
- } else if (target.channelId) {
69
- await sendChannelMessage(token, target.channelId, text, target.messageId);
70
- } else if (target.type === "dm") {
71
- await sendC2CMessage(token, target.senderId, text, target.messageId, refIdx);
72
- }
73
- }, ctx.log, account.accountId);
74
- }
75
-
76
- /**
77
- * 发送错误提示给用户
78
- */
79
- export async function sendErrorToTarget(ctx: ReplyContext, errorText: string): Promise<void> {
80
- try {
81
- await sendTextToTarget(ctx, errorText);
82
- } catch (sendErr) {
83
- ctx.log?.error(`[qqbot:${ctx.account.accountId}] Failed to send error message: ${sendErr}`);
84
- }
85
- }
86
-
87
- /**
88
- * 处理结构化载荷(QQBOT_PAYLOAD: 前缀的 JSON)
89
- * 返回 true 表示已处理,false 表示不是结构化载荷
90
- */
91
- export async function handleStructuredPayload(
92
- ctx: ReplyContext,
93
- replyText: string,
94
- recordActivity: () => void,
95
- ): Promise<boolean> {
96
- const { target, account, cfg, log } = ctx;
97
- const payloadResult = parseQQBotPayload(replyText);
98
-
99
- if (!payloadResult.isPayload) return false;
100
-
101
- if (payloadResult.error) {
102
- log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`);
103
- return true;
104
- }
105
-
106
- if (!payloadResult.payload) return true;
107
-
108
- const parsedPayload = payloadResult.payload;
109
- log?.info(`[qqbot:${account.accountId}] Detected structured payload, type: ${parsedPayload.type}`);
110
-
111
- if (isCronReminderPayload(parsedPayload)) {
112
- log?.info(`[qqbot:${account.accountId}] Processing cron_reminder payload`);
113
- const cronMessage = encodePayloadForCron(parsedPayload);
114
- const confirmText = `⏰ 提醒已设置,将在指定时间发送: "${parsedPayload.content}"`;
115
- try {
116
- await sendTextToTarget(ctx, confirmText);
117
- log?.info(`[qqbot:${account.accountId}] Cron reminder confirmation sent, cronMessage: ${cronMessage}`);
118
- } catch (err) {
119
- log?.error(`[qqbot:${account.accountId}] Failed to send cron confirmation: ${err}`);
120
- }
121
- recordActivity();
122
- return true;
123
- }
124
-
125
- if (isMediaPayload(parsedPayload)) {
126
- log?.info(`[qqbot:${account.accountId}] Processing media payload, mediaType: ${parsedPayload.mediaType}`);
127
-
128
- if (parsedPayload.mediaType === "image") {
129
- await handleImagePayload(ctx, parsedPayload);
130
- } else if (parsedPayload.mediaType === "audio") {
131
- await handleAudioPayload(ctx, parsedPayload);
132
- } else if (parsedPayload.mediaType === "video") {
133
- await handleVideoPayload(ctx, parsedPayload);
134
- } else if (parsedPayload.mediaType === "file") {
135
- await handleFilePayload(ctx, parsedPayload);
136
- } else {
137
- log?.error(`[qqbot:${account.accountId}] Unknown media type: ${(parsedPayload as MediaPayload).mediaType}`);
138
- }
139
- recordActivity();
140
- return true;
141
- }
142
-
143
- log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${(parsedPayload as any).type}`);
144
- return true;
145
- }
146
-
147
- // ============ 媒体载荷处理 ============
148
-
149
- async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
150
- const { target, account, log } = ctx;
151
- let imageUrl = normalizePath(payload.path);
152
- const originalImagePath = payload.source === "file" ? imageUrl : undefined;
153
-
154
- if (payload.source === "file") {
155
- try {
156
- if (!(await fileExistsAsync(imageUrl))) {
157
- log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
158
- return;
159
- }
160
- const imgSzCheck = checkFileSize(imageUrl, getMaxUploadSize(1)); // IMAGE = 1
161
- if (!imgSzCheck.ok) {
162
- log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
163
- return;
164
- }
165
- const fileBuffer = await readFileAsync(imageUrl);
166
- const base64Data = fileBuffer.toString("base64");
167
- const ext = path.extname(imageUrl).toLowerCase();
168
- const mimeTypes: Record<string, string> = {
169
- ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
170
- ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
171
- };
172
- const mimeType = mimeTypes[ext];
173
- if (!mimeType) {
174
- log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
175
- return;
176
- }
177
- imageUrl = `data:${mimeType};base64,${base64Data}`;
178
- log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
179
- } catch (readErr) {
180
- log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
181
- return;
182
- }
183
- }
184
-
185
- try {
186
- await sendWithTokenRetry(account.appId, account.clientSecret, async (token) => {
187
- if (target.type === "c2c") {
188
- await sendC2CImageMessage(token, target.senderId, imageUrl, target.messageId, undefined, originalImagePath);
189
- } else if (target.type === "group" && target.groupOpenid) {
190
- await sendGroupImageMessage(token, target.groupOpenid, imageUrl, target.messageId);
191
- } else if (target.channelId) {
192
- await sendChannelMessage(token, target.channelId, `![](${payload.path})`, target.messageId);
193
- }
194
- }, log, account.accountId);
195
- log?.info(`[qqbot:${account.accountId}] Sent image via media payload`);
196
-
197
- if (payload.caption) {
198
- await sendTextToTarget(ctx, payload.caption);
199
- }
200
- } catch (err) {
201
- log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
202
- }
203
- }
204
-
205
- async function handleAudioPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
206
- const { target, account, cfg, log } = ctx;
207
- try {
208
- const ttsText = payload.caption || payload.path;
209
- if (!ttsText?.trim()) {
210
- log?.error(`[qqbot:${account.accountId}] Voice missing text`);
211
- } else {
212
- const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
213
- if (!ttsCfg) {
214
- log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
215
- } else {
216
- log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
217
- const ttsDir = getQQBotDataDir("tts");
218
- const { silkPath, silkBase64, duration } = await textToSilk(ttsText, ttsCfg, ttsDir);
219
- log?.info(`[qqbot:${account.accountId}] TTS done: ${formatDuration(duration)}, file saved: ${silkPath}`);
220
-
221
- await sendWithTokenRetry(account.appId, account.clientSecret, async (token) => {
222
- if (target.type === "c2c") {
223
- await sendC2CVoiceMessage(token, target.senderId, silkBase64, target.messageId, ttsText, silkPath);
224
- } else if (target.type === "group" && target.groupOpenid) {
225
- await sendGroupVoiceMessage(token, target.groupOpenid, silkBase64, target.messageId);
226
- } else if (target.channelId) {
227
- log?.error(`[qqbot:${account.accountId}] Voice not supported in channel, sending text fallback`);
228
- await sendChannelMessage(token, target.channelId, ttsText, target.messageId);
229
- }
230
- }, log, account.accountId);
231
- log?.info(`[qqbot:${account.accountId}] Voice message sent`);
232
- }
233
- }
234
- } catch (err) {
235
- log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
236
- }
237
- }
238
-
239
- async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
240
- const { target, account, log } = ctx;
241
- try {
242
- const videoPath = normalizePath(payload.path ?? "");
243
- if (!videoPath?.trim()) {
244
- log?.error(`[qqbot:${account.accountId}] Video missing path`);
245
- } else {
246
- const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
247
- log?.info(`[qqbot:${account.accountId}] Video send: "${videoPath.slice(0, 60)}..."`);
248
-
249
- await sendWithTokenRetry(account.appId, account.clientSecret, async (token) => {
250
- if (isHttpUrl) {
251
- if (target.type === "c2c") {
252
- await sendC2CVideoMessage(token, target.senderId, videoPath, undefined, target.messageId);
253
- } else if (target.type === "group" && target.groupOpenid) {
254
- await sendGroupVideoMessage(token, target.groupOpenid, videoPath, undefined, target.messageId);
255
- } else if (target.channelId) {
256
- log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
257
- }
258
- } else {
259
- if (!(await fileExistsAsync(videoPath))) {
260
- throw new Error(`视频文件不存在: ${videoPath}`);
261
- }
262
- const vPaySzCheck = checkFileSize(videoPath, getMaxUploadSize(2)); // VIDEO = 2
263
- if (!vPaySzCheck.ok) {
264
- throw new Error(vPaySzCheck.error!);
265
- }
266
- const fileBuffer = await readFileAsync(videoPath);
267
- const videoBase64 = fileBuffer.toString("base64");
268
- log?.info(`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
269
-
270
- if (target.type === "c2c") {
271
- await sendC2CVideoMessage(token, target.senderId, undefined, videoBase64, target.messageId, undefined, videoPath);
272
- } else if (target.type === "group" && target.groupOpenid) {
273
- await sendGroupVideoMessage(token, target.groupOpenid, undefined, videoBase64, target.messageId);
274
- } else if (target.channelId) {
275
- log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
276
- }
277
- }
278
- }, log, account.accountId);
279
- log?.info(`[qqbot:${account.accountId}] Video message sent`);
280
-
281
- if (payload.caption) {
282
- await sendTextToTarget(ctx, payload.caption);
283
- }
284
- }
285
- } catch (err) {
286
- log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
287
- }
288
- }
289
-
290
- async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
291
- const { target, account, log } = ctx;
292
- try {
293
- const filePath = normalizePath(payload.path ?? "");
294
- if (!filePath?.trim()) {
295
- log?.error(`[qqbot:${account.accountId}] File missing path`);
296
- } else {
297
- const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
298
- const fileName = sanitizeFileName(path.basename(filePath));
299
- log?.info(`[qqbot:${account.accountId}] File send: "${filePath.slice(0, 60)}..." (${isHttpUrl ? "URL" : "local"})`);
300
-
301
- await sendWithTokenRetry(account.appId, account.clientSecret, async (token) => {
302
- if (isHttpUrl) {
303
- if (target.type === "c2c") {
304
- await sendC2CFileMessage(token, target.senderId, undefined, filePath, target.messageId, fileName);
305
- } else if (target.type === "group" && target.groupOpenid) {
306
- await sendGroupFileMessage(token, target.groupOpenid, undefined, filePath, target.messageId, fileName);
307
- } else if (target.channelId) {
308
- log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
309
- }
310
- } else {
311
- if (!(await fileExistsAsync(filePath))) {
312
- throw new Error(`文件不存在: ${filePath}`);
313
- }
314
- const fPaySzCheck = checkFileSize(filePath, getMaxUploadSize(4)); // FILE = 4
315
- if (!fPaySzCheck.ok) {
316
- throw new Error(fPaySzCheck.error!);
317
- }
318
- const fileBuffer = await readFileAsync(filePath);
319
- const fileBase64 = fileBuffer.toString("base64");
320
- if (target.type === "c2c") {
321
- await sendC2CFileMessage(token, target.senderId, fileBase64, undefined, target.messageId, fileName, filePath);
322
- } else if (target.type === "group" && target.groupOpenid) {
323
- await sendGroupFileMessage(token, target.groupOpenid, fileBase64, undefined, target.messageId, fileName);
324
- } else if (target.channelId) {
325
- log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
326
- }
327
- }
328
- }, log, account.accountId);
329
- log?.info(`[qqbot:${account.accountId}] File message sent`);
330
- }
331
- } catch (err) {
332
- log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
333
- }
334
- }
@@ -1,303 +0,0 @@
1
- /**
2
- * Session 持久化存储
3
- * 将 WebSocket 连接状态(sessionId、lastSeq)持久化到文件
4
- * 支持进程重启后通过 Resume 机制快速恢复连接
5
- */
6
-
7
- import fs from "node:fs";
8
- import path from "node:path";
9
-
10
- // Session 状态接口
11
- export interface SessionState {
12
- /** WebSocket Session ID */
13
- sessionId: string | null;
14
- /** 最后收到的消息序号 */
15
- lastSeq: number | null;
16
- /** 上次连接成功的时间戳 */
17
- lastConnectedAt: number;
18
- /** 上次成功的权限级别索引 */
19
- intentLevelIndex: number;
20
- /** 关联的机器人账户 ID */
21
- accountId: string;
22
- /** 保存时间 */
23
- savedAt: number;
24
- /** 创建此 session 时使用的 appId(用于检测凭据变更) */
25
- appId?: string;
26
- }
27
-
28
- import { getQQBotDataDir } from "./utils/platform.js";
29
-
30
- // Session 文件目录
31
- const SESSION_DIR = getQQBotDataDir("sessions");
32
-
33
- // Session 过期时间(5分钟)- Resume 要求在断开后一定时间内恢复
34
- const SESSION_EXPIRE_TIME = 5 * 60 * 1000;
35
-
36
- // 写入节流时间(避免频繁写入)
37
- const SAVE_THROTTLE_MS = 1000;
38
-
39
- // 每个账户的节流状态
40
- const throttleState = new Map<string, {
41
- pendingState: SessionState | null;
42
- lastSaveTime: number;
43
- throttleTimer: ReturnType<typeof setTimeout> | null;
44
- }>();
45
-
46
- /**
47
- * 确保目录存在
48
- */
49
- function ensureDir(): void {
50
- if (!fs.existsSync(SESSION_DIR)) {
51
- fs.mkdirSync(SESSION_DIR, { recursive: true });
52
- }
53
- }
54
-
55
- /**
56
- * 获取 Session 文件路径
57
- */
58
- function getSessionPath(accountId: string): string {
59
- // 清理 accountId 中的特殊字符
60
- const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
61
- return path.join(SESSION_DIR, `session-${safeId}.json`);
62
- }
63
-
64
- /**
65
- * 加载 Session 状态
66
- * @param accountId 账户 ID
67
- * @param expectedAppId 当前使用的 appId,如果与保存时的 appId 不匹配则视为失效
68
- * @returns Session 状态,如果不存在、已过期或 appId 不匹配返回 null
69
- */
70
- export function loadSession(accountId: string, expectedAppId?: string): SessionState | null {
71
- const filePath = getSessionPath(accountId);
72
-
73
- try {
74
- if (!fs.existsSync(filePath)) {
75
- return null;
76
- }
77
-
78
- const data = fs.readFileSync(filePath, "utf-8");
79
- const state = JSON.parse(data) as SessionState;
80
-
81
- // 检查是否过期
82
- const now = Date.now();
83
- if (now - state.savedAt > SESSION_EXPIRE_TIME) {
84
- console.log(`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`);
85
- try {
86
- fs.unlinkSync(filePath);
87
- } catch {
88
- // 忽略删除错误
89
- }
90
- return null;
91
- }
92
-
93
- // 检查 appId 是否匹配(凭据变更检测)
94
- if (expectedAppId && state.appId && state.appId !== expectedAppId) {
95
- console.log(`[session-store] appId mismatch for ${accountId}: saved=${state.appId}, current=${expectedAppId}. Discarding stale session.`);
96
- try {
97
- fs.unlinkSync(filePath);
98
- } catch {
99
- // 忽略删除错误
100
- }
101
- return null;
102
- }
103
-
104
- // 验证必要字段
105
- if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) {
106
- console.log(`[session-store] Invalid session data for ${accountId}`);
107
- return null;
108
- }
109
-
110
- console.log(`[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, appId=${state.appId ?? "unknown"}, age=${Math.round((now - state.savedAt) / 1000)}s`);
111
- return state;
112
- } catch (err) {
113
- console.error(`[session-store] Failed to load session for ${accountId}: ${err}`);
114
- return null;
115
- }
116
- }
117
-
118
- /**
119
- * 保存 Session 状态(带节流,避免频繁写入)
120
- * @param state Session 状态
121
- */
122
- export function saveSession(state: SessionState): void {
123
- const { accountId } = state;
124
-
125
- // 获取或初始化节流状态
126
- let throttle = throttleState.get(accountId);
127
- if (!throttle) {
128
- throttle = {
129
- pendingState: null,
130
- lastSaveTime: 0,
131
- throttleTimer: null,
132
- };
133
- throttleState.set(accountId, throttle);
134
- }
135
-
136
- const now = Date.now();
137
- const timeSinceLastSave = now - throttle.lastSaveTime;
138
-
139
- // 如果距离上次保存时间足够长,立即保存
140
- if (timeSinceLastSave >= SAVE_THROTTLE_MS) {
141
- doSaveSession(state);
142
- throttle.lastSaveTime = now;
143
- throttle.pendingState = null;
144
-
145
- // 清除待定的节流定时器
146
- if (throttle.throttleTimer) {
147
- clearTimeout(throttle.throttleTimer);
148
- throttle.throttleTimer = null;
149
- }
150
- } else {
151
- // 记录待保存的状态
152
- throttle.pendingState = state;
153
-
154
- // 如果没有设置定时器,设置一个
155
- if (!throttle.throttleTimer) {
156
- const delay = SAVE_THROTTLE_MS - timeSinceLastSave;
157
- throttle.throttleTimer = setTimeout(() => {
158
- const t = throttleState.get(accountId);
159
- if (t && t.pendingState) {
160
- doSaveSession(t.pendingState);
161
- t.lastSaveTime = Date.now();
162
- t.pendingState = null;
163
- }
164
- if (t) {
165
- t.throttleTimer = null;
166
- }
167
- }, delay);
168
- }
169
- }
170
- }
171
-
172
- /**
173
- * 实际执行保存操作
174
- */
175
- function doSaveSession(state: SessionState): void {
176
- const filePath = getSessionPath(state.accountId);
177
-
178
- try {
179
- ensureDir();
180
-
181
- // 更新保存时间
182
- const stateToSave: SessionState = {
183
- ...state,
184
- savedAt: Date.now(),
185
- };
186
-
187
- fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
188
- console.log(`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`);
189
- } catch (err) {
190
- console.error(`[session-store] Failed to save session for ${state.accountId}: ${err}`);
191
- }
192
- }
193
-
194
- /**
195
- * 清除 Session 状态
196
- * @param accountId 账户 ID
197
- */
198
- export function clearSession(accountId: string): void {
199
- const filePath = getSessionPath(accountId);
200
-
201
- // 清除节流状态
202
- const throttle = throttleState.get(accountId);
203
- if (throttle) {
204
- if (throttle.throttleTimer) {
205
- clearTimeout(throttle.throttleTimer);
206
- }
207
- throttleState.delete(accountId);
208
- }
209
-
210
- try {
211
- if (fs.existsSync(filePath)) {
212
- fs.unlinkSync(filePath);
213
- console.log(`[session-store] Cleared session for ${accountId}`);
214
- }
215
- } catch (err) {
216
- console.error(`[session-store] Failed to clear session for ${accountId}: ${err}`);
217
- }
218
- }
219
-
220
- /**
221
- * 更新 lastSeq(轻量级更新)
222
- * @param accountId 账户 ID
223
- * @param lastSeq 最新的消息序号
224
- */
225
- export function updateLastSeq(accountId: string, lastSeq: number): void {
226
- const existing = loadSession(accountId);
227
- if (existing && existing.sessionId) {
228
- saveSession({
229
- ...existing,
230
- lastSeq,
231
- });
232
- }
233
- }
234
-
235
- /**
236
- * 获取所有保存的 Session 状态
237
- */
238
- export function getAllSessions(): SessionState[] {
239
- const sessions: SessionState[] = [];
240
-
241
- try {
242
- ensureDir();
243
- const files = fs.readdirSync(SESSION_DIR);
244
-
245
- for (const file of files) {
246
- if (file.startsWith("session-") && file.endsWith(".json")) {
247
- const filePath = path.join(SESSION_DIR, file);
248
- try {
249
- const data = fs.readFileSync(filePath, "utf-8");
250
- const state = JSON.parse(data) as SessionState;
251
- sessions.push(state);
252
- } catch {
253
- // 忽略解析错误
254
- }
255
- }
256
- }
257
- } catch {
258
- // 目录不存在等错误
259
- }
260
-
261
- return sessions;
262
- }
263
-
264
- /**
265
- * 清理过期的 Session 文件
266
- */
267
- export function cleanupExpiredSessions(): number {
268
- let cleaned = 0;
269
-
270
- try {
271
- ensureDir();
272
- const files = fs.readdirSync(SESSION_DIR);
273
- const now = Date.now();
274
-
275
- for (const file of files) {
276
- if (file.startsWith("session-") && file.endsWith(".json")) {
277
- const filePath = path.join(SESSION_DIR, file);
278
- try {
279
- const data = fs.readFileSync(filePath, "utf-8");
280
- const state = JSON.parse(data) as SessionState;
281
-
282
- if (now - state.savedAt > SESSION_EXPIRE_TIME) {
283
- fs.unlinkSync(filePath);
284
- cleaned++;
285
- console.log(`[session-store] Cleaned expired session: ${file}`);
286
- }
287
- } catch {
288
- // 忽略解析错误,但也删除损坏的文件
289
- try {
290
- fs.unlinkSync(filePath);
291
- cleaned++;
292
- } catch {
293
- // 忽略
294
- }
295
- }
296
- }
297
- }
298
- } catch {
299
- // 目录不存在等错误
300
- }
301
-
302
- return cleaned;
303
- }