@tencent-connect/openclaw-qqbot 1.6.4-alpha.9 → 1.6.4
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/README.md +45 -8
- package/README.zh.md +45 -8
- package/clawdbot.plugin.json +1 -1
- package/dist/index.js +2 -0
- package/dist/src/admin-resolver.d.ts +27 -0
- package/dist/src/admin-resolver.js +122 -0
- package/dist/src/channel.js +37 -2
- package/dist/src/credential-backup.d.ts +31 -0
- package/dist/src/credential-backup.js +66 -0
- package/dist/src/gateway.js +101 -1515
- package/dist/src/inbound-attachments.d.ts +58 -0
- package/dist/src/inbound-attachments.js +234 -0
- package/dist/src/message-queue.d.ts +50 -0
- package/dist/src/message-queue.js +115 -0
- package/dist/src/outbound-deliver.d.ts +48 -0
- package/dist/src/outbound-deliver.js +462 -0
- package/dist/src/outbound.js +3 -3
- package/dist/src/reply-dispatcher.d.ts +35 -0
- package/dist/src/reply-dispatcher.js +311 -0
- package/dist/src/slash-commands.js +604 -81
- 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/remind.d.ts +2 -0
- package/dist/src/tools/remind.js +247 -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 +11 -4
- package/dist/src/update-checker.js +42 -41
- package/dist/src/utils/file-utils.d.ts +9 -0
- package/dist/src/utils/file-utils.js +43 -0
- package/dist/src/utils/media-tags.js +6 -0
- package/dist/src/utils/platform.d.ts +10 -0
- package/dist/src/utils/platform.js +16 -0
- package/dist/src/utils/text-parsing.d.ts +32 -0
- package/dist/src/utils/text-parsing.js +80 -0
- package/index.ts +2 -0
- package/moltbot.plugin.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/cleanup-legacy-plugins.sh +3 -6
- package/scripts/upgrade-via-alt-pkg.sh +307 -0
- package/scripts/upgrade-via-npm.ps1 +296 -0
- package/scripts/upgrade-via-source.sh +50 -0
- package/skills/{qqbot-cron → qqbot-remind}/SKILL.md +40 -20
- package/src/admin-resolver.ts +140 -0
- package/src/channel.ts +36 -2
- package/src/credential-backup.ts +72 -0
- package/src/gateway.ts +124 -1589
- package/src/inbound-attachments.ts +304 -0
- package/src/message-queue.ts +169 -0
- package/src/outbound-deliver.ts +552 -0
- package/src/outbound.ts +3 -3
- package/src/reply-dispatcher.ts +334 -0
- package/src/slash-commands.ts +612 -81
- package/src/startup-greeting.ts +98 -0
- package/src/stt.ts +86 -0
- package/src/tools/remind.ts +296 -0
- package/src/typing-keepalive.ts +59 -0
- package/src/update-checker.ts +49 -42
- package/src/utils/file-utils.ts +45 -0
- package/src/utils/media-tags.ts +6 -0
- package/src/utils/platform.ts +17 -0
- package/src/utils/text-parsing.ts +82 -0
package/dist/src/gateway.js
CHANGED
|
@@ -1,75 +1,23 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, sendProactiveC2CMessage } from "./api.js";
|
|
3
|
+
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT } from "./api.js";
|
|
5
4
|
import { loadSession, saveSession, clearSession } from "./session-store.js";
|
|
6
|
-
import { recordKnownUser, flushKnownUsers
|
|
5
|
+
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
|
7
6
|
import { getQQBotRuntime } from "./runtime.js";
|
|
8
7
|
import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex } from "./ref-index-store.js";
|
|
9
|
-
import { matchSlashCommand
|
|
8
|
+
import { matchSlashCommand } from "./slash-commands.js";
|
|
9
|
+
import { createMessageQueue } from "./message-queue.js";
|
|
10
10
|
import { triggerUpdateCheck } from "./update-checker.js";
|
|
11
|
-
import { startImageServer, isImageServerRunning
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
const c = cfg;
|
|
22
|
-
// 优先使用 channels.qqbot.stt(插件专属配置)
|
|
23
|
-
const channelStt = c?.channels?.qqbot?.stt;
|
|
24
|
-
if (channelStt && channelStt.enabled !== false) {
|
|
25
|
-
const providerId = channelStt?.provider || "openai";
|
|
26
|
-
const providerCfg = c?.models?.providers?.[providerId];
|
|
27
|
-
const baseUrl = channelStt?.baseUrl || providerCfg?.baseUrl;
|
|
28
|
-
const apiKey = channelStt?.apiKey || providerCfg?.apiKey;
|
|
29
|
-
const model = channelStt?.model || "whisper-1";
|
|
30
|
-
if (baseUrl && apiKey) {
|
|
31
|
-
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
// 回退到 tools.media.audio.models[0](框架级配置)
|
|
35
|
-
const audioModelEntry = c?.tools?.media?.audio?.models?.[0];
|
|
36
|
-
if (audioModelEntry) {
|
|
37
|
-
const providerId = audioModelEntry?.provider || "openai";
|
|
38
|
-
const providerCfg = c?.models?.providers?.[providerId];
|
|
39
|
-
const baseUrl = audioModelEntry?.baseUrl || providerCfg?.baseUrl;
|
|
40
|
-
const apiKey = audioModelEntry?.apiKey || providerCfg?.apiKey;
|
|
41
|
-
const model = audioModelEntry?.model || "whisper-1";
|
|
42
|
-
if (baseUrl && apiKey) {
|
|
43
|
-
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
async function transcribeAudio(audioPath, cfg) {
|
|
49
|
-
const sttCfg = resolveSTTConfig(cfg);
|
|
50
|
-
if (!sttCfg)
|
|
51
|
-
return null;
|
|
52
|
-
const fileBuffer = fs.readFileSync(audioPath);
|
|
53
|
-
const fileName = sanitizeFileName(path.basename(audioPath));
|
|
54
|
-
const mime = fileName.endsWith(".wav") ? "audio/wav"
|
|
55
|
-
: fileName.endsWith(".mp3") ? "audio/mpeg"
|
|
56
|
-
: fileName.endsWith(".ogg") ? "audio/ogg"
|
|
57
|
-
: "application/octet-stream";
|
|
58
|
-
const form = new FormData();
|
|
59
|
-
form.append("file", new Blob([fileBuffer], { type: mime }), fileName);
|
|
60
|
-
form.append("model", sttCfg.model);
|
|
61
|
-
const resp = await fetch(`${sttCfg.baseUrl}/audio/transcriptions`, {
|
|
62
|
-
method: "POST",
|
|
63
|
-
headers: { "Authorization": `Bearer ${sttCfg.apiKey}` },
|
|
64
|
-
body: form,
|
|
65
|
-
});
|
|
66
|
-
if (!resp.ok) {
|
|
67
|
-
const detail = await resp.text().catch(() => "");
|
|
68
|
-
throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
|
|
69
|
-
}
|
|
70
|
-
const result = await resp.json();
|
|
71
|
-
return result.text?.trim() || null;
|
|
72
|
-
}
|
|
11
|
+
import { startImageServer, isImageServerRunning } from "./image-server.js";
|
|
12
|
+
import { resolveTTSConfig } from "./utils/audio-convert.js";
|
|
13
|
+
import { processAttachments, formatVoiceText } from "./inbound-attachments.js";
|
|
14
|
+
import { getQQBotDataDir, runDiagnostics } from "./utils/platform.js";
|
|
15
|
+
import { sendDocument, sendMedia as sendMediaAuto } from "./outbound.js";
|
|
16
|
+
import { parseFaceTags, parseRefIndices, buildAttachmentSummaries } from "./utils/text-parsing.js";
|
|
17
|
+
import { sendStartupGreetings } from "./admin-resolver.js";
|
|
18
|
+
import { sendWithTokenRetry, sendErrorToTarget, handleStructuredPayload } from "./reply-dispatcher.js";
|
|
19
|
+
import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
|
|
20
|
+
import { parseAndSendMediaTags, sendPlainReply } from "./outbound-deliver.js";
|
|
73
21
|
// QQ Bot intents - 按权限级别分组
|
|
74
22
|
const INTENTS = {
|
|
75
23
|
// 基础权限(默认有)
|
|
@@ -93,147 +41,6 @@ const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开
|
|
|
93
41
|
const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765", 10);
|
|
94
42
|
// 使用绝对路径,确保文件保存和读取使用同一目录
|
|
95
43
|
const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || getQQBotDataDir("images");
|
|
96
|
-
// 消息队列配置(异步处理,防止阻塞心跳)
|
|
97
|
-
const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度(全局总量)
|
|
98
|
-
const PER_USER_QUEUE_SIZE = 20; // 单用户最大排队数
|
|
99
|
-
const MAX_CONCURRENT_USERS = 10; // 最大同时处理的用户数
|
|
100
|
-
// ============ 消息回复限流器 ============
|
|
101
|
-
// 同一 message_id 1小时内最多回复 4 次,超过1小时需降级为主动消息
|
|
102
|
-
const MESSAGE_REPLY_LIMIT = 4;
|
|
103
|
-
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
|
|
104
|
-
const messageReplyTracker = new Map();
|
|
105
|
-
/**
|
|
106
|
-
* 检查是否可以回复该消息(限流检查)
|
|
107
|
-
* @param messageId 消息ID
|
|
108
|
-
* @returns { allowed: boolean, remaining: number } allowed=是否允许回复,remaining=剩余次数
|
|
109
|
-
*/
|
|
110
|
-
function checkMessageReplyLimit(messageId) {
|
|
111
|
-
const now = Date.now();
|
|
112
|
-
const record = messageReplyTracker.get(messageId);
|
|
113
|
-
// 清理过期记录(定期清理,避免内存泄漏)
|
|
114
|
-
if (messageReplyTracker.size > 10000) {
|
|
115
|
-
for (const [id, rec] of messageReplyTracker) {
|
|
116
|
-
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
117
|
-
messageReplyTracker.delete(id);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (!record) {
|
|
122
|
-
return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
|
|
123
|
-
}
|
|
124
|
-
// 检查是否过期
|
|
125
|
-
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
126
|
-
messageReplyTracker.delete(messageId);
|
|
127
|
-
return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
|
|
128
|
-
}
|
|
129
|
-
// 检查是否超过限制
|
|
130
|
-
const remaining = MESSAGE_REPLY_LIMIT - record.count;
|
|
131
|
-
return { allowed: remaining > 0, remaining: Math.max(0, remaining) };
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* 记录一次消息回复
|
|
135
|
-
* @param messageId 消息ID
|
|
136
|
-
*/
|
|
137
|
-
function recordMessageReply(messageId) {
|
|
138
|
-
const now = Date.now();
|
|
139
|
-
const record = messageReplyTracker.get(messageId);
|
|
140
|
-
if (!record) {
|
|
141
|
-
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
|
142
|
-
}
|
|
143
|
-
else {
|
|
144
|
-
// 检查是否过期,过期则重新计数
|
|
145
|
-
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
146
|
-
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
record.count++;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
// ============ QQ 表情标签解析 ============
|
|
154
|
-
/**
|
|
155
|
-
* 解析 QQ 表情标签,将 <faceType=1,faceId="13",ext="base64..."> 格式
|
|
156
|
-
* 替换为 【表情: 中文名】 格式
|
|
157
|
-
* ext 字段为 Base64 编码的 JSON,格式如 {"text":"呲牙"}
|
|
158
|
-
*/
|
|
159
|
-
function parseFaceTags(text) {
|
|
160
|
-
if (!text)
|
|
161
|
-
return text;
|
|
162
|
-
// 匹配 <faceType=...,faceId="...",ext="..."> 格式的表情标签
|
|
163
|
-
return text.replace(/<faceType=\d+,faceId="[^"]*",ext="([^"]*)">/g, (_match, ext) => {
|
|
164
|
-
try {
|
|
165
|
-
const decoded = Buffer.from(ext, "base64").toString("utf-8");
|
|
166
|
-
const parsed = JSON.parse(decoded);
|
|
167
|
-
const faceName = parsed.text || "未知表情";
|
|
168
|
-
return `【表情: ${faceName}】`;
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
return _match;
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
// formatMediaErrorMessage 已移至 user-messages.ts 集中管理
|
|
176
|
-
// ============ 内部标记过滤 ============
|
|
177
|
-
/**
|
|
178
|
-
* 过滤内部标记(如 [[reply_to: xxx]])
|
|
179
|
-
* 这些标记可能被 AI 错误地学习并输出,需要在发送前移除
|
|
180
|
-
*/
|
|
181
|
-
function filterInternalMarkers(text) {
|
|
182
|
-
if (!text)
|
|
183
|
-
return text;
|
|
184
|
-
// 过滤 [[xxx: yyy]] 格式的内部标记
|
|
185
|
-
// 例如: [[reply_to: ROBOT1.0_kbc...]]
|
|
186
|
-
let result = text.replace(/\[\[[a-z_]+:\s*[^\]]*\]\]/gi, "");
|
|
187
|
-
// 清理可能产生的多余空行
|
|
188
|
-
result = result.replace(/\n{3,}/g, "\n\n").trim();
|
|
189
|
-
return result;
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* 从 message_scene.ext 数组中解析引用索引
|
|
193
|
-
* ext 格式示例: ["", "ref_msg_idx=REFIDX_xxx", "msg_idx=REFIDX_yyy"]
|
|
194
|
-
*/
|
|
195
|
-
function parseRefIndices(ext) {
|
|
196
|
-
if (!ext || ext.length === 0)
|
|
197
|
-
return {};
|
|
198
|
-
let refMsgIdx;
|
|
199
|
-
let msgIdx;
|
|
200
|
-
for (const item of ext) {
|
|
201
|
-
if (item.startsWith("ref_msg_idx=")) {
|
|
202
|
-
refMsgIdx = item.slice("ref_msg_idx=".length);
|
|
203
|
-
}
|
|
204
|
-
else if (item.startsWith("msg_idx=")) {
|
|
205
|
-
msgIdx = item.slice("msg_idx=".length);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return { refMsgIdx, msgIdx };
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* 从附件列表中构建附件摘要(用于引用索引缓存)
|
|
212
|
-
* @param attachments 原始附件列表
|
|
213
|
-
* @param localPaths 与 attachments 一一对应的本地路径(下载后产生)
|
|
214
|
-
*/
|
|
215
|
-
function buildAttachmentSummaries(attachments, localPaths) {
|
|
216
|
-
if (!attachments || attachments.length === 0)
|
|
217
|
-
return undefined;
|
|
218
|
-
return attachments.map((att, idx) => {
|
|
219
|
-
const ct = att.content_type?.toLowerCase() ?? "";
|
|
220
|
-
let type = "unknown";
|
|
221
|
-
if (ct.startsWith("image/"))
|
|
222
|
-
type = "image";
|
|
223
|
-
else if (ct === "voice" || ct.startsWith("audio/") || ct.includes("silk") || ct.includes("amr"))
|
|
224
|
-
type = "voice";
|
|
225
|
-
else if (ct.startsWith("video/"))
|
|
226
|
-
type = "video";
|
|
227
|
-
else if (ct.startsWith("application/") || ct.startsWith("text/"))
|
|
228
|
-
type = "file";
|
|
229
|
-
return {
|
|
230
|
-
type,
|
|
231
|
-
filename: att.filename,
|
|
232
|
-
contentType: att.content_type,
|
|
233
|
-
localPath: localPaths?.[idx] ?? undefined,
|
|
234
|
-
};
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
44
|
/**
|
|
238
45
|
* 启动图床服务器
|
|
239
46
|
*/
|
|
@@ -258,73 +65,9 @@ async function ensureImageServer(log, publicBaseUrl) {
|
|
|
258
65
|
return null;
|
|
259
66
|
}
|
|
260
67
|
}
|
|
261
|
-
// ============ 启动问候语(首次安装/版本更新 vs 普通重启) ============
|
|
262
68
|
// 模块级变量:进程生命周期内只有首次为 true
|
|
263
69
|
// 区分 gateway restart(进程重启)和 health-monitor 断线重连
|
|
264
70
|
let isFirstReadyGlobal = true;
|
|
265
|
-
const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
|
|
266
|
-
const STARTUP_GREETING_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
|
|
267
|
-
function getStartupGreetingText(version) {
|
|
268
|
-
return `🎉 QQBot 插件已更新至 v${version},在线等候你的吩咐。`;
|
|
269
|
-
}
|
|
270
|
-
function readStartupMarker() {
|
|
271
|
-
try {
|
|
272
|
-
if (fs.existsSync(STARTUP_MARKER_FILE)) {
|
|
273
|
-
const data = JSON.parse(fs.readFileSync(STARTUP_MARKER_FILE, "utf8"));
|
|
274
|
-
return data || {};
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
// 文件损坏或不存在,视为无 marker
|
|
279
|
-
}
|
|
280
|
-
return {};
|
|
281
|
-
}
|
|
282
|
-
function writeStartupMarker(data) {
|
|
283
|
-
try {
|
|
284
|
-
fs.writeFileSync(STARTUP_MARKER_FILE, JSON.stringify(data) + "\n");
|
|
285
|
-
}
|
|
286
|
-
catch {
|
|
287
|
-
// ignore
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* 判断是否需要发送“灵魂上线”问候:
|
|
292
|
-
* - 首次安装 / 版本变更:可发送
|
|
293
|
-
* - 同版本:不发送
|
|
294
|
-
* - 同版本近期失败:冷却期内不重试,减少噪音
|
|
295
|
-
*/
|
|
296
|
-
function getStartupGreetingPlan() {
|
|
297
|
-
const currentVersion = getPluginVersion();
|
|
298
|
-
const marker = readStartupMarker();
|
|
299
|
-
if (marker.version === currentVersion) {
|
|
300
|
-
return { shouldSend: false, version: currentVersion, reason: "same-version" };
|
|
301
|
-
}
|
|
302
|
-
if (marker.lastFailureVersion === currentVersion && marker.lastFailureAt) {
|
|
303
|
-
const lastFailureAtMs = new Date(marker.lastFailureAt).getTime();
|
|
304
|
-
if (!Number.isNaN(lastFailureAtMs) && Date.now() - lastFailureAtMs < STARTUP_GREETING_RETRY_COOLDOWN_MS) {
|
|
305
|
-
return { shouldSend: false, version: currentVersion, reason: "cooldown" };
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
return { shouldSend: true, greeting: getStartupGreetingText(currentVersion), version: currentVersion };
|
|
309
|
-
}
|
|
310
|
-
function markStartupGreetingSent(version) {
|
|
311
|
-
writeStartupMarker({
|
|
312
|
-
version,
|
|
313
|
-
startedAt: new Date().toISOString(),
|
|
314
|
-
greetedAt: new Date().toISOString(),
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
function markStartupGreetingFailed(version, reason) {
|
|
318
|
-
const marker = readStartupMarker();
|
|
319
|
-
// 同版本已有失败记录时,不覆盖 lastFailureAt,避免冷却期被无限续期
|
|
320
|
-
const shouldPreserveTimestamp = marker.lastFailureVersion === version && marker.lastFailureAt;
|
|
321
|
-
writeStartupMarker({
|
|
322
|
-
...marker,
|
|
323
|
-
lastFailureVersion: version,
|
|
324
|
-
lastFailureAt: shouldPreserveTimestamp ? marker.lastFailureAt : new Date().toISOString(),
|
|
325
|
-
lastFailureReason: reason,
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
71
|
/**
|
|
329
72
|
* 启动 Gateway WebSocket 连接(带自动重连)
|
|
330
73
|
* 支持流式消息发送
|
|
@@ -417,110 +160,7 @@ export async function startGateway(ctx) {
|
|
|
417
160
|
let shouldRefreshToken = false; // 下次连接是否需要刷新 token
|
|
418
161
|
// 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
|
|
419
162
|
// health-monitor 重连不会重新初始化为 true
|
|
420
|
-
const
|
|
421
|
-
const safeAccountId = account.accountId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
422
|
-
const safeAppId = account.appId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
423
|
-
const UPGRADE_GREETING_TARGET_FILE = path.join(getQQBotDataDir("data"), `upgrade-greeting-target-${safeAccountId}-${safeAppId}.json`);
|
|
424
|
-
/**
|
|
425
|
-
* 读取已持久化的管理员 openid
|
|
426
|
-
*/
|
|
427
|
-
const loadAdminOpenId = () => {
|
|
428
|
-
try {
|
|
429
|
-
if (fs.existsSync(ADMIN_MARKER_FILE)) {
|
|
430
|
-
const data = JSON.parse(fs.readFileSync(ADMIN_MARKER_FILE, "utf8"));
|
|
431
|
-
if (data.openid)
|
|
432
|
-
return data.openid;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
catch { /* 文件损坏视为无 */ }
|
|
436
|
-
return undefined;
|
|
437
|
-
};
|
|
438
|
-
const loadUpgradeGreetingTargetOpenId = () => {
|
|
439
|
-
try {
|
|
440
|
-
if (fs.existsSync(UPGRADE_GREETING_TARGET_FILE)) {
|
|
441
|
-
const data = JSON.parse(fs.readFileSync(UPGRADE_GREETING_TARGET_FILE, "utf8"));
|
|
442
|
-
if (!data.openid)
|
|
443
|
-
return undefined;
|
|
444
|
-
if (data.appId && data.appId !== account.appId)
|
|
445
|
-
return undefined;
|
|
446
|
-
if (data.accountId && data.accountId !== account.accountId)
|
|
447
|
-
return undefined;
|
|
448
|
-
return data.openid;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
catch { /* 文件损坏视为无 */ }
|
|
452
|
-
return undefined;
|
|
453
|
-
};
|
|
454
|
-
const clearUpgradeGreetingTargetOpenId = () => {
|
|
455
|
-
try {
|
|
456
|
-
if (fs.existsSync(UPGRADE_GREETING_TARGET_FILE)) {
|
|
457
|
-
fs.unlinkSync(UPGRADE_GREETING_TARGET_FILE);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
catch { /* ignore */ }
|
|
461
|
-
};
|
|
462
|
-
/**
|
|
463
|
-
* 将管理员 openid 持久化到文件
|
|
464
|
-
*/
|
|
465
|
-
const saveAdminOpenId = (openid) => {
|
|
466
|
-
try {
|
|
467
|
-
fs.writeFileSync(ADMIN_MARKER_FILE, JSON.stringify({ openid, savedAt: new Date().toISOString() }));
|
|
468
|
-
}
|
|
469
|
-
catch { /* ignore */ }
|
|
470
|
-
};
|
|
471
|
-
/**
|
|
472
|
-
* 解析管理员 openid:
|
|
473
|
-
* 1. 优先读持久化文件(稳定)
|
|
474
|
-
* 2. fallback 取第一个私聊用户,并写入文件锁定
|
|
475
|
-
*/
|
|
476
|
-
const resolveAdminOpenId = () => {
|
|
477
|
-
const saved = loadAdminOpenId();
|
|
478
|
-
if (saved)
|
|
479
|
-
return saved;
|
|
480
|
-
const first = listKnownUsers({ accountId: account.accountId, type: "c2c", sortBy: "firstSeenAt", sortOrder: "asc", limit: 1 })[0]?.openid;
|
|
481
|
-
if (first) {
|
|
482
|
-
saveAdminOpenId(first);
|
|
483
|
-
log?.info(`[qqbot:${account.accountId}] Auto-detected admin openid: ${first} (persisted)`);
|
|
484
|
-
}
|
|
485
|
-
return first;
|
|
486
|
-
};
|
|
487
|
-
/** 异步发送启动问候语(仅发给管理员) */
|
|
488
|
-
const sendStartupGreetings = (trigger) => {
|
|
489
|
-
(async () => {
|
|
490
|
-
const plan = getStartupGreetingPlan();
|
|
491
|
-
if (!plan.shouldSend || !plan.greeting) {
|
|
492
|
-
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (${plan.reason ?? "debounced"}, trigger=${trigger})`);
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
const upgradeTargetOpenId = loadUpgradeGreetingTargetOpenId();
|
|
496
|
-
const targetOpenId = upgradeTargetOpenId || resolveAdminOpenId();
|
|
497
|
-
if (!targetOpenId) {
|
|
498
|
-
markStartupGreetingFailed(plan.version, "no-admin");
|
|
499
|
-
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (no admin or known user)`);
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
try {
|
|
503
|
-
const receiverType = upgradeTargetOpenId ? "upgrade-requester" : "admin";
|
|
504
|
-
log?.info(`[qqbot:${account.accountId}] Sending startup greeting to ${receiverType} (trigger=${trigger}): "${plan.greeting}"`);
|
|
505
|
-
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
506
|
-
const GREETING_TIMEOUT_MS = 10_000;
|
|
507
|
-
await Promise.race([
|
|
508
|
-
sendProactiveC2CMessage(token, targetOpenId, plan.greeting),
|
|
509
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
|
|
510
|
-
]);
|
|
511
|
-
markStartupGreetingSent(plan.version);
|
|
512
|
-
if (upgradeTargetOpenId) {
|
|
513
|
-
clearUpgradeGreetingTargetOpenId();
|
|
514
|
-
}
|
|
515
|
-
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to ${receiverType}: ${targetOpenId}`);
|
|
516
|
-
}
|
|
517
|
-
catch (err) {
|
|
518
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
519
|
-
markStartupGreetingFailed(plan.version, message);
|
|
520
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send startup greeting: ${message}`);
|
|
521
|
-
}
|
|
522
|
-
})();
|
|
523
|
-
};
|
|
163
|
+
const adminCtx = { accountId: account.accountId, appId: account.appId, clientSecret: account.clientSecret, log };
|
|
524
164
|
// ============ P1-2: 尝试从持久化存储恢复 Session ============
|
|
525
165
|
// 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
|
|
526
166
|
const savedSession = loadSession(account.accountId, account.appId);
|
|
@@ -529,113 +169,36 @@ export async function startGateway(ctx) {
|
|
|
529
169
|
lastSeq = savedSession.lastSeq;
|
|
530
170
|
log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`);
|
|
531
171
|
}
|
|
532
|
-
// ============
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
let handleMessageFnRef = null;
|
|
539
|
-
let totalEnqueued = 0; // 全局已入队总数(用于溢出保护)
|
|
540
|
-
// 获取消息的路由 key(决定并发隔离粒度)
|
|
541
|
-
const getMessagePeerId = (msg) => {
|
|
542
|
-
if (msg.type === "guild")
|
|
543
|
-
return `guild:${msg.channelId ?? "unknown"}`;
|
|
544
|
-
if (msg.type === "group")
|
|
545
|
-
return `group:${msg.groupOpenid ?? "unknown"}`;
|
|
546
|
-
return `dm:${msg.senderId}`;
|
|
547
|
-
};
|
|
548
|
-
const enqueueMessage = (msg) => {
|
|
549
|
-
const peerId = getMessagePeerId(msg);
|
|
550
|
-
let queue = userQueues.get(peerId);
|
|
551
|
-
if (!queue) {
|
|
552
|
-
queue = [];
|
|
553
|
-
userQueues.set(peerId, queue);
|
|
554
|
-
}
|
|
555
|
-
// 单用户队列溢出保护
|
|
556
|
-
if (queue.length >= PER_USER_QUEUE_SIZE) {
|
|
557
|
-
const dropped = queue.shift();
|
|
558
|
-
log?.error(`[qqbot:${account.accountId}] Per-user queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
|
|
559
|
-
}
|
|
560
|
-
// 全局总量保护
|
|
561
|
-
totalEnqueued++;
|
|
562
|
-
if (totalEnqueued > MESSAGE_QUEUE_SIZE) {
|
|
563
|
-
log?.error(`[qqbot:${account.accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`);
|
|
564
|
-
}
|
|
565
|
-
queue.push(msg);
|
|
566
|
-
log?.debug?.(`[qqbot:${account.accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`);
|
|
567
|
-
// 如果该用户没有正在处理的消息,立即启动处理
|
|
568
|
-
drainUserQueue(peerId);
|
|
569
|
-
};
|
|
570
|
-
// 处理指定用户队列中的消息(串行)
|
|
571
|
-
const drainUserQueue = async (peerId) => {
|
|
572
|
-
if (activeUsers.has(peerId))
|
|
573
|
-
return; // 该用户已有处理中的消息
|
|
574
|
-
if (activeUsers.size >= MAX_CONCURRENT_USERS) {
|
|
575
|
-
log?.info(`[qqbot:${account.accountId}] Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`);
|
|
576
|
-
return; // 达到并发上限,等待其他用户处理完后触发
|
|
577
|
-
}
|
|
578
|
-
const queue = userQueues.get(peerId);
|
|
579
|
-
if (!queue || queue.length === 0) {
|
|
580
|
-
userQueues.delete(peerId);
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
activeUsers.add(peerId);
|
|
584
|
-
try {
|
|
585
|
-
while (queue.length > 0 && !isAborted) {
|
|
586
|
-
const msg = queue.shift();
|
|
587
|
-
totalEnqueued = Math.max(0, totalEnqueued - 1);
|
|
588
|
-
try {
|
|
589
|
-
if (handleMessageFnRef) {
|
|
590
|
-
await handleMessageFnRef(msg);
|
|
591
|
-
messagesProcessed++;
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
catch (err) {
|
|
595
|
-
log?.error(`[qqbot:${account.accountId}] Message processor error for ${peerId}: ${err}`);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
finally {
|
|
600
|
-
activeUsers.delete(peerId);
|
|
601
|
-
userQueues.delete(peerId);
|
|
602
|
-
// 处理完后,唤醒等待的用户填满并发槽位
|
|
603
|
-
for (const [waitingPeerId, waitingQueue] of userQueues) {
|
|
604
|
-
if (activeUsers.size >= MAX_CONCURRENT_USERS)
|
|
605
|
-
break; // 槽位已满
|
|
606
|
-
if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
|
|
607
|
-
drainUserQueue(waitingPeerId);
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
};
|
|
612
|
-
const startMessageProcessor = (handleMessageFn) => {
|
|
613
|
-
handleMessageFnRef = handleMessageFn;
|
|
614
|
-
log?.info(`[qqbot:${account.accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`);
|
|
615
|
-
};
|
|
616
|
-
// 获取队列状态快照(供斜杠指令使用)
|
|
617
|
-
const getQueueSnapshot = (senderPeerId) => {
|
|
618
|
-
let totalPending = 0;
|
|
619
|
-
for (const [, q] of userQueues) {
|
|
620
|
-
totalPending += q.length;
|
|
621
|
-
}
|
|
622
|
-
const senderQueue = userQueues.get(senderPeerId);
|
|
623
|
-
return {
|
|
624
|
-
totalPending,
|
|
625
|
-
activeUsers: activeUsers.size,
|
|
626
|
-
maxConcurrentUsers: MAX_CONCURRENT_USERS,
|
|
627
|
-
senderPending: senderQueue ? senderQueue.length : 0,
|
|
628
|
-
};
|
|
629
|
-
};
|
|
172
|
+
// ============ 按用户并发的消息队列 ============
|
|
173
|
+
const msgQueue = createMessageQueue({
|
|
174
|
+
accountId: account.accountId,
|
|
175
|
+
log,
|
|
176
|
+
isAborted: () => isAborted,
|
|
177
|
+
});
|
|
630
178
|
// 斜杠指令拦截:在入队前匹配插件级指令,命中则直接回复,不入队
|
|
179
|
+
// 紧急命令列表:这些命令会立即执行,不进入斜杠匹配流程
|
|
180
|
+
const URGENT_COMMANDS = ["/stop"];
|
|
631
181
|
const trySlashCommandOrEnqueue = async (msg) => {
|
|
632
182
|
const content = (msg.content ?? "").trim();
|
|
633
183
|
if (!content.startsWith("/")) {
|
|
634
|
-
|
|
184
|
+
msgQueue.enqueue(msg);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// 检测是否为紧急命令 — 立即执行,清空该用户队列
|
|
188
|
+
const contentLower = content.toLowerCase();
|
|
189
|
+
const isUrgentCommand = URGENT_COMMANDS.some(cmd => contentLower.startsWith(cmd.toLowerCase()));
|
|
190
|
+
if (isUrgentCommand) {
|
|
191
|
+
log?.info(`[qqbot:${account.accountId}] Urgent command detected: ${content.slice(0, 20)}, executing immediately`);
|
|
192
|
+
const peerId = msgQueue.getMessagePeerId(msg);
|
|
193
|
+
const droppedCount = msgQueue.clearUserQueue(peerId);
|
|
194
|
+
if (droppedCount > 0) {
|
|
195
|
+
log?.info(`[qqbot:${account.accountId}] Dropped ${droppedCount} queued messages for ${peerId} due to urgent command`);
|
|
196
|
+
}
|
|
197
|
+
msgQueue.executeImmediate(msg);
|
|
635
198
|
return;
|
|
636
199
|
}
|
|
637
200
|
const receivedAt = Date.now();
|
|
638
|
-
const peerId = getMessagePeerId(msg);
|
|
201
|
+
const peerId = msgQueue.getMessagePeerId(msg);
|
|
639
202
|
const cmdCtx = {
|
|
640
203
|
type: msg.type,
|
|
641
204
|
senderId: msg.senderId,
|
|
@@ -650,13 +213,13 @@ export async function startGateway(ctx) {
|
|
|
650
213
|
accountId: account.accountId,
|
|
651
214
|
appId: account.appId,
|
|
652
215
|
accountConfig: account.config,
|
|
653
|
-
queueSnapshot:
|
|
216
|
+
queueSnapshot: msgQueue.getSnapshot(peerId),
|
|
654
217
|
};
|
|
655
218
|
try {
|
|
656
219
|
const reply = await matchSlashCommand(cmdCtx);
|
|
657
220
|
if (reply === null) {
|
|
658
221
|
// 不是插件级指令,正常入队交给框架
|
|
659
|
-
|
|
222
|
+
msgQueue.enqueue(msg);
|
|
660
223
|
return;
|
|
661
224
|
}
|
|
662
225
|
// 命中插件级指令,直接回复
|
|
@@ -702,7 +265,7 @@ export async function startGateway(ctx) {
|
|
|
702
265
|
catch (err) {
|
|
703
266
|
log?.error(`[qqbot:${account.accountId}] Slash command error: ${err}`);
|
|
704
267
|
// 出错时回退到正常入队
|
|
705
|
-
|
|
268
|
+
msgQueue.enqueue(msg);
|
|
706
269
|
}
|
|
707
270
|
};
|
|
708
271
|
abortSignal.addEventListener("abort", () => {
|
|
@@ -787,14 +350,22 @@ export async function startGateway(ctx) {
|
|
|
787
350
|
accountId: account.accountId,
|
|
788
351
|
direction: "inbound",
|
|
789
352
|
});
|
|
790
|
-
//
|
|
353
|
+
// 发送输入状态提示 + 启动自动续期(仅 C2C 私聊有效)
|
|
791
354
|
// refIdx 通过 Promise 延迟获取,在真正需要时再 await
|
|
355
|
+
const isC2C = event.type === "c2c" || event.type === "dm";
|
|
356
|
+
// 用对象包装避免 TS 控制流分析将 null 初始值窄化为 never
|
|
357
|
+
const typing = { keepAlive: null };
|
|
792
358
|
const inputNotifyPromise = (async () => {
|
|
359
|
+
if (!isC2C)
|
|
360
|
+
return undefined;
|
|
793
361
|
try {
|
|
794
362
|
let token = await getAccessToken(account.appId, account.clientSecret);
|
|
795
363
|
try {
|
|
796
|
-
const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId,
|
|
364
|
+
const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, TYPING_INPUT_SECOND);
|
|
797
365
|
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}${notifyResponse.refIdx ? `, got refIdx=${notifyResponse.refIdx}` : ""}`);
|
|
366
|
+
// 首次成功后启动定时续期
|
|
367
|
+
typing.keepAlive = new TypingKeepAlive(() => getAccessToken(account.appId, account.clientSecret), () => clearTokenCache(account.appId), event.senderId, event.messageId, log, `[qqbot:${account.accountId}]`);
|
|
368
|
+
typing.keepAlive.start();
|
|
798
369
|
return notifyResponse.refIdx;
|
|
799
370
|
}
|
|
800
371
|
catch (notifyErr) {
|
|
@@ -803,7 +374,9 @@ export async function startGateway(ctx) {
|
|
|
803
374
|
log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`);
|
|
804
375
|
clearTokenCache(account.appId);
|
|
805
376
|
token = await getAccessToken(account.appId, account.clientSecret);
|
|
806
|
-
const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId,
|
|
377
|
+
const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, TYPING_INPUT_SECOND);
|
|
378
|
+
typing.keepAlive = new TypingKeepAlive(() => getAccessToken(account.appId, account.clientSecret), () => clearTokenCache(account.appId), event.senderId, event.messageId, log, `[qqbot:${account.accountId}]`);
|
|
379
|
+
typing.keepAlive.start();
|
|
807
380
|
return notifyResponse.refIdx;
|
|
808
381
|
}
|
|
809
382
|
else {
|
|
@@ -834,7 +407,7 @@ export async function startGateway(ctx) {
|
|
|
834
407
|
});
|
|
835
408
|
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
836
409
|
// 组装消息体
|
|
837
|
-
// 静态系统提示已移至 skills/qqbot-
|
|
410
|
+
// 静态系统提示已移至 skills/qqbot-remind/SKILL.md 和 skills/qqbot-media/SKILL.md
|
|
838
411
|
// BodyForAgent 只保留必要的动态上下文信息
|
|
839
412
|
// ============ 用户标识信息 ============
|
|
840
413
|
// 收集额外的系统提示(如果配置了账户级别的 systemPrompt)
|
|
@@ -843,195 +416,11 @@ export async function startGateway(ctx) {
|
|
|
843
416
|
systemPrompts.push(account.systemPrompt);
|
|
844
417
|
}
|
|
845
418
|
// 处理附件(图片等)- 下载到本地供 openclaw 访问
|
|
846
|
-
|
|
847
|
-
const imageUrls =
|
|
848
|
-
const imageMediaTypes = [];
|
|
849
|
-
const voiceAttachmentPaths = [];
|
|
850
|
-
const voiceAttachmentUrls = [];
|
|
851
|
-
const voiceAsrReferTexts = [];
|
|
852
|
-
const voiceTranscripts = [];
|
|
853
|
-
// 每个附件的本地路径(与 event.attachments 一一对应,未下载的为 null)
|
|
854
|
-
const attachmentLocalPaths = [];
|
|
855
|
-
const voiceTranscriptSources = [];
|
|
856
|
-
// 存到 .openclaw/qqbot 目录下的 downloads 文件夹
|
|
857
|
-
const downloadDir = getQQBotDataDir("downloads");
|
|
858
|
-
if (event.attachments?.length) {
|
|
859
|
-
const otherAttachments = [];
|
|
860
|
-
// Phase 1: 并行下载所有附件
|
|
861
|
-
const downloadTasks = event.attachments.map(async (att) => {
|
|
862
|
-
const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
|
|
863
|
-
const isVoice = isVoiceAttachment(att);
|
|
864
|
-
const wavUrl = isVoice && att.voice_wav_url
|
|
865
|
-
? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
|
|
866
|
-
: "";
|
|
867
|
-
let localPath = null;
|
|
868
|
-
let audioPath = null;
|
|
869
|
-
if (isVoice && wavUrl) {
|
|
870
|
-
const wavLocalPath = await downloadFile(wavUrl, downloadDir);
|
|
871
|
-
if (wavLocalPath) {
|
|
872
|
-
localPath = wavLocalPath;
|
|
873
|
-
audioPath = wavLocalPath;
|
|
874
|
-
log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`);
|
|
875
|
-
}
|
|
876
|
-
else {
|
|
877
|
-
log?.error(`[qqbot:${account.accountId}] Failed to download voice_wav_url, falling back to original URL`);
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
if (!localPath) {
|
|
881
|
-
localPath = await downloadFile(attUrl, downloadDir, att.filename);
|
|
882
|
-
}
|
|
883
|
-
return { att, attUrl, isVoice, localPath, audioPath };
|
|
884
|
-
});
|
|
885
|
-
const downloadResults = await Promise.all(downloadTasks);
|
|
886
|
-
// Phase 2: 并行处理语音转换+转录(非语音附件同步归类)
|
|
887
|
-
const processTasks = downloadResults.map(async ({ att, attUrl, isVoice, localPath, audioPath }) => {
|
|
888
|
-
const asrReferText = typeof att.asr_refer_text === "string" ? att.asr_refer_text.trim() : "";
|
|
889
|
-
const wavUrl = isVoice && att.voice_wav_url
|
|
890
|
-
? (att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url)
|
|
891
|
-
: "";
|
|
892
|
-
const voiceSourceUrl = wavUrl || attUrl;
|
|
893
|
-
// 收集语音元数据(顺序无关)
|
|
894
|
-
const meta = {
|
|
895
|
-
voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : undefined,
|
|
896
|
-
asrReferText: isVoice && asrReferText ? asrReferText : undefined,
|
|
897
|
-
};
|
|
898
|
-
if (localPath) {
|
|
899
|
-
if (att.content_type?.startsWith("image/")) {
|
|
900
|
-
log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
|
|
901
|
-
return { localPath, type: "image", contentType: att.content_type, meta };
|
|
902
|
-
}
|
|
903
|
-
else if (isVoice) {
|
|
904
|
-
log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
|
|
905
|
-
// 语音处理:转换 + 转录
|
|
906
|
-
const sttCfg = resolveSTTConfig(cfg);
|
|
907
|
-
if (!sttCfg) {
|
|
908
|
-
if (asrReferText) {
|
|
909
|
-
log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename} (STT not configured, using asr_refer_text fallback)`);
|
|
910
|
-
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
|
|
911
|
-
}
|
|
912
|
-
else {
|
|
913
|
-
log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename} (STT not configured, skipping transcription)`);
|
|
914
|
-
return { localPath, type: "voice", transcript: "[语音消息 - 语音识别未配置,无法转录]", transcriptSource: "fallback", meta };
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
// SILK→WAV 转换
|
|
918
|
-
if (!audioPath) {
|
|
919
|
-
log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename}, converting SILK→WAV...`);
|
|
920
|
-
try {
|
|
921
|
-
const wavResult = await convertSilkToWav(localPath, downloadDir);
|
|
922
|
-
if (wavResult) {
|
|
923
|
-
audioPath = wavResult.wavPath;
|
|
924
|
-
log?.info(`[qqbot:${account.accountId}] Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`);
|
|
925
|
-
}
|
|
926
|
-
else {
|
|
927
|
-
audioPath = localPath;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
catch (convertErr) {
|
|
931
|
-
log?.error(`[qqbot:${account.accountId}] Voice conversion failed: ${convertErr}`);
|
|
932
|
-
if (asrReferText) {
|
|
933
|
-
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
|
|
934
|
-
}
|
|
935
|
-
else {
|
|
936
|
-
return { localPath, type: "voice", transcript: "[语音消息 - 格式转换失败]", transcriptSource: "fallback", meta };
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
// STT 转录
|
|
941
|
-
try {
|
|
942
|
-
const transcript = await transcribeAudio(audioPath, cfg);
|
|
943
|
-
if (transcript) {
|
|
944
|
-
log?.info(`[qqbot:${account.accountId}] STT transcript: ${transcript.slice(0, 100)}...`);
|
|
945
|
-
return { localPath, type: "voice", transcript, transcriptSource: "stt", meta };
|
|
946
|
-
}
|
|
947
|
-
else if (asrReferText) {
|
|
948
|
-
log?.info(`[qqbot:${account.accountId}] STT returned empty result, using asr_refer_text fallback`);
|
|
949
|
-
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
|
|
950
|
-
}
|
|
951
|
-
else {
|
|
952
|
-
log?.info(`[qqbot:${account.accountId}] STT returned empty result`);
|
|
953
|
-
return { localPath, type: "voice", transcript: "[语音消息 - 转录结果为空]", transcriptSource: "fallback", meta };
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
catch (sttErr) {
|
|
957
|
-
log?.error(`[qqbot:${account.accountId}] STT failed: ${sttErr}`);
|
|
958
|
-
if (asrReferText) {
|
|
959
|
-
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
|
|
960
|
-
}
|
|
961
|
-
else {
|
|
962
|
-
return { localPath, type: "voice", transcript: "[语音消息 - 转录失败]", transcriptSource: "fallback", meta };
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
else {
|
|
967
|
-
log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
|
|
968
|
-
return { localPath, type: "other", filename: att.filename, meta };
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
else {
|
|
972
|
-
log?.error(`[qqbot:${account.accountId}] Failed to download: ${attUrl}`);
|
|
973
|
-
if (att.content_type?.startsWith("image/")) {
|
|
974
|
-
return { localPath: null, type: "image-fallback", attUrl, contentType: att.content_type, meta };
|
|
975
|
-
}
|
|
976
|
-
else if (isVoice && asrReferText) {
|
|
977
|
-
log?.info(`[qqbot:${account.accountId}] Voice attachment download failed, using asr_refer_text fallback`);
|
|
978
|
-
return { localPath: null, type: "voice-fallback", transcript: asrReferText, meta };
|
|
979
|
-
}
|
|
980
|
-
else {
|
|
981
|
-
return { localPath: null, type: "other-fallback", filename: att.filename ?? att.content_type, meta };
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
});
|
|
985
|
-
const processResults = await Promise.all(processTasks);
|
|
986
|
-
// Phase 3: 按原始顺序归类结果(保持与附件数组一一对应)
|
|
987
|
-
for (const result of processResults) {
|
|
988
|
-
// 收集语音元数据
|
|
989
|
-
if (result.meta.voiceUrl)
|
|
990
|
-
voiceAttachmentUrls.push(result.meta.voiceUrl);
|
|
991
|
-
if (result.meta.asrReferText)
|
|
992
|
-
voiceAsrReferTexts.push(result.meta.asrReferText);
|
|
993
|
-
if (result.type === "image" && result.localPath) {
|
|
994
|
-
imageUrls.push(result.localPath);
|
|
995
|
-
imageMediaTypes.push(result.contentType);
|
|
996
|
-
attachmentLocalPaths.push(result.localPath);
|
|
997
|
-
}
|
|
998
|
-
else if (result.type === "voice" && result.localPath) {
|
|
999
|
-
voiceAttachmentPaths.push(result.localPath);
|
|
1000
|
-
voiceTranscripts.push(result.transcript);
|
|
1001
|
-
voiceTranscriptSources.push(result.transcriptSource);
|
|
1002
|
-
attachmentLocalPaths.push(result.localPath);
|
|
1003
|
-
}
|
|
1004
|
-
else if (result.type === "other" && result.localPath) {
|
|
1005
|
-
otherAttachments.push(`[附件: ${result.localPath}]`);
|
|
1006
|
-
attachmentLocalPaths.push(result.localPath);
|
|
1007
|
-
}
|
|
1008
|
-
else if (result.type === "image-fallback") {
|
|
1009
|
-
imageUrls.push(result.attUrl);
|
|
1010
|
-
imageMediaTypes.push(result.contentType);
|
|
1011
|
-
attachmentLocalPaths.push(null);
|
|
1012
|
-
}
|
|
1013
|
-
else if (result.type === "voice-fallback") {
|
|
1014
|
-
voiceTranscripts.push(result.transcript);
|
|
1015
|
-
voiceTranscriptSources.push("asr");
|
|
1016
|
-
attachmentLocalPaths.push(null);
|
|
1017
|
-
}
|
|
1018
|
-
else if (result.type === "other-fallback") {
|
|
1019
|
-
otherAttachments.push(`[附件: ${result.filename}] (下载失败)`);
|
|
1020
|
-
attachmentLocalPaths.push(null);
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
if (otherAttachments.length > 0) {
|
|
1024
|
-
attachmentInfo += "\n" + otherAttachments.join("\n");
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
419
|
+
const processed = await processAttachments(event.attachments, { accountId: account.accountId, cfg, log });
|
|
420
|
+
const { attachmentInfo, imageUrls, imageMediaTypes, voiceAttachmentPaths, voiceAttachmentUrls, voiceAsrReferTexts, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } = processed;
|
|
1027
421
|
// 语音转录文本注入到用户消息中
|
|
1028
|
-
|
|
422
|
+
const voiceText = formatVoiceText(voiceTranscripts);
|
|
1029
423
|
const hasAsrReferFallback = voiceTranscriptSources.includes("asr");
|
|
1030
|
-
if (voiceTranscripts.length > 0) {
|
|
1031
|
-
voiceText = voiceTranscripts.length === 1
|
|
1032
|
-
? `[语音消息] ${voiceTranscripts[0]}`
|
|
1033
|
-
: voiceTranscripts.map((t, i) => `[语音${i + 1}] ${t}`).join("\n");
|
|
1034
|
-
}
|
|
1035
424
|
// 解析 QQ 表情标签,将 <faceType=...,ext="base64"> 替换为 【表情: 中文名】
|
|
1036
425
|
const parsedContent = parseFaceTags(event.content);
|
|
1037
426
|
const userContent = voiceText
|
|
@@ -1104,7 +493,6 @@ export async function startGateway(ctx) {
|
|
|
1104
493
|
...(imageUrls.length > 0 ? { imageUrls } : {}),
|
|
1105
494
|
});
|
|
1106
495
|
// BodyForAgent: AI 实际看到的完整上下文(动态数据 + 系统提示 + 用户输入)
|
|
1107
|
-
const nowMs = Date.now();
|
|
1108
496
|
// 构建媒体附件纯数据描述(图片 + 语音统一列出)
|
|
1109
497
|
const uniqueVoicePaths = [...new Set(voiceAttachmentPaths)];
|
|
1110
498
|
const uniqueVoiceUrls = [...new Set(voiceAttachmentUrls)];
|
|
@@ -1246,45 +634,19 @@ export async function startGateway(ctx) {
|
|
|
1246
634
|
ReplyToIsQuote: replyToIsQuote,
|
|
1247
635
|
} : {}),
|
|
1248
636
|
});
|
|
1249
|
-
//
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const errMsg = String(err);
|
|
1257
|
-
// 如果是 token 相关错误,清除缓存重试一次
|
|
1258
|
-
if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
|
|
1259
|
-
log?.info(`[qqbot:${account.accountId}] Token may be expired, refreshing...`);
|
|
1260
|
-
clearTokenCache(account.appId);
|
|
1261
|
-
const newToken = await getAccessToken(account.appId, account.clientSecret);
|
|
1262
|
-
return await sendFn(newToken);
|
|
1263
|
-
}
|
|
1264
|
-
else {
|
|
1265
|
-
throw err;
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
637
|
+
// 构建回复上下文
|
|
638
|
+
const replyTarget = {
|
|
639
|
+
type: event.type,
|
|
640
|
+
senderId: event.senderId,
|
|
641
|
+
messageId: event.messageId,
|
|
642
|
+
channelId: event.channelId,
|
|
643
|
+
groupOpenid: event.groupOpenid,
|
|
1268
644
|
};
|
|
645
|
+
const replyCtx = { target: replyTarget, account, cfg, log };
|
|
646
|
+
// 简化的 token 重试包装(使用 reply-dispatcher 的通用实现)
|
|
647
|
+
const sendWithRetry = (sendFn) => sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
|
|
1269
648
|
// 发送错误提示的辅助函数
|
|
1270
|
-
const sendErrorMessage =
|
|
1271
|
-
try {
|
|
1272
|
-
await sendWithTokenRetry(async (token) => {
|
|
1273
|
-
if (event.type === "c2c") {
|
|
1274
|
-
await sendC2CMessage(token, event.senderId, errorText, event.messageId);
|
|
1275
|
-
}
|
|
1276
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1277
|
-
await sendGroupMessage(token, event.groupOpenid, errorText, event.messageId);
|
|
1278
|
-
}
|
|
1279
|
-
else if (event.channelId) {
|
|
1280
|
-
await sendChannelMessage(token, event.channelId, errorText, event.messageId);
|
|
1281
|
-
}
|
|
1282
|
-
});
|
|
1283
|
-
}
|
|
1284
|
-
catch (sendErr) {
|
|
1285
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send error message: ${sendErr}`);
|
|
1286
|
-
}
|
|
1287
|
-
};
|
|
649
|
+
const sendErrorMessage = (errorText) => sendErrorToTarget(replyCtx, errorText);
|
|
1288
650
|
try {
|
|
1289
651
|
const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
|
1290
652
|
// 追踪是否有响应
|
|
@@ -1346,11 +708,6 @@ export async function startGateway(ctx) {
|
|
|
1346
708
|
}
|
|
1347
709
|
}, responseTimeout);
|
|
1348
710
|
});
|
|
1349
|
-
// ============ 消息发送目标 ============
|
|
1350
|
-
// 确定发送目标
|
|
1351
|
-
const targetTo = event.type === "c2c" ? event.senderId
|
|
1352
|
-
: event.type === "group" ? `group:${event.groupOpenid}`
|
|
1353
|
-
: `channel:${event.channelId}`;
|
|
1354
711
|
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1355
712
|
ctx: ctxPayload,
|
|
1356
713
|
cfg,
|
|
@@ -1436,6 +793,8 @@ export async function startGateway(ctx) {
|
|
|
1436
793
|
}
|
|
1437
794
|
// 收到 block 回复,清除所有超时定时器
|
|
1438
795
|
hasBlockResponse = true;
|
|
796
|
+
// 收到真正回复,立即停止输入状态续期(让 "输入中" 尽快消失)
|
|
797
|
+
typing.keepAlive?.stop();
|
|
1439
798
|
if (timeoutId) {
|
|
1440
799
|
clearTimeout(timeoutId);
|
|
1441
800
|
timeoutId = null;
|
|
@@ -1448,8 +807,6 @@ export async function startGateway(ctx) {
|
|
|
1448
807
|
log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
|
|
1449
808
|
}
|
|
1450
809
|
// ============ 引用回复 ============
|
|
1451
|
-
// 机器人回复时,引用用户当前发来的消息(event.msgIdx 是用户消息自身的 REFIDX)
|
|
1452
|
-
// 只在第一条回复消息上附加引用,后续消息不重复引用
|
|
1453
810
|
const quoteRef = event.msgIdx;
|
|
1454
811
|
let quoteRefUsed = false;
|
|
1455
812
|
const consumeQuoteRef = () => {
|
|
@@ -1460,191 +817,18 @@ export async function startGateway(ctx) {
|
|
|
1460
817
|
return undefined;
|
|
1461
818
|
};
|
|
1462
819
|
let replyText = payload.text ?? "";
|
|
1463
|
-
// ============ 媒体标签解析 ============
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
const
|
|
1474
|
-
|
|
1475
|
-
if (mediaTagMatches.length > 0) {
|
|
1476
|
-
const tagCounts = mediaTagMatches.reduce((acc, m) => { const t = m[1].toLowerCase(); acc[t] = (acc[t] ?? 0) + 1; return acc; }, {});
|
|
1477
|
-
log?.info(`[qqbot:${account.accountId}] Detected media tags: ${Object.entries(tagCounts).map(([k, v]) => `${v} <${k}>`).join(", ")}`);
|
|
1478
|
-
// 构建发送队列
|
|
1479
|
-
const sendQueue = [];
|
|
1480
|
-
let lastIndex = 0;
|
|
1481
|
-
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
1482
|
-
let match;
|
|
1483
|
-
while ((match = mediaTagRegexWithIndex.exec(replyText)) !== null) {
|
|
1484
|
-
// 添加标签前的文本
|
|
1485
|
-
const textBefore = replyText.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
|
|
1486
|
-
if (textBefore) {
|
|
1487
|
-
sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
|
|
1488
|
-
}
|
|
1489
|
-
const tagName = match[1].toLowerCase(); // "qqimg" or "qqvoice" or "qqfile"
|
|
1490
|
-
// 剥离 MEDIA: 前缀(框架可能注入),展开 ~ 路径
|
|
1491
|
-
let mediaPath = match[2]?.trim() ?? "";
|
|
1492
|
-
if (mediaPath.startsWith("MEDIA:")) {
|
|
1493
|
-
mediaPath = mediaPath.slice("MEDIA:".length);
|
|
1494
|
-
}
|
|
1495
|
-
mediaPath = normalizePath(mediaPath);
|
|
1496
|
-
// 处理可能被模型转义的路径
|
|
1497
|
-
// 1. 双反斜杠 -> 单反斜杠(Markdown 转义)
|
|
1498
|
-
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
|
1499
|
-
// 2. 八进制转义序列 + UTF-8 双重编码修复
|
|
1500
|
-
try {
|
|
1501
|
-
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
|
1502
|
-
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
|
1503
|
-
if (hasOctal || hasNonASCII) {
|
|
1504
|
-
log?.debug?.(`[qqbot:${account.accountId}] Decoding path with mixed encoding: ${mediaPath}`);
|
|
1505
|
-
// Step 1: 将八进制转义转换为字节
|
|
1506
|
-
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_, octal) => {
|
|
1507
|
-
return String.fromCharCode(parseInt(octal, 8));
|
|
1508
|
-
});
|
|
1509
|
-
// Step 2: 提取所有字节(包括 Latin-1 字符)
|
|
1510
|
-
const bytes = [];
|
|
1511
|
-
for (let i = 0; i < decoded.length; i++) {
|
|
1512
|
-
const code = decoded.charCodeAt(i);
|
|
1513
|
-
if (code <= 0xFF) {
|
|
1514
|
-
bytes.push(code);
|
|
1515
|
-
}
|
|
1516
|
-
else {
|
|
1517
|
-
const charBytes = Buffer.from(decoded[i], 'utf8');
|
|
1518
|
-
bytes.push(...charBytes);
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
// Step 3: 尝试按 UTF-8 解码
|
|
1522
|
-
const buffer = Buffer.from(bytes);
|
|
1523
|
-
const utf8Decoded = buffer.toString('utf8');
|
|
1524
|
-
if (!utf8Decoded.includes('\uFFFD') || utf8Decoded.length < decoded.length) {
|
|
1525
|
-
mediaPath = utf8Decoded;
|
|
1526
|
-
log?.debug?.(`[qqbot:${account.accountId}] Successfully decoded path: ${mediaPath}`);
|
|
1527
|
-
}
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
catch (decodeErr) {
|
|
1531
|
-
log?.error(`[qqbot:${account.accountId}] Path decode error: ${decodeErr}`);
|
|
1532
|
-
}
|
|
1533
|
-
if (mediaPath) {
|
|
1534
|
-
if (tagName === "qqmedia") {
|
|
1535
|
-
sendQueue.push({ type: "media", content: mediaPath });
|
|
1536
|
-
log?.info(`[qqbot:${account.accountId}] Found auto-detect media in <qqmedia>: ${mediaPath}`);
|
|
1537
|
-
}
|
|
1538
|
-
else if (tagName === "qqvoice") {
|
|
1539
|
-
sendQueue.push({ type: "voice", content: mediaPath });
|
|
1540
|
-
log?.info(`[qqbot:${account.accountId}] Found voice path in <qqvoice>: ${mediaPath}`);
|
|
1541
|
-
}
|
|
1542
|
-
else if (tagName === "qqvideo") {
|
|
1543
|
-
sendQueue.push({ type: "video", content: mediaPath });
|
|
1544
|
-
log?.info(`[qqbot:${account.accountId}] Found video URL in <qqvideo>: ${mediaPath}`);
|
|
1545
|
-
}
|
|
1546
|
-
else if (tagName === "qqfile") {
|
|
1547
|
-
sendQueue.push({ type: "file", content: mediaPath });
|
|
1548
|
-
log?.info(`[qqbot:${account.accountId}] Found file path in <qqfile>: ${mediaPath}`);
|
|
1549
|
-
}
|
|
1550
|
-
else {
|
|
1551
|
-
sendQueue.push({ type: "image", content: mediaPath });
|
|
1552
|
-
log?.info(`[qqbot:${account.accountId}] Found image path in <qqimg>: ${mediaPath}`);
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
lastIndex = match.index + match[0].length;
|
|
1556
|
-
}
|
|
1557
|
-
// 添加最后一个标签后的文本
|
|
1558
|
-
const textAfter = replyText.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
|
|
1559
|
-
if (textAfter) {
|
|
1560
|
-
sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
|
|
1561
|
-
}
|
|
1562
|
-
log?.info(`[qqbot:${account.accountId}] Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
|
|
1563
|
-
// 按顺序发送(使用 Telegram 风格的统一媒体发送函数)
|
|
1564
|
-
const mediaTarget = {
|
|
1565
|
-
targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
|
|
1566
|
-
targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid : event.channelId,
|
|
1567
|
-
account,
|
|
1568
|
-
replyToId: event.messageId,
|
|
1569
|
-
logPrefix: `[qqbot:${account.accountId}]`,
|
|
1570
|
-
};
|
|
1571
|
-
for (const item of sendQueue) {
|
|
1572
|
-
if (item.type === "text") {
|
|
1573
|
-
// 对长文本进行分块发送
|
|
1574
|
-
const textChunks = getQQBotRuntime().channel.text.chunkMarkdownText(item.content, TEXT_CHUNK_LIMIT);
|
|
1575
|
-
for (const chunk of textChunks) {
|
|
1576
|
-
try {
|
|
1577
|
-
await sendWithTokenRetry(async (token) => {
|
|
1578
|
-
const ref = consumeQuoteRef();
|
|
1579
|
-
if (event.type === "c2c") {
|
|
1580
|
-
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
1581
|
-
}
|
|
1582
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1583
|
-
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
1584
|
-
}
|
|
1585
|
-
else if (event.channelId) {
|
|
1586
|
-
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
1587
|
-
}
|
|
1588
|
-
});
|
|
1589
|
-
log?.info(`[qqbot:${account.accountId}] Sent text chunk (${chunk.length}/${item.content.length} chars): ${chunk.slice(0, 50)}...`);
|
|
1590
|
-
}
|
|
1591
|
-
catch (err) {
|
|
1592
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send text chunk: ${err}`);
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
else if (item.type === "image") {
|
|
1597
|
-
const result = await sendPhoto(mediaTarget, item.content);
|
|
1598
|
-
if (result.error) {
|
|
1599
|
-
log?.error(`[qqbot:${account.accountId}] sendPhoto error: ${result.error}`);
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
else if (item.type === "voice") {
|
|
1603
|
-
const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
|
|
1604
|
-
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
|
|
1605
|
-
// 语音发送加外层超时保护,避免阻塞后续发送队列
|
|
1606
|
-
const voiceTimeout = 45000; // 45 秒(waitForFile 30s + 转码/上传 15s)
|
|
1607
|
-
try {
|
|
1608
|
-
const result = await Promise.race([
|
|
1609
|
-
sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled),
|
|
1610
|
-
new Promise((resolve) => setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout)),
|
|
1611
|
-
]);
|
|
1612
|
-
if (result.error) {
|
|
1613
|
-
log?.error(`[qqbot:${account.accountId}] sendVoice error: ${result.error}`);
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
catch (err) {
|
|
1617
|
-
log?.error(`[qqbot:${account.accountId}] sendVoice unexpected error: ${err}`);
|
|
1618
|
-
}
|
|
1619
|
-
}
|
|
1620
|
-
else if (item.type === "video") {
|
|
1621
|
-
const result = await sendVideoMsg(mediaTarget, item.content);
|
|
1622
|
-
if (result.error) {
|
|
1623
|
-
log?.error(`[qqbot:${account.accountId}] sendVideoMsg error: ${result.error}`);
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
else if (item.type === "file") {
|
|
1627
|
-
const result = await sendDocument(mediaTarget, item.content);
|
|
1628
|
-
if (result.error) {
|
|
1629
|
-
log?.error(`[qqbot:${account.accountId}] sendDocument error: ${result.error}`);
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
else if (item.type === "media") {
|
|
1633
|
-
// qqmedia: 自动根据扩展名路由到 sendPhoto/sendVoice/sendVideoMsg/sendDocument
|
|
1634
|
-
const result = await sendMediaAuto({
|
|
1635
|
-
to: qualifiedTarget,
|
|
1636
|
-
text: "",
|
|
1637
|
-
mediaUrl: item.content,
|
|
1638
|
-
accountId: account.accountId,
|
|
1639
|
-
replyToId: event.messageId,
|
|
1640
|
-
account,
|
|
1641
|
-
});
|
|
1642
|
-
if (result.error) {
|
|
1643
|
-
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error: ${result.error}`);
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
// 记录活动并返回
|
|
820
|
+
// ============ 媒体标签解析 + 发送 ============
|
|
821
|
+
const deliverEvent = {
|
|
822
|
+
type: event.type,
|
|
823
|
+
senderId: event.senderId,
|
|
824
|
+
messageId: event.messageId,
|
|
825
|
+
channelId: event.channelId,
|
|
826
|
+
groupOpenid: event.groupOpenid,
|
|
827
|
+
msgIdx: event.msgIdx,
|
|
828
|
+
};
|
|
829
|
+
const deliverActx = { account, qualifiedTarget, log };
|
|
830
|
+
const mediaResult = await parseAndSendMediaTags(replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef);
|
|
831
|
+
if (mediaResult.handled) {
|
|
1648
832
|
pluginRuntime.channel.activity.record({
|
|
1649
833
|
channel: "qqbot",
|
|
1650
834
|
accountId: account.accountId,
|
|
@@ -1652,620 +836,18 @@ export async function startGateway(ctx) {
|
|
|
1652
836
|
});
|
|
1653
837
|
return;
|
|
1654
838
|
}
|
|
839
|
+
replyText = mediaResult.normalizedText;
|
|
1655
840
|
// ============ 结构化载荷检测与分发 ============
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
// 根据 type 分发到对应处理器
|
|
1667
|
-
if (isCronReminderPayload(parsedPayload)) {
|
|
1668
|
-
// ============ 定时提醒载荷处理 ============
|
|
1669
|
-
log?.info(`[qqbot:${account.accountId}] Processing cron_reminder payload`);
|
|
1670
|
-
// 将载荷编码为 Base64,构建 cron add 命令
|
|
1671
|
-
const cronMessage = encodePayloadForCron(parsedPayload);
|
|
1672
|
-
// 向用户确认提醒已设置(通过正常消息发送)
|
|
1673
|
-
const confirmText = `⏰ 提醒已设置,将在指定时间发送: "${parsedPayload.content}"`;
|
|
1674
|
-
try {
|
|
1675
|
-
await sendWithTokenRetry(async (token) => {
|
|
1676
|
-
if (event.type === "c2c") {
|
|
1677
|
-
await sendC2CMessage(token, event.senderId, confirmText, event.messageId);
|
|
1678
|
-
}
|
|
1679
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1680
|
-
await sendGroupMessage(token, event.groupOpenid, confirmText, event.messageId);
|
|
1681
|
-
}
|
|
1682
|
-
else if (event.channelId) {
|
|
1683
|
-
await sendChannelMessage(token, event.channelId, confirmText, event.messageId);
|
|
1684
|
-
}
|
|
1685
|
-
});
|
|
1686
|
-
log?.info(`[qqbot:${account.accountId}] Cron reminder confirmation sent, cronMessage: ${cronMessage}`);
|
|
1687
|
-
}
|
|
1688
|
-
catch (err) {
|
|
1689
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send cron confirmation: ${err}`);
|
|
1690
|
-
}
|
|
1691
|
-
// 记录活动并返回(cron add 命令需要由 AI 执行,这里只处理载荷)
|
|
1692
|
-
pluginRuntime.channel.activity.record({
|
|
1693
|
-
channel: "qqbot",
|
|
1694
|
-
accountId: account.accountId,
|
|
1695
|
-
direction: "outbound",
|
|
1696
|
-
});
|
|
1697
|
-
return;
|
|
1698
|
-
}
|
|
1699
|
-
else if (isMediaPayload(parsedPayload)) {
|
|
1700
|
-
// ============ 媒体消息载荷处理 ============
|
|
1701
|
-
log?.info(`[qqbot:${account.accountId}] Processing media payload, mediaType: ${parsedPayload.mediaType}`);
|
|
1702
|
-
if (parsedPayload.mediaType === "image") {
|
|
1703
|
-
// 处理图片发送(展开 ~ 路径)
|
|
1704
|
-
let imageUrl = normalizePath(parsedPayload.path);
|
|
1705
|
-
const originalImagePath = parsedPayload.source === "file" ? imageUrl : undefined;
|
|
1706
|
-
// 如果是本地文件,转换为 Base64 Data URL
|
|
1707
|
-
if (parsedPayload.source === "file") {
|
|
1708
|
-
try {
|
|
1709
|
-
if (!(await fileExistsAsync(imageUrl))) {
|
|
1710
|
-
log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
|
|
1711
|
-
return;
|
|
1712
|
-
}
|
|
1713
|
-
const imgSzCheck = checkFileSize(imageUrl);
|
|
1714
|
-
if (!imgSzCheck.ok) {
|
|
1715
|
-
log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
|
|
1716
|
-
return;
|
|
1717
|
-
}
|
|
1718
|
-
const fileBuffer = await readFileAsync(imageUrl);
|
|
1719
|
-
const base64Data = fileBuffer.toString("base64");
|
|
1720
|
-
const ext = path.extname(imageUrl).toLowerCase();
|
|
1721
|
-
const mimeTypes = {
|
|
1722
|
-
".jpg": "image/jpeg",
|
|
1723
|
-
".jpeg": "image/jpeg",
|
|
1724
|
-
".png": "image/png",
|
|
1725
|
-
".gif": "image/gif",
|
|
1726
|
-
".webp": "image/webp",
|
|
1727
|
-
".bmp": "image/bmp",
|
|
1728
|
-
};
|
|
1729
|
-
const mimeType = mimeTypes[ext];
|
|
1730
|
-
if (!mimeType) {
|
|
1731
|
-
log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
|
|
1732
|
-
return;
|
|
1733
|
-
}
|
|
1734
|
-
imageUrl = `data:${mimeType};base64,${base64Data}`;
|
|
1735
|
-
log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
|
|
1736
|
-
}
|
|
1737
|
-
catch (readErr) {
|
|
1738
|
-
log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
|
|
1739
|
-
return;
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
// 发送图片(传递原始本地路径以便 refIdx 缓存记录来源)
|
|
1743
|
-
try {
|
|
1744
|
-
await sendWithTokenRetry(async (token) => {
|
|
1745
|
-
if (event.type === "c2c") {
|
|
1746
|
-
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId, undefined, originalImagePath);
|
|
1747
|
-
}
|
|
1748
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1749
|
-
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
|
1750
|
-
}
|
|
1751
|
-
else if (event.channelId) {
|
|
1752
|
-
// 频道使用 Markdown 格式
|
|
1753
|
-
await sendChannelMessage(token, event.channelId, ``, event.messageId);
|
|
1754
|
-
}
|
|
1755
|
-
});
|
|
1756
|
-
log?.info(`[qqbot:${account.accountId}] Sent image via media payload`);
|
|
1757
|
-
// 如果有描述文本,单独发送
|
|
1758
|
-
if (parsedPayload.caption) {
|
|
1759
|
-
await sendWithTokenRetry(async (token) => {
|
|
1760
|
-
if (event.type === "c2c") {
|
|
1761
|
-
await sendC2CMessage(token, event.senderId, parsedPayload.caption, event.messageId);
|
|
1762
|
-
}
|
|
1763
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1764
|
-
await sendGroupMessage(token, event.groupOpenid, parsedPayload.caption, event.messageId);
|
|
1765
|
-
}
|
|
1766
|
-
else if (event.channelId) {
|
|
1767
|
-
await sendChannelMessage(token, event.channelId, parsedPayload.caption, event.messageId);
|
|
1768
|
-
}
|
|
1769
|
-
});
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
catch (err) {
|
|
1773
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
else if (parsedPayload.mediaType === "audio") {
|
|
1777
|
-
// TTS 语音发送:文字 → PCM → SILK → QQ 语音
|
|
1778
|
-
try {
|
|
1779
|
-
const ttsText = parsedPayload.caption || parsedPayload.path;
|
|
1780
|
-
if (!ttsText?.trim()) {
|
|
1781
|
-
log?.error(`[qqbot:${account.accountId}] Voice missing text`);
|
|
1782
|
-
}
|
|
1783
|
-
else {
|
|
1784
|
-
const ttsCfg = resolveTTSConfig(cfg);
|
|
1785
|
-
if (!ttsCfg) {
|
|
1786
|
-
log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
|
|
1787
|
-
}
|
|
1788
|
-
else {
|
|
1789
|
-
log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
|
|
1790
|
-
const ttsDir = getQQBotDataDir("tts");
|
|
1791
|
-
const { silkPath, silkBase64, duration } = await textToSilk(ttsText, ttsCfg, ttsDir);
|
|
1792
|
-
log?.info(`[qqbot:${account.accountId}] TTS done: ${formatDuration(duration)}, file saved: ${silkPath}`);
|
|
1793
|
-
await sendWithTokenRetry(async (token) => {
|
|
1794
|
-
if (event.type === "c2c") {
|
|
1795
|
-
await sendC2CVoiceMessage(token, event.senderId, silkBase64, event.messageId, ttsText, silkPath);
|
|
1796
|
-
}
|
|
1797
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1798
|
-
await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64, event.messageId);
|
|
1799
|
-
}
|
|
1800
|
-
else if (event.channelId) {
|
|
1801
|
-
log?.error(`[qqbot:${account.accountId}] Voice not supported in channel, sending text fallback`);
|
|
1802
|
-
await sendChannelMessage(token, event.channelId, ttsText, event.messageId);
|
|
1803
|
-
}
|
|
1804
|
-
});
|
|
1805
|
-
log?.info(`[qqbot:${account.accountId}] Voice message sent`);
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
catch (err) {
|
|
1810
|
-
log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
else if (parsedPayload.mediaType === "video") {
|
|
1814
|
-
// 视频发送:支持公网 URL 和本地文件
|
|
1815
|
-
try {
|
|
1816
|
-
const videoPath = normalizePath(parsedPayload.path ?? "");
|
|
1817
|
-
if (!videoPath?.trim()) {
|
|
1818
|
-
log?.error(`[qqbot:${account.accountId}] Video missing path`);
|
|
1819
|
-
}
|
|
1820
|
-
else {
|
|
1821
|
-
const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
|
|
1822
|
-
log?.info(`[qqbot:${account.accountId}] Video send: "${videoPath.slice(0, 60)}..."`);
|
|
1823
|
-
await sendWithTokenRetry(async (token) => {
|
|
1824
|
-
if (isHttpUrl) {
|
|
1825
|
-
// 公网 URL
|
|
1826
|
-
if (event.type === "c2c") {
|
|
1827
|
-
await sendC2CVideoMessage(token, event.senderId, videoPath, undefined, event.messageId);
|
|
1828
|
-
}
|
|
1829
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1830
|
-
await sendGroupVideoMessage(token, event.groupOpenid, videoPath, undefined, event.messageId);
|
|
1831
|
-
}
|
|
1832
|
-
else if (event.channelId) {
|
|
1833
|
-
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
|
1834
|
-
}
|
|
1835
|
-
}
|
|
1836
|
-
else {
|
|
1837
|
-
// 本地文件:读取为 Base64
|
|
1838
|
-
if (!(await fileExistsAsync(videoPath))) {
|
|
1839
|
-
throw new Error(`视频文件不存在: ${videoPath}`);
|
|
1840
|
-
}
|
|
1841
|
-
const vPaySzCheck = checkFileSize(videoPath);
|
|
1842
|
-
if (!vPaySzCheck.ok) {
|
|
1843
|
-
throw new Error(vPaySzCheck.error);
|
|
1844
|
-
}
|
|
1845
|
-
const fileBuffer = await readFileAsync(videoPath);
|
|
1846
|
-
const videoBase64 = fileBuffer.toString("base64");
|
|
1847
|
-
log?.info(`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
|
|
1848
|
-
if (event.type === "c2c") {
|
|
1849
|
-
await sendC2CVideoMessage(token, event.senderId, undefined, videoBase64, event.messageId, undefined, videoPath);
|
|
1850
|
-
}
|
|
1851
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1852
|
-
await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
|
|
1853
|
-
}
|
|
1854
|
-
else if (event.channelId) {
|
|
1855
|
-
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
});
|
|
1859
|
-
log?.info(`[qqbot:${account.accountId}] Video message sent`);
|
|
1860
|
-
// 如果有描述文本,单独发送
|
|
1861
|
-
if (parsedPayload.caption) {
|
|
1862
|
-
await sendWithTokenRetry(async (token) => {
|
|
1863
|
-
if (event.type === "c2c") {
|
|
1864
|
-
await sendC2CMessage(token, event.senderId, parsedPayload.caption, event.messageId);
|
|
1865
|
-
}
|
|
1866
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1867
|
-
await sendGroupMessage(token, event.groupOpenid, parsedPayload.caption, event.messageId);
|
|
1868
|
-
}
|
|
1869
|
-
else if (event.channelId) {
|
|
1870
|
-
await sendChannelMessage(token, event.channelId, parsedPayload.caption, event.messageId);
|
|
1871
|
-
}
|
|
1872
|
-
});
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
}
|
|
1876
|
-
catch (err) {
|
|
1877
|
-
log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
else if (parsedPayload.mediaType === "file") {
|
|
1881
|
-
// 文件发送
|
|
1882
|
-
try {
|
|
1883
|
-
const filePath = normalizePath(parsedPayload.path ?? "");
|
|
1884
|
-
if (!filePath?.trim()) {
|
|
1885
|
-
log?.error(`[qqbot:${account.accountId}] File missing path`);
|
|
1886
|
-
}
|
|
1887
|
-
else {
|
|
1888
|
-
const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
|
|
1889
|
-
const fileName = sanitizeFileName(path.basename(filePath));
|
|
1890
|
-
log?.info(`[qqbot:${account.accountId}] File send: "${filePath.slice(0, 60)}..." (${isHttpUrl ? "URL" : "local"})`);
|
|
1891
|
-
await sendWithTokenRetry(async (token) => {
|
|
1892
|
-
if (isHttpUrl) {
|
|
1893
|
-
if (event.type === "c2c") {
|
|
1894
|
-
await sendC2CFileMessage(token, event.senderId, undefined, filePath, event.messageId, fileName);
|
|
1895
|
-
}
|
|
1896
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1897
|
-
await sendGroupFileMessage(token, event.groupOpenid, undefined, filePath, event.messageId, fileName);
|
|
1898
|
-
}
|
|
1899
|
-
else if (event.channelId) {
|
|
1900
|
-
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
else {
|
|
1904
|
-
if (!(await fileExistsAsync(filePath))) {
|
|
1905
|
-
throw new Error(`文件不存在: ${filePath}`);
|
|
1906
|
-
}
|
|
1907
|
-
const fPaySzCheck = checkFileSize(filePath);
|
|
1908
|
-
if (!fPaySzCheck.ok) {
|
|
1909
|
-
throw new Error(fPaySzCheck.error);
|
|
1910
|
-
}
|
|
1911
|
-
const fileBuffer = await readFileAsync(filePath);
|
|
1912
|
-
const fileBase64 = fileBuffer.toString("base64");
|
|
1913
|
-
if (event.type === "c2c") {
|
|
1914
|
-
await sendC2CFileMessage(token, event.senderId, fileBase64, undefined, event.messageId, fileName, filePath);
|
|
1915
|
-
}
|
|
1916
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
1917
|
-
await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
|
|
1918
|
-
}
|
|
1919
|
-
else if (event.channelId) {
|
|
1920
|
-
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
});
|
|
1924
|
-
log?.info(`[qqbot:${account.accountId}] File message sent`);
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
catch (err) {
|
|
1928
|
-
log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
else {
|
|
1932
|
-
log?.error(`[qqbot:${account.accountId}] Unknown media type: ${parsedPayload.mediaType}`);
|
|
1933
|
-
}
|
|
1934
|
-
// 记录活动并返回
|
|
1935
|
-
pluginRuntime.channel.activity.record({
|
|
1936
|
-
channel: "qqbot",
|
|
1937
|
-
accountId: account.accountId,
|
|
1938
|
-
direction: "outbound",
|
|
1939
|
-
});
|
|
1940
|
-
return;
|
|
1941
|
-
}
|
|
1942
|
-
else {
|
|
1943
|
-
// 未知的载荷类型
|
|
1944
|
-
log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${parsedPayload.type}`);
|
|
1945
|
-
return;
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
}
|
|
1949
|
-
// ============ 非结构化消息:简化处理 ============
|
|
1950
|
-
const imageUrls = [];
|
|
1951
|
-
const localMediaToSend = []; // 本地路径走 sendMedia 自动路由
|
|
1952
|
-
/**
|
|
1953
|
-
* 收集媒体 URL/路径
|
|
1954
|
-
* - 公网 URL / Base64 Data URL → 图片处理管线
|
|
1955
|
-
* - 本地文件路径 → sendMedia 自动路由(根据扩展名识别类型)
|
|
1956
|
-
*/
|
|
1957
|
-
const collectImageUrl = (url) => {
|
|
1958
|
-
if (!url)
|
|
1959
|
-
return false;
|
|
1960
|
-
const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
|
|
1961
|
-
const isDataUrl = url.startsWith("data:image/");
|
|
1962
|
-
if (isHttpUrl || isDataUrl) {
|
|
1963
|
-
if (!imageUrls.includes(url)) {
|
|
1964
|
-
imageUrls.push(url);
|
|
1965
|
-
if (isDataUrl) {
|
|
1966
|
-
log?.info(`[qqbot:${account.accountId}] Collected Base64 image (length: ${url.length})`);
|
|
1967
|
-
}
|
|
1968
|
-
else {
|
|
1969
|
-
log?.info(`[qqbot:${account.accountId}] Collected media URL: ${url.slice(0, 80)}...`);
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
return true;
|
|
1973
|
-
}
|
|
1974
|
-
// 本地文件路径:走 sendMedia 自动路由
|
|
1975
|
-
if (isLocalFilePath(url)) {
|
|
1976
|
-
if (!localMediaToSend.includes(url)) {
|
|
1977
|
-
localMediaToSend.push(url);
|
|
1978
|
-
log?.info(`[qqbot:${account.accountId}] Collected local media for auto-routing: ${url}`);
|
|
1979
|
-
}
|
|
1980
|
-
return true;
|
|
1981
|
-
}
|
|
1982
|
-
return false;
|
|
1983
|
-
};
|
|
1984
|
-
// 处理 mediaUrls 和 mediaUrl 字段
|
|
1985
|
-
if (payload.mediaUrls?.length) {
|
|
1986
|
-
for (const url of payload.mediaUrls) {
|
|
1987
|
-
collectImageUrl(url);
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
if (payload.mediaUrl) {
|
|
1991
|
-
collectImageUrl(payload.mediaUrl);
|
|
1992
|
-
}
|
|
1993
|
-
// 提取文本中的图片格式(仅处理公网 URL)
|
|
1994
|
-
// 📝 设计:本地路径必须使用 QQBOT_PAYLOAD JSON 格式发送
|
|
1995
|
-
const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi;
|
|
1996
|
-
const mdMatches = [...replyText.matchAll(mdImageRegex)];
|
|
1997
|
-
for (const match of mdMatches) {
|
|
1998
|
-
const url = match[2]?.trim();
|
|
1999
|
-
if (url && !imageUrls.includes(url)) {
|
|
2000
|
-
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
2001
|
-
// 公网 URL:收集并处理
|
|
2002
|
-
imageUrls.push(url);
|
|
2003
|
-
log?.info(`[qqbot:${account.accountId}] Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
|
|
2004
|
-
}
|
|
2005
|
-
else if (isLocalFilePath(url)) {
|
|
2006
|
-
// 本地路径:走 sendMedia 自动路由
|
|
2007
|
-
if (!localMediaToSend.includes(url)) {
|
|
2008
|
-
localMediaToSend.push(url);
|
|
2009
|
-
log?.info(`[qqbot:${account.accountId}] Collected local media from markdown for auto-routing: ${url}`);
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
// 提取裸 URL 图片(公网 URL)
|
|
2015
|
-
const bareUrlRegex = /(?<![(\["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi;
|
|
2016
|
-
const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)];
|
|
2017
|
-
for (const match of bareUrlMatches) {
|
|
2018
|
-
const url = match[1];
|
|
2019
|
-
if (url && !imageUrls.includes(url)) {
|
|
2020
|
-
imageUrls.push(url);
|
|
2021
|
-
log?.info(`[qqbot:${account.accountId}] Extracted bare image URL: ${url.slice(0, 80)}...`);
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
// 判断是否使用 markdown 模式
|
|
2025
|
-
const useMarkdown = account.markdownSupport === true;
|
|
2026
|
-
log?.info(`[qqbot:${account.accountId}] Markdown mode: ${useMarkdown}, images: ${imageUrls.length}`);
|
|
2027
|
-
let textWithoutImages = replyText;
|
|
2028
|
-
// 🎯 过滤内部标记(如 [[reply_to: xxx]])
|
|
2029
|
-
// 这些标记可能被 AI 错误地学习并输出
|
|
2030
|
-
textWithoutImages = filterInternalMarkers(textWithoutImages);
|
|
2031
|
-
// 根据模式处理图片
|
|
2032
|
-
if (useMarkdown) {
|
|
2033
|
-
// ============ Markdown 模式 ============
|
|
2034
|
-
// 🎯 关键改动:区分公网 URL 和本地文件/Base64
|
|
2035
|
-
// - 公网 URL (http/https) → 使用 Markdown 图片格式 
|
|
2036
|
-
// - 本地文件/Base64 (data:image/...) → 使用富媒体 API 发送
|
|
2037
|
-
// 分离图片:公网 URL vs Base64/本地文件
|
|
2038
|
-
const httpImageUrls = []; // 公网 URL,用于 Markdown 嵌入
|
|
2039
|
-
const base64ImageUrls = []; // Base64,用于富媒体 API
|
|
2040
|
-
for (const url of imageUrls) {
|
|
2041
|
-
if (url.startsWith("data:image/")) {
|
|
2042
|
-
base64ImageUrls.push(url);
|
|
2043
|
-
}
|
|
2044
|
-
else if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
2045
|
-
httpImageUrls.push(url);
|
|
2046
|
-
}
|
|
2047
|
-
}
|
|
2048
|
-
log?.info(`[qqbot:${account.accountId}] Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`);
|
|
2049
|
-
// 🔹 第一步:通过富媒体 API 发送 Base64 图片(本地文件已转换为 Base64)
|
|
2050
|
-
if (base64ImageUrls.length > 0) {
|
|
2051
|
-
log?.info(`[qqbot:${account.accountId}] Sending ${base64ImageUrls.length} image(s) via Rich Media API...`);
|
|
2052
|
-
for (const imageUrl of base64ImageUrls) {
|
|
2053
|
-
try {
|
|
2054
|
-
await sendWithTokenRetry(async (token) => {
|
|
2055
|
-
if (event.type === "c2c") {
|
|
2056
|
-
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
|
|
2057
|
-
}
|
|
2058
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
2059
|
-
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
|
2060
|
-
}
|
|
2061
|
-
else if (event.channelId) {
|
|
2062
|
-
// 频道暂不支持富媒体,跳过
|
|
2063
|
-
log?.info(`[qqbot:${account.accountId}] Channel does not support rich media, skipping Base64 image`);
|
|
2064
|
-
}
|
|
2065
|
-
});
|
|
2066
|
-
log?.info(`[qqbot:${account.accountId}] Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`);
|
|
2067
|
-
}
|
|
2068
|
-
catch (imgErr) {
|
|
2069
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send Base64 image via Rich Media API: ${imgErr}`);
|
|
2070
|
-
}
|
|
2071
|
-
}
|
|
2072
|
-
}
|
|
2073
|
-
// 🔹 第二步:处理文本和公网 URL 图片
|
|
2074
|
-
// 记录已存在于文本中的 markdown 图片 URL
|
|
2075
|
-
const existingMdUrls = new Set(mdMatches.map(m => m[2]));
|
|
2076
|
-
// 需要追加的公网图片(从 mediaUrl/mediaUrls 来的,且不在文本中)
|
|
2077
|
-
const imagesToAppend = [];
|
|
2078
|
-
// 处理需要追加的公网 URL 图片:获取尺寸并格式化
|
|
2079
|
-
for (const url of httpImageUrls) {
|
|
2080
|
-
if (!existingMdUrls.has(url)) {
|
|
2081
|
-
// 这个 URL 不在文本的 markdown 格式中,需要追加
|
|
2082
|
-
try {
|
|
2083
|
-
const size = await getImageSize(url);
|
|
2084
|
-
const mdImage = formatQQBotMarkdownImage(url, size);
|
|
2085
|
-
imagesToAppend.push(mdImage);
|
|
2086
|
-
log?.info(`[qqbot:${account.accountId}] Formatted HTTP image: ${size ? `${size.width}x${size.height}` : 'default size'} - ${url.slice(0, 60)}...`);
|
|
2087
|
-
}
|
|
2088
|
-
catch (err) {
|
|
2089
|
-
log?.info(`[qqbot:${account.accountId}] Failed to get image size, using default: ${err}`);
|
|
2090
|
-
const mdImage = formatQQBotMarkdownImage(url, null);
|
|
2091
|
-
imagesToAppend.push(mdImage);
|
|
2092
|
-
}
|
|
2093
|
-
}
|
|
2094
|
-
}
|
|
2095
|
-
// 处理文本中已有的 markdown 图片:补充公网 URL 的尺寸信息
|
|
2096
|
-
// 📝 本地路径不再特殊处理(保留在文本中),因为不通过非结构化消息发送
|
|
2097
|
-
for (const match of mdMatches) {
|
|
2098
|
-
const fullMatch = match[0]; // 
|
|
2099
|
-
const imgUrl = match[2]; // url 部分
|
|
2100
|
-
// 只处理公网 URL,补充尺寸信息
|
|
2101
|
-
const isHttpUrl = imgUrl.startsWith('http://') || imgUrl.startsWith('https://');
|
|
2102
|
-
if (isHttpUrl && !hasQQBotImageSize(fullMatch)) {
|
|
2103
|
-
try {
|
|
2104
|
-
const size = await getImageSize(imgUrl);
|
|
2105
|
-
const newMdImage = formatQQBotMarkdownImage(imgUrl, size);
|
|
2106
|
-
textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage);
|
|
2107
|
-
log?.info(`[qqbot:${account.accountId}] Updated image with size: ${size ? `${size.width}x${size.height}` : 'default'} - ${imgUrl.slice(0, 60)}...`);
|
|
2108
|
-
}
|
|
2109
|
-
catch (err) {
|
|
2110
|
-
log?.info(`[qqbot:${account.accountId}] Failed to get image size for existing md, using default: ${err}`);
|
|
2111
|
-
const newMdImage = formatQQBotMarkdownImage(imgUrl, null);
|
|
2112
|
-
textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage);
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
// 从文本中移除裸 URL 图片(已转换为 markdown 格式)
|
|
2117
|
-
for (const match of bareUrlMatches) {
|
|
2118
|
-
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
|
2119
|
-
}
|
|
2120
|
-
// 追加需要添加的公网图片到文本末尾
|
|
2121
|
-
if (imagesToAppend.length > 0) {
|
|
2122
|
-
textWithoutImages = textWithoutImages.trim();
|
|
2123
|
-
if (textWithoutImages) {
|
|
2124
|
-
textWithoutImages += "\n\n" + imagesToAppend.join("\n");
|
|
2125
|
-
}
|
|
2126
|
-
else {
|
|
2127
|
-
textWithoutImages = imagesToAppend.join("\n");
|
|
2128
|
-
}
|
|
2129
|
-
}
|
|
2130
|
-
// 🔹 第三步:发送带公网图片的 markdown 消息
|
|
2131
|
-
if (textWithoutImages.trim()) {
|
|
2132
|
-
const mdChunks = chunkText(textWithoutImages, TEXT_CHUNK_LIMIT);
|
|
2133
|
-
for (const chunk of mdChunks) {
|
|
2134
|
-
try {
|
|
2135
|
-
await sendWithTokenRetry(async (token) => {
|
|
2136
|
-
const ref = consumeQuoteRef();
|
|
2137
|
-
if (event.type === "c2c") {
|
|
2138
|
-
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
2139
|
-
}
|
|
2140
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
2141
|
-
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
2142
|
-
}
|
|
2143
|
-
else if (event.channelId) {
|
|
2144
|
-
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
2145
|
-
}
|
|
2146
|
-
});
|
|
2147
|
-
log?.info(`[qqbot:${account.accountId}] Sent markdown chunk (${chunk.length}/${textWithoutImages.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`);
|
|
2148
|
-
}
|
|
2149
|
-
catch (err) {
|
|
2150
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send markdown message chunk: ${err}`);
|
|
2151
|
-
}
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2155
|
-
else {
|
|
2156
|
-
// ============ 普通文本模式:使用 sendPhoto 发送图片(内置 URL fallback) ============
|
|
2157
|
-
const imgMediaTarget = {
|
|
2158
|
-
targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
|
|
2159
|
-
targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid : event.channelId,
|
|
2160
|
-
account,
|
|
2161
|
-
replyToId: event.messageId,
|
|
2162
|
-
logPrefix: `[qqbot:${account.accountId}]`,
|
|
2163
|
-
};
|
|
2164
|
-
// 从文本中移除所有图片相关内容
|
|
2165
|
-
for (const match of mdMatches) {
|
|
2166
|
-
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
|
2167
|
-
}
|
|
2168
|
-
for (const match of bareUrlMatches) {
|
|
2169
|
-
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
|
2170
|
-
}
|
|
2171
|
-
// 处理文本中的 URL 点号(防止被 QQ 解析为链接),仅群聊时过滤,C2C 不过滤
|
|
2172
|
-
if (textWithoutImages && event.type !== "c2c") {
|
|
2173
|
-
textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
|
2174
|
-
}
|
|
2175
|
-
try {
|
|
2176
|
-
// 发送图片(通过 sendPhoto,内置 URL 直传 → 下载 fallback)
|
|
2177
|
-
for (const imageUrl of imageUrls) {
|
|
2178
|
-
try {
|
|
2179
|
-
const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
|
|
2180
|
-
if (imgResult.error) {
|
|
2181
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgResult.error}`);
|
|
2182
|
-
}
|
|
2183
|
-
else {
|
|
2184
|
-
log?.info(`[qqbot:${account.accountId}] Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
|
|
2185
|
-
}
|
|
2186
|
-
}
|
|
2187
|
-
catch (imgErr) {
|
|
2188
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
// 发送文本消息(分块)
|
|
2192
|
-
if (textWithoutImages.trim()) {
|
|
2193
|
-
const plainChunks = chunkText(textWithoutImages, TEXT_CHUNK_LIMIT);
|
|
2194
|
-
for (const chunk of plainChunks) {
|
|
2195
|
-
await sendWithTokenRetry(async (token) => {
|
|
2196
|
-
const ref = consumeQuoteRef();
|
|
2197
|
-
if (event.type === "c2c") {
|
|
2198
|
-
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
2199
|
-
}
|
|
2200
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
2201
|
-
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
2202
|
-
}
|
|
2203
|
-
else if (event.channelId) {
|
|
2204
|
-
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
2205
|
-
}
|
|
2206
|
-
});
|
|
2207
|
-
log?.info(`[qqbot:${account.accountId}] Sent text chunk (${chunk.length}/${textWithoutImages.length} chars) (${event.type})`);
|
|
2208
|
-
}
|
|
2209
|
-
}
|
|
2210
|
-
}
|
|
2211
|
-
catch (err) {
|
|
2212
|
-
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
|
|
2213
|
-
}
|
|
2214
|
-
}
|
|
2215
|
-
// 发送 localMediaToSend 中收集到的本地媒体(由 payload.mediaUrl 或 markdown 本地路径触发)
|
|
2216
|
-
if (localMediaToSend.length > 0) {
|
|
2217
|
-
log?.info(`[qqbot:${account.accountId}] Sending ${localMediaToSend.length} local media via sendMedia auto-routing`);
|
|
2218
|
-
for (const mediaPath of localMediaToSend) {
|
|
2219
|
-
try {
|
|
2220
|
-
const result = await sendMediaAuto({
|
|
2221
|
-
to: qualifiedTarget,
|
|
2222
|
-
text: "",
|
|
2223
|
-
mediaUrl: mediaPath,
|
|
2224
|
-
accountId: account.accountId,
|
|
2225
|
-
replyToId: event.messageId,
|
|
2226
|
-
account,
|
|
2227
|
-
});
|
|
2228
|
-
if (result.error) {
|
|
2229
|
-
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error for ${mediaPath}: ${result.error}`);
|
|
2230
|
-
}
|
|
2231
|
-
else {
|
|
2232
|
-
log?.info(`[qqbot:${account.accountId}] Sent local media: ${mediaPath}`);
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
|
-
catch (err) {
|
|
2236
|
-
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) failed for ${mediaPath}: ${err}`);
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
// ============ 转发 tool 阶段收集的媒体(TTS 语音、生成图片等) ============
|
|
2241
|
-
// block 回复已发送,但 tool deliver 阶段收集的媒体 URL 未被发送(tool deliver 只收集不发送)。
|
|
2242
|
-
// 此处主动转发,避免工具产出的媒体被静默丢弃。
|
|
2243
|
-
if (toolMediaUrls.length > 0) {
|
|
2244
|
-
log?.info(`[qqbot:${account.accountId}] Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`);
|
|
2245
|
-
for (const mediaUrl of toolMediaUrls) {
|
|
2246
|
-
try {
|
|
2247
|
-
const result = await sendMediaAuto({
|
|
2248
|
-
to: qualifiedTarget,
|
|
2249
|
-
text: "",
|
|
2250
|
-
mediaUrl,
|
|
2251
|
-
accountId: account.accountId,
|
|
2252
|
-
replyToId: event.messageId,
|
|
2253
|
-
account,
|
|
2254
|
-
});
|
|
2255
|
-
if (result.error) {
|
|
2256
|
-
log?.error(`[qqbot:${account.accountId}] Tool media forward error: ${result.error}`);
|
|
2257
|
-
}
|
|
2258
|
-
else {
|
|
2259
|
-
log?.info(`[qqbot:${account.accountId}] Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
|
|
2260
|
-
}
|
|
2261
|
-
}
|
|
2262
|
-
catch (err) {
|
|
2263
|
-
log?.error(`[qqbot:${account.accountId}] Tool media forward failed: ${err}`);
|
|
2264
|
-
}
|
|
2265
|
-
}
|
|
2266
|
-
// 清空已转发的 URL,避免 tool-only 兜底重复发送
|
|
2267
|
-
toolMediaUrls.length = 0;
|
|
2268
|
-
}
|
|
841
|
+
const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
|
|
842
|
+
channel: "qqbot",
|
|
843
|
+
accountId: account.accountId,
|
|
844
|
+
direction: "outbound",
|
|
845
|
+
});
|
|
846
|
+
const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
|
|
847
|
+
if (handled)
|
|
848
|
+
return;
|
|
849
|
+
// ============ 非结构化消息发送 ============
|
|
850
|
+
await sendPlainReply(payload, replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef, toolMediaUrls);
|
|
2269
851
|
pluginRuntime.channel.activity.record({
|
|
2270
852
|
channel: "qqbot",
|
|
2271
853
|
accountId: account.accountId,
|
|
@@ -2322,6 +904,10 @@ export async function startGateway(ctx) {
|
|
|
2322
904
|
catch (err) {
|
|
2323
905
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
|
2324
906
|
}
|
|
907
|
+
finally {
|
|
908
|
+
// 无论成功/失败/超时,都停止输入状态续期
|
|
909
|
+
typing.keepAlive?.stop();
|
|
910
|
+
}
|
|
2325
911
|
};
|
|
2326
912
|
ws.on("open", () => {
|
|
2327
913
|
log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
|
|
@@ -2329,7 +915,7 @@ export async function startGateway(ctx) {
|
|
|
2329
915
|
reconnectAttempts = 0; // 连接成功,重置重试计数
|
|
2330
916
|
lastConnectTime = Date.now(); // 记录连接时间
|
|
2331
917
|
// 启动消息处理器(异步处理,防止阻塞心跳)
|
|
2332
|
-
|
|
918
|
+
msgQueue.startProcessor(handleMessage);
|
|
2333
919
|
// P1-1: 启动后台 Token 刷新
|
|
2334
920
|
startBackgroundTokenRefresh(account.appId, account.clientSecret, {
|
|
2335
921
|
log: log,
|
|
@@ -2418,7 +1004,7 @@ export async function startGateway(ctx) {
|
|
|
2418
1004
|
}
|
|
2419
1005
|
else {
|
|
2420
1006
|
isFirstReadyGlobal = false;
|
|
2421
|
-
sendStartupGreetings("READY");
|
|
1007
|
+
sendStartupGreetings(adminCtx, "READY");
|
|
2422
1008
|
} // end isFirstReady
|
|
2423
1009
|
}
|
|
2424
1010
|
else if (t === "RESUMED") {
|
|
@@ -2427,7 +1013,7 @@ export async function startGateway(ctx) {
|
|
|
2427
1013
|
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
2428
1014
|
if (isFirstReadyGlobal) {
|
|
2429
1015
|
isFirstReadyGlobal = false;
|
|
2430
|
-
sendStartupGreetings("RESUMED");
|
|
1016
|
+
sendStartupGreetings(adminCtx, "RESUMED");
|
|
2431
1017
|
}
|
|
2432
1018
|
// P1-2: 更新 Session 连接时间
|
|
2433
1019
|
if (sessionId) {
|