@ryantest/openclaw-qqbot 0.0.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 (197) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +483 -0
  3. package/README.zh.md +478 -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 +26 -0
  8. package/dist/src/admin-resolver.d.ts +27 -0
  9. package/dist/src/admin-resolver.js +122 -0
  10. package/dist/src/api.d.ts +156 -0
  11. package/dist/src/api.js +599 -0
  12. package/dist/src/channel.d.ts +11 -0
  13. package/dist/src/channel.js +354 -0
  14. package/dist/src/config.d.ts +25 -0
  15. package/dist/src/config.js +161 -0
  16. package/dist/src/credential-backup.d.ts +31 -0
  17. package/dist/src/credential-backup.js +66 -0
  18. package/dist/src/gateway.d.ts +18 -0
  19. package/dist/src/gateway.js +1265 -0
  20. package/dist/src/image-server.d.ts +68 -0
  21. package/dist/src/image-server.js +462 -0
  22. package/dist/src/inbound-attachments.d.ts +58 -0
  23. package/dist/src/inbound-attachments.js +234 -0
  24. package/dist/src/known-users.d.ts +100 -0
  25. package/dist/src/known-users.js +263 -0
  26. package/dist/src/message-queue.d.ts +50 -0
  27. package/dist/src/message-queue.js +115 -0
  28. package/dist/src/onboarding.d.ts +10 -0
  29. package/dist/src/onboarding.js +203 -0
  30. package/dist/src/outbound-deliver.d.ts +48 -0
  31. package/dist/src/outbound-deliver.js +462 -0
  32. package/dist/src/outbound.d.ts +203 -0
  33. package/dist/src/outbound.js +1102 -0
  34. package/dist/src/proactive.d.ts +170 -0
  35. package/dist/src/proactive.js +399 -0
  36. package/dist/src/ref-index-store.d.ts +70 -0
  37. package/dist/src/ref-index-store.js +273 -0
  38. package/dist/src/reply-dispatcher.d.ts +35 -0
  39. package/dist/src/reply-dispatcher.js +311 -0
  40. package/dist/src/runtime.d.ts +3 -0
  41. package/dist/src/runtime.js +10 -0
  42. package/dist/src/session-store.d.ts +52 -0
  43. package/dist/src/session-store.js +254 -0
  44. package/dist/src/slash-commands.d.ts +71 -0
  45. package/dist/src/slash-commands.js +1179 -0
  46. package/dist/src/startup-greeting.d.ts +30 -0
  47. package/dist/src/startup-greeting.js +78 -0
  48. package/dist/src/stt.d.ts +21 -0
  49. package/dist/src/stt.js +70 -0
  50. package/dist/src/tools/channel.d.ts +16 -0
  51. package/dist/src/tools/channel.js +234 -0
  52. package/dist/src/tools/remind.d.ts +2 -0
  53. package/dist/src/tools/remind.js +247 -0
  54. package/dist/src/types.d.ts +175 -0
  55. package/dist/src/types.js +1 -0
  56. package/dist/src/typing-keepalive.d.ts +27 -0
  57. package/dist/src/typing-keepalive.js +64 -0
  58. package/dist/src/update-checker.d.ts +34 -0
  59. package/dist/src/update-checker.js +166 -0
  60. package/dist/src/user-messages.d.ts +8 -0
  61. package/dist/src/user-messages.js +8 -0
  62. package/dist/src/utils/audio-convert.d.ts +89 -0
  63. package/dist/src/utils/audio-convert.js +704 -0
  64. package/dist/src/utils/file-utils.d.ts +55 -0
  65. package/dist/src/utils/file-utils.js +150 -0
  66. package/dist/src/utils/image-size.d.ts +51 -0
  67. package/dist/src/utils/image-size.js +234 -0
  68. package/dist/src/utils/media-tags.d.ts +14 -0
  69. package/dist/src/utils/media-tags.js +164 -0
  70. package/dist/src/utils/payload.d.ts +112 -0
  71. package/dist/src/utils/payload.js +186 -0
  72. package/dist/src/utils/platform.d.ts +137 -0
  73. package/dist/src/utils/platform.js +390 -0
  74. package/dist/src/utils/text-parsing.d.ts +32 -0
  75. package/dist/src/utils/text-parsing.js +80 -0
  76. package/dist/src/utils/upload-cache.d.ts +34 -0
  77. package/dist/src/utils/upload-cache.js +93 -0
  78. package/index.ts +31 -0
  79. package/moltbot.plugin.json +16 -0
  80. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  81. package/node_modules/@eshaz/web-worker/README.md +134 -0
  82. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  83. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  84. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  85. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  86. package/node_modules/@eshaz/web-worker/node.js +223 -0
  87. package/node_modules/@eshaz/web-worker/package.json +54 -0
  88. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  89. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  90. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  91. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  92. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  93. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  94. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  95. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  96. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  97. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  98. package/node_modules/mpg123-decoder/README.md +265 -0
  99. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  100. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  101. package/node_modules/mpg123-decoder/index.js +8 -0
  102. package/node_modules/mpg123-decoder/package.json +58 -0
  103. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  104. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  105. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  106. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  107. package/node_modules/silk-wasm/LICENSE +21 -0
  108. package/node_modules/silk-wasm/README.md +85 -0
  109. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  110. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  111. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  112. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  113. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  114. package/node_modules/silk-wasm/package.json +39 -0
  115. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  116. package/node_modules/simple-yenc/.prettierignore +1 -0
  117. package/node_modules/simple-yenc/LICENSE +7 -0
  118. package/node_modules/simple-yenc/README.md +163 -0
  119. package/node_modules/simple-yenc/dist/esm.js +1 -0
  120. package/node_modules/simple-yenc/dist/index.js +1 -0
  121. package/node_modules/simple-yenc/package.json +50 -0
  122. package/node_modules/simple-yenc/rollup.config.js +27 -0
  123. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  124. package/node_modules/ws/LICENSE +20 -0
  125. package/node_modules/ws/README.md +548 -0
  126. package/node_modules/ws/browser.js +8 -0
  127. package/node_modules/ws/index.js +13 -0
  128. package/node_modules/ws/lib/buffer-util.js +131 -0
  129. package/node_modules/ws/lib/constants.js +19 -0
  130. package/node_modules/ws/lib/event-target.js +292 -0
  131. package/node_modules/ws/lib/extension.js +203 -0
  132. package/node_modules/ws/lib/limiter.js +55 -0
  133. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  134. package/node_modules/ws/lib/receiver.js +706 -0
  135. package/node_modules/ws/lib/sender.js +602 -0
  136. package/node_modules/ws/lib/stream.js +161 -0
  137. package/node_modules/ws/lib/subprotocol.js +62 -0
  138. package/node_modules/ws/lib/validation.js +152 -0
  139. package/node_modules/ws/lib/websocket-server.js +554 -0
  140. package/node_modules/ws/lib/websocket.js +1393 -0
  141. package/node_modules/ws/package.json +69 -0
  142. package/node_modules/ws/wrapper.mjs +8 -0
  143. package/openclaw.plugin.json +16 -0
  144. package/package.json +76 -0
  145. package/scripts/cleanup-legacy-plugins.sh +124 -0
  146. package/scripts/proactive-api-server.ts +369 -0
  147. package/scripts/send-proactive.ts +293 -0
  148. package/scripts/set-markdown.sh +156 -0
  149. package/scripts/test-sendmedia.ts +116 -0
  150. package/scripts/upgrade-via-alt-pkg.sh +307 -0
  151. package/scripts/upgrade-via-npm.ps1 +296 -0
  152. package/scripts/upgrade-via-npm.sh +301 -0
  153. package/scripts/upgrade-via-source.sh +774 -0
  154. package/skills/qqbot-channel/SKILL.md +263 -0
  155. package/skills/qqbot-channel/references/api_references.md +521 -0
  156. package/skills/qqbot-media/SKILL.md +56 -0
  157. package/skills/qqbot-remind/SKILL.md +149 -0
  158. package/src/admin-resolver.ts +140 -0
  159. package/src/api.ts +819 -0
  160. package/src/bot-logs-2026-03-21T11-21-47(2).txt +46 -0
  161. package/src/channel.ts +381 -0
  162. package/src/config.ts +187 -0
  163. package/src/credential-backup.ts +72 -0
  164. package/src/gateway.log +43 -0
  165. package/src/gateway.ts +1404 -0
  166. package/src/image-server.ts +539 -0
  167. package/src/inbound-attachments.ts +304 -0
  168. package/src/known-users.ts +353 -0
  169. package/src/message-queue.ts +169 -0
  170. package/src/onboarding.ts +274 -0
  171. package/src/openclaw-2026-03-21.log +3729 -0
  172. package/src/openclaw-plugin-sdk.d.ts +522 -0
  173. package/src/outbound-deliver.ts +552 -0
  174. package/src/outbound.ts +1266 -0
  175. package/src/proactive.ts +530 -0
  176. package/src/ref-index-store.ts +357 -0
  177. package/src/reply-dispatcher.ts +334 -0
  178. package/src/runtime.ts +14 -0
  179. package/src/session-store.ts +303 -0
  180. package/src/slash-commands.ts +1305 -0
  181. package/src/startup-greeting.ts +98 -0
  182. package/src/stt.ts +86 -0
  183. package/src/tools/channel.ts +281 -0
  184. package/src/tools/remind.ts +296 -0
  185. package/src/types.ts +183 -0
  186. package/src/typing-keepalive.ts +59 -0
  187. package/src/update-checker.ts +179 -0
  188. package/src/user-messages.ts +7 -0
  189. package/src/utils/audio-convert.ts +803 -0
  190. package/src/utils/file-utils.ts +167 -0
  191. package/src/utils/image-size.ts +266 -0
  192. package/src/utils/media-tags.ts +182 -0
  193. package/src/utils/payload.ts +265 -0
  194. package/src/utils/platform.ts +435 -0
  195. package/src/utils/text-parsing.ts +82 -0
  196. package/src/utils/upload-cache.ts +128 -0
  197. package/tsconfig.json +16 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * 入站附件处理模块
3
+ *
4
+ * 负责下载、转换、转录用户发送的附件(图片/语音/文件),
5
+ * 并归类为统一的 ProcessedAttachments 结构供 gateway 消费。
6
+ */
7
+ import { downloadFile } from "./image-server.js";
8
+ import { convertSilkToWav, isVoiceAttachment, formatDuration } from "./utils/audio-convert.js";
9
+ import { transcribeAudio, resolveSTTConfig } from "./stt.js";
10
+ import { getQQBotMediaDir } from "./utils/platform.js";
11
+ // ============ 空结果常量 ============
12
+ const EMPTY_RESULT = {
13
+ attachmentInfo: "",
14
+ imageUrls: [],
15
+ imageMediaTypes: [],
16
+ voiceAttachmentPaths: [],
17
+ voiceAttachmentUrls: [],
18
+ voiceAsrReferTexts: [],
19
+ voiceTranscripts: [],
20
+ voiceTranscriptSources: [],
21
+ attachmentLocalPaths: [],
22
+ };
23
+ // ============ 主函数 ============
24
+ /**
25
+ * 处理入站消息的附件列表。
26
+ *
27
+ * 三阶段流水线:
28
+ * 1. 并行下载所有附件到本地
29
+ * 2. 并行处理语音转换 + STT 转录
30
+ * 3. 按原始顺序归类结果
31
+ */
32
+ export async function processAttachments(attachments, ctx) {
33
+ if (!attachments?.length)
34
+ return EMPTY_RESULT;
35
+ const { accountId, cfg, log } = ctx;
36
+ const downloadDir = getQQBotMediaDir("downloads");
37
+ const prefix = `[qqbot:${accountId}]`;
38
+ // 结果收集
39
+ const imageUrls = [];
40
+ const imageMediaTypes = [];
41
+ const voiceAttachmentPaths = [];
42
+ const voiceAttachmentUrls = [];
43
+ const voiceAsrReferTexts = [];
44
+ const voiceTranscripts = [];
45
+ const voiceTranscriptSources = [];
46
+ const attachmentLocalPaths = [];
47
+ const otherAttachments = [];
48
+ // Phase 1: 并行下载所有附件
49
+ const downloadTasks = attachments.map(async (att) => {
50
+ const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
51
+ const isVoice = isVoiceAttachment(att);
52
+ const wavUrl = isVoice && att.voice_wav_url
53
+ ? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
54
+ : "";
55
+ let localPath = null;
56
+ let audioPath = null;
57
+ if (isVoice && wavUrl) {
58
+ const wavLocalPath = await downloadFile(wavUrl, downloadDir);
59
+ if (wavLocalPath) {
60
+ localPath = wavLocalPath;
61
+ audioPath = wavLocalPath;
62
+ log?.info(`${prefix} Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`);
63
+ }
64
+ else {
65
+ log?.error(`${prefix} Failed to download voice_wav_url, falling back to original URL`);
66
+ }
67
+ }
68
+ if (!localPath) {
69
+ localPath = await downloadFile(attUrl, downloadDir, att.filename);
70
+ }
71
+ return { att, attUrl, isVoice, localPath, audioPath };
72
+ });
73
+ const downloadResults = await Promise.all(downloadTasks);
74
+ // Phase 2: 并行处理语音转换 + 转录(非语音附件同步归类)
75
+ const processTasks = downloadResults.map(async ({ att, attUrl, isVoice, localPath, audioPath }) => {
76
+ const asrReferText = typeof att.asr_refer_text === "string" ? att.asr_refer_text.trim() : "";
77
+ const wavUrl = isVoice && att.voice_wav_url
78
+ ? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
79
+ : "";
80
+ const voiceSourceUrl = wavUrl || attUrl;
81
+ const meta = {
82
+ voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : undefined,
83
+ asrReferText: isVoice && asrReferText ? asrReferText : undefined,
84
+ };
85
+ if (localPath) {
86
+ if (att.content_type?.startsWith("image/")) {
87
+ log?.info(`${prefix} Downloaded attachment to: ${localPath}`);
88
+ return { localPath, type: "image", contentType: att.content_type, meta };
89
+ }
90
+ else if (isVoice) {
91
+ log?.info(`${prefix} Downloaded attachment to: ${localPath}`);
92
+ return processVoiceAttachment(localPath, audioPath, att, asrReferText, cfg, downloadDir, log, prefix);
93
+ }
94
+ else {
95
+ log?.info(`${prefix} Downloaded attachment to: ${localPath}`);
96
+ return { localPath, type: "other", filename: att.filename, meta };
97
+ }
98
+ }
99
+ else {
100
+ log?.error(`${prefix} Failed to download: ${attUrl}`);
101
+ if (att.content_type?.startsWith("image/")) {
102
+ return { localPath: null, type: "image-fallback", attUrl, contentType: att.content_type, meta };
103
+ }
104
+ else if (isVoice && asrReferText) {
105
+ log?.info(`${prefix} Voice attachment download failed, using asr_refer_text fallback`);
106
+ return { localPath: null, type: "voice-fallback", transcript: asrReferText, meta };
107
+ }
108
+ else {
109
+ return { localPath: null, type: "other-fallback", filename: att.filename ?? att.content_type, meta };
110
+ }
111
+ }
112
+ });
113
+ const processResults = await Promise.all(processTasks);
114
+ // Phase 3: 按原始顺序归类结果
115
+ for (const result of processResults) {
116
+ if (result.meta.voiceUrl)
117
+ voiceAttachmentUrls.push(result.meta.voiceUrl);
118
+ if (result.meta.asrReferText)
119
+ voiceAsrReferTexts.push(result.meta.asrReferText);
120
+ if (result.type === "image" && result.localPath) {
121
+ imageUrls.push(result.localPath);
122
+ imageMediaTypes.push(result.contentType);
123
+ attachmentLocalPaths.push(result.localPath);
124
+ }
125
+ else if (result.type === "voice" && result.localPath) {
126
+ voiceAttachmentPaths.push(result.localPath);
127
+ voiceTranscripts.push(result.transcript);
128
+ voiceTranscriptSources.push(result.transcriptSource);
129
+ attachmentLocalPaths.push(result.localPath);
130
+ }
131
+ else if (result.type === "other" && result.localPath) {
132
+ otherAttachments.push(`[附件: ${result.localPath}]`);
133
+ attachmentLocalPaths.push(result.localPath);
134
+ }
135
+ else if (result.type === "image-fallback") {
136
+ imageUrls.push(result.attUrl);
137
+ imageMediaTypes.push(result.contentType);
138
+ attachmentLocalPaths.push(null);
139
+ }
140
+ else if (result.type === "voice-fallback") {
141
+ voiceTranscripts.push(result.transcript);
142
+ voiceTranscriptSources.push("asr");
143
+ attachmentLocalPaths.push(null);
144
+ }
145
+ else if (result.type === "other-fallback") {
146
+ otherAttachments.push(`[附件: ${result.filename}] (下载失败)`);
147
+ attachmentLocalPaths.push(null);
148
+ }
149
+ }
150
+ const attachmentInfo = otherAttachments.length > 0 ? "\n" + otherAttachments.join("\n") : "";
151
+ return {
152
+ attachmentInfo,
153
+ imageUrls,
154
+ imageMediaTypes,
155
+ voiceAttachmentPaths,
156
+ voiceAttachmentUrls,
157
+ voiceAsrReferTexts,
158
+ voiceTranscripts,
159
+ voiceTranscriptSources,
160
+ attachmentLocalPaths,
161
+ };
162
+ }
163
+ /**
164
+ * 将语音转录结果组装为用户消息中的文本片段。
165
+ */
166
+ export function formatVoiceText(transcripts) {
167
+ if (transcripts.length === 0)
168
+ return "";
169
+ return transcripts.length === 1
170
+ ? `[语音消息] ${transcripts[0]}`
171
+ : transcripts.map((t, i) => `[语音${i + 1}] ${t}`).join("\n");
172
+ }
173
+ async function processVoiceAttachment(localPath, audioPath, att, asrReferText, cfg, downloadDir, log, prefix) {
174
+ const wavUrl = att.voice_wav_url
175
+ ? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
176
+ : "";
177
+ const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
178
+ const voiceSourceUrl = wavUrl || attUrl;
179
+ const meta = {
180
+ voiceUrl: voiceSourceUrl || undefined,
181
+ asrReferText: asrReferText || undefined,
182
+ };
183
+ const sttCfg = resolveSTTConfig(cfg);
184
+ if (!sttCfg) {
185
+ if (asrReferText) {
186
+ log?.info(`${prefix} Voice attachment: ${att.filename} (STT not configured, using asr_refer_text fallback)`);
187
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
188
+ }
189
+ log?.info(`${prefix} Voice attachment: ${att.filename} (STT not configured, skipping transcription)`);
190
+ return { localPath, type: "voice", transcript: "[语音消息 - 语音识别未配置,无法转录]", transcriptSource: "fallback", meta };
191
+ }
192
+ // SILK→WAV 转换
193
+ if (!audioPath) {
194
+ log?.info(`${prefix} Voice attachment: ${att.filename}, converting SILK→WAV...`);
195
+ try {
196
+ const wavResult = await convertSilkToWav(localPath, downloadDir);
197
+ if (wavResult) {
198
+ audioPath = wavResult.wavPath;
199
+ log?.info(`${prefix} Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`);
200
+ }
201
+ else {
202
+ audioPath = localPath;
203
+ }
204
+ }
205
+ catch (convertErr) {
206
+ log?.error(`${prefix} Voice conversion failed: ${convertErr}`);
207
+ if (asrReferText) {
208
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
209
+ }
210
+ return { localPath, type: "voice", transcript: "[语音消息 - 格式转换失败]", transcriptSource: "fallback", meta };
211
+ }
212
+ }
213
+ // STT 转录
214
+ try {
215
+ const transcript = await transcribeAudio(audioPath, cfg);
216
+ if (transcript) {
217
+ log?.info(`${prefix} STT transcript: ${transcript.slice(0, 100)}...`);
218
+ return { localPath, type: "voice", transcript, transcriptSource: "stt", meta };
219
+ }
220
+ if (asrReferText) {
221
+ log?.info(`${prefix} STT returned empty result, using asr_refer_text fallback`);
222
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
223
+ }
224
+ log?.info(`${prefix} STT returned empty result`);
225
+ return { localPath, type: "voice", transcript: "[语音消息 - 转录结果为空]", transcriptSource: "fallback", meta };
226
+ }
227
+ catch (sttErr) {
228
+ log?.error(`${prefix} STT failed: ${sttErr}`);
229
+ if (asrReferText) {
230
+ return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
231
+ }
232
+ return { localPath, type: "voice", transcript: "[语音消息 - 转录失败]", transcriptSource: "fallback", meta };
233
+ }
234
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 已知用户存储
3
+ * 记录与机器人交互过的所有用户
4
+ * 支持主动消息和批量通知功能
5
+ */
6
+ export interface KnownUser {
7
+ /** 用户 openid(唯一标识) */
8
+ openid: string;
9
+ /** 消息类型:私聊用户 / 群组 */
10
+ type: "c2c" | "group";
11
+ /** 用户昵称(如有) */
12
+ nickname?: string;
13
+ /** 群组 openid(如果是群消息) */
14
+ groupOpenid?: string;
15
+ /** 关联的机器人账户 ID */
16
+ accountId: string;
17
+ /** 首次交互时间戳 */
18
+ firstSeenAt: number;
19
+ /** 最后交互时间戳 */
20
+ lastSeenAt: number;
21
+ /** 交互次数 */
22
+ interactionCount: number;
23
+ }
24
+ /**
25
+ * 强制立即保存(用于进程退出前)
26
+ */
27
+ export declare function flushKnownUsers(): void;
28
+ /**
29
+ * 记录已知用户(收到消息时调用)
30
+ * @param user 用户信息(部分字段)
31
+ */
32
+ export declare function recordKnownUser(user: {
33
+ openid: string;
34
+ type: "c2c" | "group";
35
+ nickname?: string;
36
+ groupOpenid?: string;
37
+ accountId: string;
38
+ }): void;
39
+ /**
40
+ * 获取单个用户信息
41
+ * @param accountId 机器人账户 ID
42
+ * @param openid 用户 openid
43
+ * @param type 消息类型
44
+ * @param groupOpenid 群组 openid(可选)
45
+ */
46
+ export declare function getKnownUser(accountId: string, openid: string, type?: "c2c" | "group", groupOpenid?: string): KnownUser | undefined;
47
+ /**
48
+ * 列出所有已知用户
49
+ * @param options 筛选选项
50
+ */
51
+ export declare function listKnownUsers(options?: {
52
+ /** 筛选特定机器人账户的用户 */
53
+ accountId?: string;
54
+ /** 筛选消息类型 */
55
+ type?: "c2c" | "group";
56
+ /** 最近活跃时间(毫秒,如 86400000 表示最近 24 小时) */
57
+ activeWithin?: number;
58
+ /** 返回数量限制 */
59
+ limit?: number;
60
+ /** 排序方式 */
61
+ sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount";
62
+ /** 排序方向 */
63
+ sortOrder?: "asc" | "desc";
64
+ }): KnownUser[];
65
+ /**
66
+ * 获取用户统计信息
67
+ * @param accountId 机器人账户 ID(可选,不传则返回所有账户的统计)
68
+ */
69
+ export declare function getKnownUsersStats(accountId?: string): {
70
+ totalUsers: number;
71
+ c2cUsers: number;
72
+ groupUsers: number;
73
+ activeIn24h: number;
74
+ activeIn7d: number;
75
+ };
76
+ /**
77
+ * 删除用户记录
78
+ * @param accountId 机器人账户 ID
79
+ * @param openid 用户 openid
80
+ * @param type 消息类型
81
+ * @param groupOpenid 群组 openid(可选)
82
+ */
83
+ export declare function removeKnownUser(accountId: string, openid: string, type?: "c2c" | "group", groupOpenid?: string): boolean;
84
+ /**
85
+ * 清除所有用户记录
86
+ * @param accountId 机器人账户 ID(可选,不传则清除所有)
87
+ */
88
+ export declare function clearKnownUsers(accountId?: string): number;
89
+ /**
90
+ * 获取用户的所有群组(某用户在哪些群里交互过)
91
+ * @param accountId 机器人账户 ID
92
+ * @param openid 用户 openid
93
+ */
94
+ export declare function getUserGroups(accountId: string, openid: string): string[];
95
+ /**
96
+ * 获取群组的所有成员
97
+ * @param accountId 机器人账户 ID
98
+ * @param groupOpenid 群组 openid
99
+ */
100
+ export declare function getGroupMembers(accountId: string, groupOpenid: string): KnownUser[];
@@ -0,0 +1,263 @@
1
+ /**
2
+ * 已知用户存储
3
+ * 记录与机器人交互过的所有用户
4
+ * 支持主动消息和批量通知功能
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { getQQBotDataDir } from "./utils/platform.js";
9
+ // 存储文件路径
10
+ const KNOWN_USERS_DIR = getQQBotDataDir("data");
11
+ const KNOWN_USERS_FILE = path.join(KNOWN_USERS_DIR, "known-users.json");
12
+ // 内存缓存
13
+ let usersCache = null;
14
+ // 写入节流配置
15
+ const SAVE_THROTTLE_MS = 5000; // 5秒写入一次
16
+ let saveTimer = null;
17
+ let isDirty = false;
18
+ /**
19
+ * 确保目录存在
20
+ */
21
+ function ensureDir() {
22
+ if (!fs.existsSync(KNOWN_USERS_DIR)) {
23
+ fs.mkdirSync(KNOWN_USERS_DIR, { recursive: true });
24
+ }
25
+ }
26
+ /**
27
+ * 从文件加载用户数据到缓存
28
+ */
29
+ function loadUsersFromFile() {
30
+ if (usersCache !== null) {
31
+ return usersCache;
32
+ }
33
+ usersCache = new Map();
34
+ try {
35
+ if (fs.existsSync(KNOWN_USERS_FILE)) {
36
+ const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
37
+ const users = JSON.parse(data);
38
+ for (const user of users) {
39
+ // 使用复合键:accountId + type + openid(群组还要加 groupOpenid)
40
+ const key = makeUserKey(user);
41
+ usersCache.set(key, user);
42
+ }
43
+ console.log(`[known-users] Loaded ${usersCache.size} users`);
44
+ }
45
+ }
46
+ catch (err) {
47
+ console.error(`[known-users] Failed to load users: ${err}`);
48
+ usersCache = new Map();
49
+ }
50
+ return usersCache;
51
+ }
52
+ /**
53
+ * 保存用户数据到文件(节流版本)
54
+ */
55
+ function saveUsersToFile() {
56
+ if (!isDirty)
57
+ return;
58
+ if (saveTimer) {
59
+ return; // 已有定时器在等待
60
+ }
61
+ saveTimer = setTimeout(() => {
62
+ saveTimer = null;
63
+ doSaveUsersToFile();
64
+ }, SAVE_THROTTLE_MS);
65
+ }
66
+ /**
67
+ * 实际执行保存
68
+ */
69
+ function doSaveUsersToFile() {
70
+ if (!usersCache || !isDirty)
71
+ return;
72
+ try {
73
+ ensureDir();
74
+ const users = Array.from(usersCache.values());
75
+ fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(users, null, 2), "utf-8");
76
+ isDirty = false;
77
+ }
78
+ catch (err) {
79
+ console.error(`[known-users] Failed to save users: ${err}`);
80
+ }
81
+ }
82
+ /**
83
+ * 强制立即保存(用于进程退出前)
84
+ */
85
+ export function flushKnownUsers() {
86
+ if (saveTimer) {
87
+ clearTimeout(saveTimer);
88
+ saveTimer = null;
89
+ }
90
+ doSaveUsersToFile();
91
+ }
92
+ /**
93
+ * 生成用户唯一键
94
+ */
95
+ function makeUserKey(user) {
96
+ const base = `${user.accountId}:${user.type}:${user.openid}`;
97
+ if (user.type === "group" && user.groupOpenid) {
98
+ return `${base}:${user.groupOpenid}`;
99
+ }
100
+ return base;
101
+ }
102
+ /**
103
+ * 记录已知用户(收到消息时调用)
104
+ * @param user 用户信息(部分字段)
105
+ */
106
+ export function recordKnownUser(user) {
107
+ const cache = loadUsersFromFile();
108
+ const key = makeUserKey(user);
109
+ const now = Date.now();
110
+ const existing = cache.get(key);
111
+ if (existing) {
112
+ // 更新已存在的用户
113
+ existing.lastSeenAt = now;
114
+ existing.interactionCount++;
115
+ if (user.nickname && user.nickname !== existing.nickname) {
116
+ existing.nickname = user.nickname;
117
+ }
118
+ }
119
+ else {
120
+ // 新用户
121
+ const newUser = {
122
+ openid: user.openid,
123
+ type: user.type,
124
+ nickname: user.nickname,
125
+ groupOpenid: user.groupOpenid,
126
+ accountId: user.accountId,
127
+ firstSeenAt: now,
128
+ lastSeenAt: now,
129
+ interactionCount: 1,
130
+ };
131
+ cache.set(key, newUser);
132
+ console.log(`[known-users] New user: ${user.openid} (${user.type})`);
133
+ }
134
+ isDirty = true;
135
+ saveUsersToFile();
136
+ }
137
+ /**
138
+ * 获取单个用户信息
139
+ * @param accountId 机器人账户 ID
140
+ * @param openid 用户 openid
141
+ * @param type 消息类型
142
+ * @param groupOpenid 群组 openid(可选)
143
+ */
144
+ export function getKnownUser(accountId, openid, type = "c2c", groupOpenid) {
145
+ const cache = loadUsersFromFile();
146
+ const key = makeUserKey({ accountId, openid, type, groupOpenid });
147
+ return cache.get(key);
148
+ }
149
+ /**
150
+ * 列出所有已知用户
151
+ * @param options 筛选选项
152
+ */
153
+ export function listKnownUsers(options) {
154
+ const cache = loadUsersFromFile();
155
+ let users = Array.from(cache.values());
156
+ // 筛选
157
+ if (options?.accountId) {
158
+ users = users.filter(u => u.accountId === options.accountId);
159
+ }
160
+ if (options?.type) {
161
+ users = users.filter(u => u.type === options.type);
162
+ }
163
+ if (options?.activeWithin) {
164
+ const cutoff = Date.now() - options.activeWithin;
165
+ users = users.filter(u => u.lastSeenAt >= cutoff);
166
+ }
167
+ // 排序
168
+ const sortBy = options?.sortBy ?? "lastSeenAt";
169
+ const sortOrder = options?.sortOrder ?? "desc";
170
+ users.sort((a, b) => {
171
+ const aVal = a[sortBy] ?? 0;
172
+ const bVal = b[sortBy] ?? 0;
173
+ return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
174
+ });
175
+ // 限制数量
176
+ if (options?.limit && options.limit > 0) {
177
+ users = users.slice(0, options.limit);
178
+ }
179
+ return users;
180
+ }
181
+ /**
182
+ * 获取用户统计信息
183
+ * @param accountId 机器人账户 ID(可选,不传则返回所有账户的统计)
184
+ */
185
+ export function getKnownUsersStats(accountId) {
186
+ let users = listKnownUsers({ accountId });
187
+ const now = Date.now();
188
+ const day = 24 * 60 * 60 * 1000;
189
+ return {
190
+ totalUsers: users.length,
191
+ c2cUsers: users.filter(u => u.type === "c2c").length,
192
+ groupUsers: users.filter(u => u.type === "group").length,
193
+ activeIn24h: users.filter(u => now - u.lastSeenAt < day).length,
194
+ activeIn7d: users.filter(u => now - u.lastSeenAt < 7 * day).length,
195
+ };
196
+ }
197
+ /**
198
+ * 删除用户记录
199
+ * @param accountId 机器人账户 ID
200
+ * @param openid 用户 openid
201
+ * @param type 消息类型
202
+ * @param groupOpenid 群组 openid(可选)
203
+ */
204
+ export function removeKnownUser(accountId, openid, type = "c2c", groupOpenid) {
205
+ const cache = loadUsersFromFile();
206
+ const key = makeUserKey({ accountId, openid, type, groupOpenid });
207
+ if (cache.has(key)) {
208
+ cache.delete(key);
209
+ isDirty = true;
210
+ saveUsersToFile();
211
+ console.log(`[known-users] Removed user ${openid}`);
212
+ return true;
213
+ }
214
+ return false;
215
+ }
216
+ /**
217
+ * 清除所有用户记录
218
+ * @param accountId 机器人账户 ID(可选,不传则清除所有)
219
+ */
220
+ export function clearKnownUsers(accountId) {
221
+ const cache = loadUsersFromFile();
222
+ let count = 0;
223
+ if (accountId) {
224
+ // 只清除指定账户的用户
225
+ for (const [key, user] of cache.entries()) {
226
+ if (user.accountId === accountId) {
227
+ cache.delete(key);
228
+ count++;
229
+ }
230
+ }
231
+ }
232
+ else {
233
+ // 清除所有
234
+ count = cache.size;
235
+ cache.clear();
236
+ }
237
+ if (count > 0) {
238
+ isDirty = true;
239
+ doSaveUsersToFile(); // 立即保存
240
+ console.log(`[known-users] Cleared ${count} users`);
241
+ }
242
+ return count;
243
+ }
244
+ /**
245
+ * 获取用户的所有群组(某用户在哪些群里交互过)
246
+ * @param accountId 机器人账户 ID
247
+ * @param openid 用户 openid
248
+ */
249
+ export function getUserGroups(accountId, openid) {
250
+ const users = listKnownUsers({ accountId, type: "group" });
251
+ return users
252
+ .filter(u => u.openid === openid && u.groupOpenid)
253
+ .map(u => u.groupOpenid);
254
+ }
255
+ /**
256
+ * 获取群组的所有成员
257
+ * @param accountId 机器人账户 ID
258
+ * @param groupOpenid 群组 openid
259
+ */
260
+ export function getGroupMembers(accountId, groupOpenid) {
261
+ return listKnownUsers({ accountId, type: "group" })
262
+ .filter(u => u.groupOpenid === groupOpenid);
263
+ }
@@ -0,0 +1,50 @@
1
+ import type { QueueSnapshot } from "./slash-commands.js";
2
+ /**
3
+ * 消息队列项类型(用于异步处理消息,防止阻塞心跳)
4
+ */
5
+ export interface QueuedMessage {
6
+ type: "c2c" | "guild" | "dm" | "group";
7
+ senderId: string;
8
+ senderName?: string;
9
+ content: string;
10
+ messageId: string;
11
+ timestamp: string;
12
+ channelId?: string;
13
+ guildId?: string;
14
+ groupOpenid?: string;
15
+ attachments?: Array<{
16
+ content_type: string;
17
+ url: string;
18
+ filename?: string;
19
+ voice_wav_url?: string;
20
+ asr_refer_text?: string;
21
+ }>;
22
+ /** 被引用消息的 refIdx(用户引用了哪条历史消息) */
23
+ refMsgIdx?: string;
24
+ /** 当前消息自身的 refIdx(供将来被引用) */
25
+ msgIdx?: string;
26
+ }
27
+ export interface MessageQueueContext {
28
+ accountId: string;
29
+ log?: {
30
+ info: (msg: string) => void;
31
+ error: (msg: string) => void;
32
+ debug?: (msg: string) => void;
33
+ };
34
+ /** 外部提供的 abort 状态检查 */
35
+ isAborted: () => boolean;
36
+ }
37
+ export interface MessageQueue {
38
+ enqueue: (msg: QueuedMessage) => void;
39
+ startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise<void>) => void;
40
+ getSnapshot: (senderPeerId: string) => QueueSnapshot;
41
+ getMessagePeerId: (msg: QueuedMessage) => string;
42
+ /** 清空指定用户的排队消息,返回被丢弃的消息数 */
43
+ clearUserQueue: (peerId: string) => number;
44
+ /** 立即执行一条消息(绕过队列),用于紧急命令 */
45
+ executeImmediate: (msg: QueuedMessage) => void;
46
+ }
47
+ /**
48
+ * 创建按用户并发的消息队列(同用户串行,跨用户并行)
49
+ */
50
+ export declare function createMessageQueue(ctx: MessageQueueContext): MessageQueue;