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