1→import WebSocket from "ws"; 2→import path from "node:path"; 3→import * as fs from "node:fs"; 4→import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; 5→import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify } from "./api.js"; 6→import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js"; 7→import { recordKnownUser, flushKnownUsers } from "./known-users.js"; 8→import { getQQBotRuntime } from "./runtime.js"; 9→import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js"; 10→import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js"; 11→import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js"; 12→import { convertSilkToWav, isVoiceAttachment, formatDuration } from "./utils/audio-convert.js"; 13→ 14→// QQ Bot intents - 按权限级别分组 15→const INTENTS = { 16→ // 基础权限(默认有) 17→ GUILDS: 1 << 0, // 频道相关 18→ GUILD_MEMBERS: 1 << 1, // 频道成员 19→ PUBLIC_GUILD_MESSAGES: 1 << 30, // 频道公开消息(公域) 20→ // 需要申请的权限 21→ DIRECT_MESSAGE: 1 << 12, // 频道私信 22→ GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请) 23→}; 24→ 25→// 权限级别:从高到低依次尝试 26→const INTENT_LEVELS = [ 27→ // Level 0: 完整权限(群聊 + 私信 + 频道) 28→ { 29→ name: "full", 30→ intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C, 31→ description: "群聊+私信+频道", 32→ }, 33→ // Level 1: 群聊 + 频道(无私信) 34→ { 35→ name: "group+channel", 36→ intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GROUP_AND_C2C, 37→ description: "群聊+频道", 38→ }, 39→ // Level 2: 仅频道(基础权限) 40→ { 41→ name: "channel-only", 42→ intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GUILD_MEMBERS, 43→ description: "仅频道消息", 44→ }, 45→]; 46→ 47→// 重连配置 48→const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟 49→const RATE_LIMIT_DELAY = 60000; // 遇到频率限制时等待 60 秒 50→const MAX_RECONNECT_ATTEMPTS = 100; 51→const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值 52→const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开 53→ 54→// 图床服务器配置(可通过环境变量覆盖) 55→const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765", 10); 56→// 使用绝对路径,确保文件保存和读取使用同一目录 57→const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process.env.HOME || "/home/ubuntu", ".openclaw", "qqbot", "images"); 58→ 59→// 消息队列配置(异步处理,防止阻塞心跳) 60→const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度 61→const MESSAGE_QUEUE_WARN_THRESHOLD = 800; // 队列告警阈值 62→ 63→// ============ 消息回复限流器 ============ 64→// 同一 message_id 1小时内最多回复 4 次,超过1小时需降级为主动消息 65→const MESSAGE_REPLY_LIMIT = 4; 66→const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时 67→ 68→interface MessageReplyRecord { 69→ count: number; 70→ firstReplyAt: number; 71→} 72→ 73→const messageReplyTracker = new Map(); 74→ 75→/** 76→ * 检查是否可以回复该消息(限流检查) 77→ * @param messageId 消息ID 78→ * @returns { allowed: boolean, remaining: number } allowed=是否允许回复,remaining=剩余次数 79→ */ 80→function checkMessageReplyLimit(messageId: string): { allowed: boolean; remaining: number } { 81→ const now = Date.now(); 82→ const record = messageReplyTracker.get(messageId); 83→ 84→ // 清理过期记录(定期清理,避免内存泄漏) 85→ if (messageReplyTracker.size > 10000) { 86→ for (const [id, rec] of messageReplyTracker) { 87→ if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) { 88→ messageReplyTracker.delete(id); 89→ } 90→ } 91→ } 92→ 93→ if (!record) { 94→ return { allowed: true, remaining: MESSAGE_REPLY_LIMIT }; 95→ } 96→ 97→ // 检查是否过期 98→ if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { 99→ messageReplyTracker.delete(messageId); 100→ return { allowed: true, remaining: MESSAGE_REPLY_LIMIT }; 101→ } 102→ 103→ // 检查是否超过限制 104→ const remaining = MESSAGE_REPLY_LIMIT - record.count; 105→ return { allowed: remaining > 0, remaining: Math.max(0, remaining) }; 106→} 107→ 108→/** 109→ * 记录一次消息回复 110→ * @param messageId 消息ID 111→ */ 112→function recordMessageReply(messageId: string): void { 113→ const now = Date.now(); 114→ const record = messageReplyTracker.get(messageId); 115→ 116→ if (!record) { 117→ messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); 118→ } else { 119→ // 检查是否过期,过期则重新计数 120→ if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { 121→ messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); 122→ } else { 123→ record.count++; 124→ } 125→ } 126→} 127→ 128→// ============ QQ 表情标签解析 ============ 129→ 130→/** 131→ * 解析 QQ 表情标签,将 格式 132→ * 替换为 【表情: 中文名】 格式 133→ * ext 字段为 Base64 编码的 JSON,格式如 {"text":"呲牙"} 134→ */ 135→function parseFaceTags(text: string): string { 136→ if (!text) return text; 137→ 138→ // 匹配 格式的表情标签 139→ return text.replace(//g, (_match, ext: string) => { 140→ try { 141→ const decoded = Buffer.from(ext, "base64").toString("utf-8"); 142→ const parsed = JSON.parse(decoded); 143→ const faceName = parsed.text || "未知表情"; 144→ return `【表情: ${faceName}】`; 145→ } catch { 146→ return _match; 147→ } 148→ }); 149→} 150→ 151→// ============ 内部标记过滤 ============ 152→ 153→/** 154→ * 过滤内部标记(如 [[reply_to: xxx]]) 155→ * 这些标记可能被 AI 错误地学习并输出,需要在发送前移除 156→ */ 157→function filterInternalMarkers(text: string): string { 158→ if (!text) return text; 159→ 160→ // 过滤 [[xxx: yyy]] 格式的内部标记 161→ // 例如: [[reply_to: ROBOT1.0_kbc...]] 162→ let result = text.replace(/\[\[[a-z_]+:\s*[^\]]*\]\]/gi, ""); 163→ 164→ // 清理可能产生的多余空行 165→ result = result.replace(/\n{3,}/g, "\n\n").trim(); 166→ 167→ return result; 168→} 169→ 170→export interface GatewayContext { 171→ account: ResolvedQQBotAccount; 172→ abortSignal: AbortSignal; 173→ cfg: unknown; 174→ onReady?: (data: unknown) => void; 175→ onError?: (error: Error) => void; 176→ log?: { 177→ info: (msg: string) => void; 178→ error: (msg: string) => void; 179→ debug?: (msg: string) => void; 180→ }; 181→} 182→ 183→/** 184→ * 消息队列项类型(用于异步处理消息,防止阻塞心跳) 185→ */ 186→interface QueuedMessage { 187→ type: "c2c" | "guild" | "dm" | "group"; 188→ senderId: string; 189→ senderName?: string; 190→ content: string; 191→ messageId: string; 192→ timestamp: string; 193→ channelId?: string; 194→ guildId?: string; 195→ groupOpenid?: string; 196→ attachments?: Array<{ content_type: string; url: string; filename?: string }>; 197→} 198→ 199→/** 200→ * 启动图床服务器 201→ */ 202→async function ensureImageServer(log?: GatewayContext["log"], publicBaseUrl?: string): Promise { 203→ if (isImageServerRunning()) { 204→ return publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`; 205→ } 206→ 207→ try { 208→ const config: Partial = { 209→ port: IMAGE_SERVER_PORT, 210→ storageDir: IMAGE_SERVER_DIR, 211→ // 使用用户配置的公网地址,而不是 0.0.0.0 212→ baseUrl: publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`, 213→ ttlSeconds: 3600, // 1 小时过期 214→ }; 215→ await startImageServer(config); 216→ log?.info(`[qqbot] Image server started on port ${IMAGE_SERVER_PORT}, baseUrl: ${config.baseUrl}`); 217→ return config.baseUrl!; 218→ } catch (err) { 219→ log?.error(`[qqbot] Failed to start image server: ${err}`); 220→ return null; 221→ } 222→} 223→ 224→/** 225→ * 启动 Gateway WebSocket 连接(带自动重连) 226→ * 支持流式消息发送 227→ */ 228→export async function startGateway(ctx: GatewayContext): Promise { 229→ const { account, abortSignal, cfg, onReady, onError, log } = ctx; 230→ 231→ if (!account.appId || !account.clientSecret) { 232→ throw new Error("QQBot not configured (missing appId or clientSecret)"); 233→ } 234→ 235→ // 初始化 API 配置(markdown 支持) 236→ initApiConfig({ 237→ markdownSupport: account.markdownSupport, 238→ }); 239→ log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport === true}`); 240→ 241→ // 如果配置了公网 URL,启动图床服务器 242→ let imageServerBaseUrl: string | null = null; 243→ if (account.imageServerBaseUrl) { 244→ // 使用用户配置的公网地址作为 baseUrl 245→ await ensureImageServer(log, account.imageServerBaseUrl); 246→ imageServerBaseUrl = account.imageServerBaseUrl; 247→ log?.info(`[qqbot:${account.accountId}] Image server enabled with URL: ${imageServerBaseUrl}`); 248→ } else { 249→ log?.info(`[qqbot:${account.accountId}] Image server disabled (no imageServerBaseUrl configured)`); 250→ } 251→ 252→ let reconnectAttempts = 0; 253→ let isAborted = false; 254→ let currentWs: WebSocket | null = null; 255→ let heartbeatInterval: ReturnType | null = null; 256→ let sessionId: string | null = null; 257→ let lastSeq: number | null = null; 258→ let lastConnectTime: number = 0; // 上次连接成功的时间 259→ let quickDisconnectCount = 0; // 连续快速断开次数 260→ let isConnecting = false; // 防止并发连接 261→ let reconnectTimer: ReturnType | null = null; // 重连定时器 262→ let shouldRefreshToken = false; // 下次连接是否需要刷新 token 263→ let intentLevelIndex = 0; // 当前尝试的权限级别索引 264→ let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别 265→ 266→ // ============ P1-2: 尝试从持久化存储恢复 Session ============ 267→ const savedSession = loadSession(account.accountId); 268→ if (savedSession) { 269→ sessionId = savedSession.sessionId; 270→ lastSeq = savedSession.lastSeq; 271→ intentLevelIndex = savedSession.intentLevelIndex; 272→ lastSuccessfulIntentLevel = savedSession.intentLevelIndex; 273→ log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}, intentLevel=${intentLevelIndex}`); 274→ } 275→ 276→ // ============ 消息队列(异步处理,防止阻塞心跳) ============ 277→ const messageQueue: QueuedMessage[] = []; 278→ let messageProcessorRunning = false; 279→ let messagesProcessed = 0; // 统计已处理消息数 280→ 281→ /** 282→ * 将消息加入队列(非阻塞) 283→ */ 284→ const enqueueMessage = (msg: QueuedMessage): void => { 285→ if (messageQueue.length >= MESSAGE_QUEUE_SIZE) { 286→ // 队列满了,丢弃最旧的消息 287→ const dropped = messageQueue.shift(); 288→ log?.error(`[qqbot:${account.accountId}] Message queue full, dropping oldest message from ${dropped?.senderId}`); 289→ } 290→ if (messageQueue.length >= MESSAGE_QUEUE_WARN_THRESHOLD) { 291→ log?.info(`[qqbot:${account.accountId}] Message queue size: ${messageQueue.length}/${MESSAGE_QUEUE_SIZE}`); 292→ } 293→ messageQueue.push(msg); 294→ log?.debug?.(`[qqbot:${account.accountId}] Message enqueued, queue size: ${messageQueue.length}`); 295→ }; 296→ 297→ /** 298→ * 启动消息处理循环(独立于 WS 消息循环) 299→ */ 300→ const startMessageProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise): void => { 301→ if (messageProcessorRunning) return; 302→ messageProcessorRunning = true; 303→ 304→ const processLoop = async () => { 305→ while (!isAborted) { 306→ if (messageQueue.length === 0) { 307→ // 队列为空,等待一小段时间 308→ await new Promise(resolve => setTimeout(resolve, 50)); 309→ continue; 310→ } 311→ 312→ const msg = messageQueue.shift()!; 313→ try { 314→ await handleMessageFn(msg); 315→ messagesProcessed++; 316→ } catch (err) { 317→ // 捕获处理异常,防止影响队列循环 318→ log?.error(`[qqbot:${account.accountId}] Message processor error: ${err}`); 319→ } 320→ } 321→ messageProcessorRunning = false; 322→ log?.info(`[qqbot:${account.accountId}] Message processor stopped`); 323→ }; 324→ 325→ // 异步启动,不阻塞调用者 326→ processLoop().catch(err => { 327→ log?.error(`[qqbot:${account.accountId}] Message processor crashed: ${err}`); 328→ messageProcessorRunning = false; 329→ }); 330→ 331→ log?.info(`[qqbot:${account.accountId}] Message processor started`); 332→ }; 333→ 334→ abortSignal.addEventListener("abort", () => { 335→ isAborted = true; 336→ if (reconnectTimer) { 337→ clearTimeout(reconnectTimer); 338→ reconnectTimer = null; 339→ } 340→ cleanup(); 341→ // P1-1: 停止后台 Token 刷新 342→ stopBackgroundTokenRefresh(); 343→ // P1-3: 保存已知用户数据 344→ flushKnownUsers(); 345→ }); 346→ 347→ const cleanup = () => { 348→ if (heartbeatInterval) { 349→ clearInterval(heartbeatInterval); 350→ heartbeatInterval = null; 351→ } 352→ if (currentWs && (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING)) { 353→ currentWs.close(); 354→ } 355→ currentWs = null; 356→ }; 357→ 358→ const getReconnectDelay = () => { 359→ const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1); 360→ return RECONNECT_DELAYS[idx]; 361→ }; 362→ 363→ const scheduleReconnect = (customDelay?: number) => { 364→ if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { 365→ log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`); 366→ return; 367→ } 368→ 369→ // 取消已有的重连定时器 370→ if (reconnectTimer) { 371→ clearTimeout(reconnectTimer); 372→ reconnectTimer = null; 373→ } 374→ 375→ const delay = customDelay ?? getReconnectDelay(); 376→ reconnectAttempts++; 377→ log?.info(`[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`); 378→ 379→ reconnectTimer = setTimeout(() => { 380→ reconnectTimer = null; 381→ if (!isAborted) { 382→ connect(); 383→ } 384→ }, delay); 385→ }; 386→ 387→ const connect = async () => { 388→ // 防止并发连接 389→ if (isConnecting) { 390→ log?.debug?.(`[qqbot:${account.accountId}] Already connecting, skip`); 391→ return; 392→ } 393→ isConnecting = true; 394→ 395→ try { 396→ cleanup(); 397→ 398→ // 如果标记了需要刷新 token,则清除缓存 399→ if (shouldRefreshToken) { 400→ log?.info(`[qqbot:${account.accountId}] Refreshing token...`); 401→ clearTokenCache(); 402→ shouldRefreshToken = false; 403→ } 404→ 405→ const accessToken = await getAccessToken(account.appId, account.clientSecret); 406→ log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`); 407→ const gatewayUrl = await getGatewayUrl(accessToken); 408→ 409→ log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`); 410→ 411→ const ws = new WebSocket(gatewayUrl); 412→ currentWs = ws; 413→ 414→ const pluginRuntime = getQQBotRuntime(); 415→ 416→ // 处理收到的消息 417→ const handleMessage = async (event: { 418→ type: "c2c" | "guild" | "dm" | "group"; 419→ senderId: string; 420→ senderName?: string; 421→ content: string; 422→ messageId: string; 423→ timestamp: string; 424→ channelId?: string; 425→ guildId?: string; 426→ groupOpenid?: string; 427→ attachments?: Array<{ content_type: string; url: string; filename?: string }>; 428→ }) => { 429→ 430→ log?.debug?.(`[qqbot:${account.accountId}] Received message: ${JSON.stringify(event)}`); 431→ log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`); 432→ if (event.attachments?.length) { 433→ log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`); 434→ } 435→ 436→ pluginRuntime.channel.activity.record({ 437→ channel: "qqbot", 438→ accountId: account.accountId, 439→ direction: "inbound", 440→ }); 441→ 442→ try{ 443→ await sendC2CInputNotify(accessToken, event.senderId, event.messageId, 60); 444→ log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`); 445→ }catch(err){ 446→ log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`); 447→ } 448→ 449→ const isGroup = event.type === "guild" || event.type === "group"; 450→ const peerId = event.type === "guild" ? `channel:${event.channelId}` 451→ : event.type === "group" ? `group:${event.groupOpenid}` 452→ : event.senderId; 453→ 454→ const route = pluginRuntime.channel.routing.resolveAgentRoute({ 455→ cfg, 456→ channel: "qqbot", 457→ accountId: account.accountId, 458→ peer: { 459→ kind: isGroup ? "group" : "dm", 460→ id: peerId, 461→ }, 462→ }); 463→ 464→ const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg); 465→ 466→ // 组装消息体 467→ // 静态系统提示已移至 skills/qqbot-cron/SKILL.md 和 skills/qqbot-media/SKILL.md 468→ // BodyForAgent 只保留必要的动态上下文信息 469→ 470→ // ============ 用户标识信息(用于定时提醒和主动消息) ============ 471→ const isGroupChat = event.type === "group"; 472→ const targetAddress = isGroupChat ? `group:${event.groupOpenid}` : event.senderId; 473→ 474→ // 收集额外的系统提示(如果配置了账户级别的 systemPrompt) 475→ const systemPrompts: string[] = []; 476→ if (account.systemPrompt) { 477→ systemPrompts.push(account.systemPrompt); 478→ } 479→ 480→ // 处理附件(图片等)- 下载到本地供 clawdbot 访问 481→ let attachmentInfo = ""; 482→ const imageUrls: string[] = []; 483→ const imageMediaTypes: string[] = []; 484→ // 存到 .openclaw/qqbot 目录下的 downloads 文件夹 485→ const downloadDir = path.join(process.env.HOME || "/home/ubuntu", ".openclaw", "qqbot", "downloads"); 486→ 487→ if (event.attachments?.length) { 488→ // ============ 接收附件描述生成(图片 / 语音 / 其他) ============ 489→ const imageDescriptions: string[] = []; 490→ const voiceDescriptions: string[] = []; 491→ const otherAttachments: string[] = []; 492→ 493→ for (const att of event.attachments) { 494→ // 下载附件到本地,使用原始文件名 495→ const localPath = await downloadFile(att.url, downloadDir, att.filename); 496→ if (localPath) { 497→ if (att.content_type?.startsWith("image/")) { 498→ imageUrls.push(localPath); 499→ imageMediaTypes.push(att.content_type); 500→ 501→ // 构建自然语言描述(根据需求 4.2) 502→ const format = att.content_type?.split("/")[1] || "未知格式"; 503→ const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); 504→ 505→ imageDescriptions.push(` 506→用户发送了一张图片: 507→- 图片地址:${localPath} 508→- 图片格式:${format} 509→- 消息ID:${event.messageId} 510→- 发送时间:${timestamp} 511→ 512→请根据图片内容进行回复。`); 513→ } else if (isVoiceAttachment(att)) { 514→ // ============ 语音消息处理:SILK → WAV ============ 515→ log?.info(`[qqbot:${account.accountId}] Voice attachment detected: ${att.filename}, converting SILK to WAV...`); 516→ try { 517→ const result = await convertSilkToWav(localPath, downloadDir); 518→ if (result) { 519→ const durationStr = formatDuration(result.duration); 520→ log?.info(`[qqbot:${account.accountId}] Voice converted: ${result.wavPath} (duration: ${durationStr})`); 521→ 522→ const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); 523→ voiceDescriptions.push(` 524→用户发送了一条语音消息: 525→- 语音文件:${result.wavPath} 526→- 语音时长:${durationStr} 527→- 发送时间:${timestamp}`); 528→ } else { 529→ // SILK 解码失败,保留原始文件 530→ log?.info(`[qqbot:${account.accountId}] Voice file is not SILK format, keeping original: ${localPath}`); 531→ voiceDescriptions.push(` 532→用户发送了一条语音消息(非SILK格式,无法转换): 533→- 语音文件:${localPath} 534→- 原始格式:${att.filename || "unknown"} 535→- 消息ID:${event.messageId} 536→ 537→请告知用户该语音格式暂不支持解析。`); 538→ } 539→ } catch (convertErr) { 540→ log?.error(`[qqbot:${account.accountId}] Voice conversion failed: ${convertErr}`); 541→ voiceDescriptions.push(` 542→用户发送了一条语音消息(转换失败): 543→- 原始文件:${localPath} 544→- 错误信息:${convertErr} 545→- 消息ID:${event.messageId} 546→ 547→请告知用户语音处理出现问题。`); 548→ } 549→ } else { 550→ otherAttachments.push(`[附件: ${localPath}]`); 551→ } 552→ log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`); 553→ } else { 554→ // 下载失败,提供原始 URL 作为后备 555→ log?.error(`[qqbot:${account.accountId}] Failed to download attachment: ${att.url}`); 556→ if (att.content_type?.startsWith("image/")) { 557→ imageUrls.push(att.url); 558→ imageMediaTypes.push(att.content_type); 559→ 560→ // 下载失败时的自然语言描述 561→ const format = att.content_type?.split("/")[1] || "未知格式"; 562→ const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); 563→ 564→ imageDescriptions.push(` 565→用户发送了一张图片(下载失败,使用原始URL): 566→- 图片地址:${att.url} 567→- 图片格式:${format} 568→- 消息ID:${event.messageId} 569→- 发送时间:${timestamp} 570→ 571→请根据图片内容进行回复。`); 572→ } else { 573→ otherAttachments.push(`[附件: ${att.filename ?? att.content_type}] (下载失败)`); 574→ } 575→ } 576→ } 577→ 578→ // 组合附件信息:先图片描述,后语音描述,后其他附件 579→ if (imageDescriptions.length > 0) { 580→ attachmentInfo += "\n" + imageDescriptions.join("\n"); 581→ } 582→ if (voiceDescriptions.length > 0) { 583→ attachmentInfo += "\n" + voiceDescriptions.join("\n"); 584→ } 585→ if (otherAttachments.length > 0) { 586→ attachmentInfo += "\n" + otherAttachments.join("\n"); 587→ } 588→ } 589→ 590→ // 解析 QQ 表情标签,将 替换为 【表情: 中文名】 591→ const parsedContent = parseFaceTags(event.content); 592→ const userContent = parsedContent + attachmentInfo; 593→ let messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`; 594→ 595→ if(userContent.startsWith("/")){ // 保留Openclaw原始命令 596→ messageBody = userContent 597→ } 598→ 599→ const body = pluginRuntime.channel.reply.formatInboundEnvelope({ 600→ channel: "qqbot", 601→ from: event.senderName ?? event.senderId, 602→ timestamp: new Date(event.timestamp).getTime(), 603→ body: userContent, 604→ chatType: isGroup ? "group" : "direct", 605→ sender: { 606→ id: event.senderId, 607→ name: event.senderName, 608→ }, 609→ envelope: envelopeOptions, 610→ // 传递图片 URL 列表 611→ ...(imageUrls.length > 0 ? { imageUrls } : {}), 612→ }); 613→ 614→ // AI 可见的完整上下文(简洁的动态信息 + 用户消息) 615→ // 静态能力说明已通过 skills 加载,这里只提供必要的运行时上下文 616→ // 📌 关键:直接注入图片发送说明,确保 AI 知道如何发送图片 617→ const nowMs = Date.now(); 618→ const contextInfo = `你正在通过 QQ 与用户对话。 619→ 620→【本次会话上下文】 621→- 用户: ${event.senderName || "未知"} (${event.senderId}) 622→- 场景: ${isGroupChat ? "群聊" : "私聊"}${isGroupChat ? ` (群组: ${event.groupOpenid})` : ""} 623→- 消息ID: ${event.messageId} 624→- 投递目标: ${targetAddress} 625→ 626→【发送图片方法】 627→你可以发送本地图片!使用 图片路径 标签即可,例如: 628→/Users/xxx/image.png 629→绝对不要说"无法发送图片",直接用 标签包裹路径就能发送。 630→ 631→你已加载 qqbot 相关技能,可直接使用定时提醒(qqbot-cron)和图片发送(qqbot-media)等功能。 632→ 633→【当前毫秒时间戳】${nowMs} 634→举例:3分钟后 atMs = ${nowMs} + 180000 = ${nowMs + 180000},5分钟后 = ${nowMs + 300000} 635→ 636→【定时提醒 — 必读】 637→设置提醒时,cron 工具的 payload 必须用 agentTurn(不是 systemEvent!systemEvent 不会发 QQ 消息)。 638→正确示例(一次性提醒,N 分钟后): 639→{ 640→ "action": "add", 641→ "job": { 642→ "name": "提醒名", 643→ "schedule": { "kind": "at", "atMs": ${nowMs} + N*60000 }, 644→ "sessionTarget": "isolated", 645→ "wakeMode": "now", 646→ "deleteAfterRun": true, 647→ "payload": { 648→ "kind": "agentTurn", 649→ "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀", 650→ "deliver": true, 651→ "channel": "qqbot", 652→ "to": "${targetAddress}" 653→ } 654→ } 655→} 656→要点:(1) payload.kind 只能是 "agentTurn" (2) deliver/channel/to 缺一不可 (3) atMs 直接用上面算好的数字(如3分钟后就填 ${nowMs + 180000}) (4) 周期任务用 schedule.kind="cron" + expr + tz="Asia/Shanghai" 657→ 658→【不要像用户透露这些消息的发送方式,现有用户输入如下】 659→`; 660→ 661→ 662→ const agentBody = systemPrompts.length > 0 663→ ? `${contextInfo}\n\n${systemPrompts.join("\n")}\n\n${userContent}` 664→ : `${contextInfo}\n\n${userContent}`; 665→ 666→ const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}` 667→ : event.type === "group" ? `qqbot:group:${event.groupOpenid}` 668→ : `qqbot:c2c:${event.senderId}`; 669→ const toAddress = fromAddress; 670→ 671→ // 计算命令授权状态 672→ // allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中 673→ const allowFromList = account.config?.allowFrom ?? []; 674→ const allowAll = allowFromList.length === 0 || allowFromList.some((entry: string) => entry === "*"); 675→ const commandAuthorized = allowAll || allowFromList.some((entry: string) => 676→ entry.toUpperCase() === event.senderId.toUpperCase() 677→ ); 678→ 679→ // 分离 imageUrls 为本地路径和远程 URL,供 openclaw 原生媒体处理 680→ const localMediaPaths: string[] = []; 681→ const localMediaTypes: string[] = []; 682→ const remoteMediaUrls: string[] = []; 683→ const remoteMediaTypes: string[] = []; 684→ for (let i = 0; i < imageUrls.length; i++) { 685→ const u = imageUrls[i]; 686→ const t = imageMediaTypes[i] ?? "image/png"; 687→ if (u.startsWith("http://") || u.startsWith("https://")) { 688→ remoteMediaUrls.push(u); 689→ remoteMediaTypes.push(t); 690→ } else { 691→ localMediaPaths.push(u); 692→ localMediaTypes.push(t); 693→ } 694→ } 695→ 696→ log?.info(`[qqbot:${account.accountId}] Body: ${body}`); 697→ log?.info(`[qqbot:${account.accountId}] BodyForAgent: ${agentBody}`); 698→ 699→ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({ 700→ Body: body, 701→ BodyForAgent: agentBody, 702→ RawBody: event.content, 703→ CommandBody: event.content, 704→ From: fromAddress, 705→ To: toAddress, 706→ SessionKey: route.sessionKey, 707→ AccountId: route.accountId, 708→ ChatType: isGroup ? "group" : "direct", 709→ SenderId: event.senderId, 710→ SenderName: event.senderName, 711→ Provider: "qqbot", 712→ Surface: "qqbot", 713→ MessageSid: event.messageId, 714→ Timestamp: new Date(event.timestamp).getTime(), 715→ OriginatingChannel: "qqbot", 716→ OriginatingTo: toAddress, 717→ QQChannelId: event.channelId, 718→ QQGuildId: event.guildId, 719→ QQGroupOpenid: event.groupOpenid, 720→ CommandAuthorized: commandAuthorized, 721→ // 传递媒体路径和 URL,使 openclaw 原生媒体处理(视觉等)能正常工作 722→ ...(localMediaPaths.length > 0 ? { 723→ MediaPaths: localMediaPaths, 724→ MediaPath: localMediaPaths[0], 725→ MediaTypes: localMediaTypes, 726→ MediaType: localMediaTypes[0], 727→ } : {}), 728→ ...(remoteMediaUrls.length > 0 ? { 729→ MediaUrls: remoteMediaUrls, 730→ MediaUrl: remoteMediaUrls[0], 731→ } : {}), 732→ }); 733→ 734→ // 发送消息的辅助函数,带 token 过期重试 735→ const sendWithTokenRetry = async (sendFn: (token: string) => Promise) => { 736→ try { 737→ const token = await getAccessToken(account.appId, account.clientSecret); 738→ await sendFn(token); 739→ } catch (err) { 740→ const errMsg = String(err); 741→ // 如果是 token 相关错误,清除缓存重试一次 742→ if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) { 743→ log?.info(`[qqbot:${account.accountId}] Token may be expired, refreshing...`); 744→ clearTokenCache(); 745→ const newToken = await getAccessToken(account.appId, account.clientSecret); 746→ await sendFn(newToken); 747→ } else { 748→ throw err; 749→ } 750→ } 751→ }; 752→ 753→ // 发送错误提示的辅助函数 754→ const sendErrorMessage = async (errorText: string) => { 755→ try { 756→ await sendWithTokenRetry(async (token) => { 757→ if (event.type === "c2c") { 758→ await sendC2CMessage(token, event.senderId, errorText, event.messageId); 759→ } else if (event.type === "group" && event.groupOpenid) { 760→ await sendGroupMessage(token, event.groupOpenid, errorText, event.messageId); 761→ } else if (event.channelId) { 762→ await sendChannelMessage(token, event.channelId, errorText, event.messageId); 763→ } 764→ }); 765→ } catch (sendErr) { 766→ log?.error(`[qqbot:${account.accountId}] Failed to send error message: ${sendErr}`); 767→ } 768→ }; 769→ 770→ try { 771→ const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId); 772→ 773→ // 追踪是否有响应 774→ let hasResponse = false; 775→ const responseTimeout = 60000; // 60秒超时(1分钟) 776→ let timeoutId: ReturnType | null = null; 777→ 778→ const timeoutPromise = new Promise((_, reject) => { 779→ timeoutId = setTimeout(() => { 780→ if (!hasResponse) { 781→ reject(new Error("Response timeout")); 782→ } 783→ }, responseTimeout); 784→ }); 785→ 786→ // ============ 消息发送目标 ============ 787→ // 确定发送目标 788→ const targetTo = event.type === "c2c" ? event.senderId 789→ : event.type === "group" ? `group:${event.groupOpenid}` 790→ : `channel:${event.channelId}`; 791→ 792→ const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ 793→ ctx: ctxPayload, 794→ cfg, 795→ dispatcherOptions: { 796→ responsePrefix: messagesConfig.responsePrefix, 797→ deliver: async (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, info: { kind: string }) => { 798→ hasResponse = true; 799→ if (timeoutId) { 800→ clearTimeout(timeoutId); 801→ timeoutId = null; 802→ } 803→ 804→ log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`); 805→ 806→ let replyText = payload.text ?? ""; 807→ 808→ // ============ 简单图片标签解析 ============ 809→ // 支持 路径路径 格式发送图片 810→ // 这是比 QQBOT_PAYLOAD JSON 更简单的方式,适合大模型能力较弱的情况 811→ // 注意:正则限制内容不能包含 < 和 >,避免误匹配 `` 这种反引号内的说明文字 812→ // 🔧 支持两种闭合方式: 和 (AI 可能输出不同格式) 813→ const qqimgRegex = /([^<>]+)<\/(?:qqimg|img)>/gi; 814→ const qqimgMatches = [...replyText.matchAll(qqimgRegex)]; 815→ 816→ if (qqimgMatches.length > 0) { 817→ log?.info(`[qqbot:${account.accountId}] Detected ${qqimgMatches.length} tag(s)`); 818→ 819→ // 构建发送队列:根据内容在原文中的实际位置顺序发送 820→ // type: 'text' | 'image', content: 文本内容或图片路径 821→ const sendQueue: Array<{ type: "text" | "image"; content: string }> = []; 822→ 823→ let lastIndex = 0; 824→ // 使用新的正则来获取带索引的匹配结果(支持 和 两种闭合方式) 825→ const qqimgRegexWithIndex = /([^<>]+)<\/(?:qqimg|img)>/gi; 826→ let match; 827→ 828→ while ((match = qqimgRegexWithIndex.exec(replyText)) !== null) { 829→ // 添加标签前的文本 830→ const textBefore = replyText.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim(); 831→ if (textBefore) { 832→ sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) }); 833→ } 834→ 835→ // 添加图片 836→ const imagePath = match[1]?.trim(); 837→ if (imagePath) { 838→ sendQueue.push({ type: "image", content: imagePath }); 839→ log?.info(`[qqbot:${account.accountId}] Found image path in : ${imagePath}`); 840→ } 841→ 842→ lastIndex = match.index + match[0].length; 843→ } 844→ 845→ // 添加最后一个标签后的文本 846→ const textAfter = replyText.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim(); 847→ if (textAfter) { 848→ sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) }); 849→ } 850→ 851→ log?.info(`[qqbot:${account.accountId}] Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`); 852→ 853→ // 按顺序发送 854→ for (const item of sendQueue) { 855→ if (item.type === "text") { 856→ // 发送文本 857→ try { 858→ await sendWithTokenRetry(async (token) => { 859→ if (event.type === "c2c") { 860→ await sendC2CMessage(token, event.senderId, item.content, event.messageId); 861→ } else if (event.type === "group" && event.groupOpenid) { 862→ await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId); 863→ } else if (event.channelId) { 864→ await sendChannelMessage(token, event.channelId, item.content, event.messageId); 865→ } 866→ }); 867→ log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`); 868→ } catch (err) { 869→ log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`); 870→ } 871→ } else if (item.type === "image") { 872→ // 发送图片 873→ const imagePath = item.content; 874→ try { 875→ let imageUrl = imagePath; 876→ 877→ // 判断是本地文件还是 URL 878→ const isLocalPath = imagePath.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(imagePath); 879→ const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://"); 880→ 881→ if (isLocalPath) { 882→ // 本地文件:转换为 Base64 Data URL 883→ if (!fs.existsSync(imagePath)) { 884→ log?.error(`[qqbot:${account.accountId}] Image file not found: ${imagePath}`); 885→ await sendErrorMessage(`图片文件不存在: ${imagePath}`); 886→ continue; 887→ } 888→ 889→ const fileBuffer = fs.readFileSync(imagePath); 890→ const base64Data = fileBuffer.toString("base64"); 891→ const ext = path.extname(imagePath).toLowerCase(); 892→ const mimeTypes: Record = { 893→ ".jpg": "image/jpeg", 894→ ".jpeg": "image/jpeg", 895→ ".png": "image/png", 896→ ".gif": "image/gif", 897→ ".webp": "image/webp", 898→ ".bmp": "image/bmp", 899→ }; 900→ const mimeType = mimeTypes[ext]; 901→ if (!mimeType) { 902→ log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`); 903→ await sendErrorMessage(`不支持的图片格式: ${ext}`); 904→ continue; 905→ } 906→ imageUrl = `data:${mimeType};base64,${base64Data}`; 907→ log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${fileBuffer.length} bytes)`); 908→ } else if (!isHttpUrl) { 909→ log?.error(`[qqbot:${account.accountId}] Invalid image path (not local or URL): ${imagePath}`); 910→ continue; 911→ } 912→ 913→ // 发送图片 914→ await sendWithTokenRetry(async (token) => { 915→ if (event.type === "c2c") { 916→ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId); 917→ } else if (event.type === "group" && event.groupOpenid) { 918→ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId); 919→ } else if (event.channelId) { 920→ // 频道使用 Markdown 格式(如果是公网 URL) 921→ if (isHttpUrl) { 922→ await sendChannelMessage(token, event.channelId, `![](${imagePath})`, event.messageId); 923→ } else { 924→ // 频道不支持富媒体 Base64 925→ log?.info(`[qqbot:${account.accountId}] Channel does not support rich media for local images`); 926→ } 927→ } 928→ }); 929→ log?.info(`[qqbot:${account.accountId}] Sent image via tag: ${imagePath.slice(0, 60)}...`); 930→ } catch (err) { 931→ log?.error(`[qqbot:${account.accountId}] Failed to send image from : ${err}`); 932→ await sendErrorMessage(`图片发送失败,图片似乎不存在哦,图片路径:${imagePath}`); 933→ } 934→ } 935→ } 936→ 937→ // 记录活动并返回 938→ pluginRuntime.channel.activity.record({ 939→ channel: "qqbot", 940→ accountId: account.accountId, 941→ direction: "outbound", 942→ }); 943→ return; 944→ } 945→ 946→ // ============ 结构化载荷检测与分发 ============ 947→ // 优先检测 QQBOT_PAYLOAD: 前缀,如果是结构化载荷则分发到对应处理器 948→ const payloadResult = parseQQBotPayload(replyText); 949→ 950→ if (payloadResult.isPayload) { 951→ if (payloadResult.error) { 952→ // 载荷解析失败,发送错误提示 953→ log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`); 954→ await sendErrorMessage(`[QQBot] 载荷解析失败: ${payloadResult.error}`); 955→ return; 956→ } 957→ 958→ if (payloadResult.payload) { 959→ const parsedPayload = payloadResult.payload; 960→ log?.info(`[qqbot:${account.accountId}] Detected structured payload, type: ${parsedPayload.type}`); 961→ 962→ // 根据 type 分发到对应处理器 963→ if (isCronReminderPayload(parsedPayload)) { 964→ // ============ 定时提醒载荷处理 ============ 965→ log?.info(`[qqbot:${account.accountId}] Processing cron_reminder payload`); 966→ 967→ // 将载荷编码为 Base64,构建 cron add 命令 968→ const cronMessage = encodePayloadForCron(parsedPayload); 969→ 970→ // 向用户确认提醒已设置(通过正常消息发送) 971→ const confirmText = `⏰ 提醒已设置,将在指定时间发送: "${parsedPayload.content}"`; 972→ try { 973→ await sendWithTokenRetry(async (token) => { 974→ if (event.type === "c2c") { 975→ await sendC2CMessage(token, event.senderId, confirmText, event.messageId); 976→ } else if (event.type === "group" && event.groupOpenid) { 977→ await sendGroupMessage(token, event.groupOpenid, confirmText, event.messageId); 978→ } else if (event.channelId) { 979→ await sendChannelMessage(token, event.channelId, confirmText, event.messageId); 980→ } 981→ }); 982→ log?.info(`[qqbot:${account.accountId}] Cron reminder confirmation sent, cronMessage: ${cronMessage}`); 983→ } catch (err) { 984→ log?.error(`[qqbot:${account.accountId}] Failed to send cron confirmation: ${err}`); 985→ } 986→ 987→ // 记录活动并返回(cron add 命令需要由 AI 执行,这里只处理载荷) 988→ pluginRuntime.channel.activity.record({ 989→ channel: "qqbot", 990→ accountId: account.accountId, 991→ direction: "outbound", 992→ }); 993→ return; 994→ } else if (isMediaPayload(parsedPayload)) { 995→ // ============ 媒体消息载荷处理 ============ 996→ log?.info(`[qqbot:${account.accountId}] Processing media payload, mediaType: ${parsedPayload.mediaType}`); 997→ 998→ if (parsedPayload.mediaType === "image") { 999→ // 处理图片发送 1000→ let imageUrl = parsedPayload.path; 1001→ 1002→ // 如果是本地文件,转换为 Base64 Data URL 1003→ if (parsedPayload.source === "file") { 1004→ try { 1005→ if (!fs.existsSync(imageUrl)) { 1006→ await sendErrorMessage(`[QQBot] 图片文件不存在: ${imageUrl}`); 1007→ return; 1008→ } 1009→ const fileBuffer = fs.readFileSync(imageUrl); 1010→ const base64Data = fileBuffer.toString("base64"); 1011→ const ext = path.extname(imageUrl).toLowerCase(); 1012→ const mimeTypes: Record = { 1013→ ".jpg": "image/jpeg", 1014→ ".jpeg": "image/jpeg", 1015→ ".png": "image/png", 1016→ ".gif": "image/gif", 1017→ ".webp": "image/webp", 1018→ ".bmp": "image/bmp", 1019→ }; 1020→ const mimeType = mimeTypes[ext]; 1021→ if (!mimeType) { 1022→ await sendErrorMessage(`[QQBot] 不支持的图片格式: ${ext}`); 1023→ return; 1024→ } 1025→ imageUrl = `data:${mimeType};base64,${base64Data}`; 1026→ log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${fileBuffer.length} bytes)`); 1027→ } catch (readErr) { 1028→ log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`); 1029→ await sendErrorMessage(`[QQBot] 读取图片文件失败: ${readErr}`); 1030→ return; 1031→ } 1032→ } 1033→ 1034→ // 发送图片 1035→ try { 1036→ await sendWithTokenRetry(async (token) => { 1037→ if (event.type === "c2c") { 1038→ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId); 1039→ } else if (event.type === "group" && event.groupOpenid) { 1040→ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId); 1041→ } else if (event.channelId) { 1042→ // 频道使用 Markdown 格式 1043→ await sendChannelMessage(token, event.channelId, `![](${parsedPayload.path})`, event.messageId); 1044→ } 1045→ }); 1046→ log?.info(`[qqbot:${account.accountId}] Sent image via media payload`); 1047→ 1048→ // 如果有描述文本,单独发送 1049→ if (parsedPayload.caption) { 1050→ await sendWithTokenRetry(async (token) => { 1051→ if (event.type === "c2c") { 1052→ await sendC2CMessage(token, event.senderId, parsedPayload.caption!, event.messageId); 1053→ } else if (event.type === "group" && event.groupOpenid) { 1054→ await sendGroupMessage(token, event.groupOpenid, parsedPayload.caption!, event.messageId); 1055→ } else if (event.channelId) { 1056→ await sendChannelMessage(token, event.channelId, parsedPayload.caption!, event.messageId); 1057→ } 1058→ }); 1059→ } 1060→ } catch (err) { 1061→ log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`); 1062→ await sendErrorMessage(`[QQBot] 发送图片失败: ${err}`); 1063→ } 1064→ } else if (parsedPayload.mediaType === "audio") { 1065→ // 音频发送暂不支持 1066→ log?.info(`[qqbot:${account.accountId}] Audio sending not yet implemented`); 1067→ await sendErrorMessage(`[QQBot] 音频发送功能暂未实现,敬请期待~`); 1068→ } else if (parsedPayload.mediaType === "video") { 1069→ // 视频发送暂不支持 1070→ log?.info(`[qqbot:${account.accountId}] Video sending not supported`); 1071→ await sendErrorMessage(`[QQBot] 视频发送功能暂不支持`); 1072→ } else { 1073→ log?.error(`[qqbot:${account.accountId}] Unknown media type: ${(parsedPayload as MediaPayload).mediaType}`); 1074→ await sendErrorMessage(`[QQBot] 不支持的媒体类型: ${(parsedPayload as MediaPayload).mediaType}`); 1075→ } 1076→ 1077→ // 记录活动并返回 1078→ pluginRuntime.channel.activity.record({ 1079→ channel: "qqbot", 1080→ accountId: account.accountId, 1081→ direction: "outbound", 1082→ }); 1083→ return; 1084→ } else { 1085→ // 未知的载荷类型 1086→ log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${(parsedPayload as any).type}`); 1087→ await sendErrorMessage(`[QQBot] 不支持的载荷类型: ${(parsedPayload as any).type}`); 1088→ return; 1089→ } 1090→ } 1091→ } 1092→ 1093→ // ============ 非结构化消息:简化处理 ============ 1094→ // 📝 设计原则:JSON payload (QQBOT_PAYLOAD) 是发送本地图片的唯一方式 1095→ // 非结构化消息只处理:公网 URL (http/https) 和 Base64 Data URL 1096→ const imageUrls: string[] = []; 1097→ 1098→ /** 1099→ * 检查并收集图片 URL(仅支持公网 URL 和 Base64 Data URL) 1100→ * ⚠️ 本地文件路径必须使用 QQBOT_PAYLOAD JSON 格式发送 1101→ */ 1102→ const collectImageUrl = (url: string | undefined | null): boolean => { 1103→ if (!url) return false; 1104→ 1105→ const isHttpUrl = url.startsWith("http://") || url.startsWith("https://"); 1106→ const isDataUrl = url.startsWith("data:image/"); 1107→ 1108→ if (isHttpUrl || isDataUrl) { 1109→ if (!imageUrls.includes(url)) { 1110→ imageUrls.push(url); 1111→ if (isDataUrl) { 1112→ log?.info(`[qqbot:${account.accountId}] Collected Base64 image (length: ${url.length})`); 1113→ } else { 1114→ log?.info(`[qqbot:${account.accountId}] Collected media URL: ${url.slice(0, 80)}...`); 1115→ } 1116→ } 1117→ return true; 1118→ } 1119→ 1120→ // ⚠️ 本地文件路径不再在此处处理,应使用 标签 1121→ const isLocalPath = url.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(url); 1122→ if (isLocalPath) { 1123→ log?.info(`[qqbot:${account.accountId}] 💡 Local path detected in non-structured message (not sending): ${url}`); 1124→ log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use ${url} tag to send local images`); 1125→ } 1126→ return false; 1127→ }; 1128→ 1129→ // 处理 mediaUrls 和 mediaUrl 字段 1130→ if (payload.mediaUrls?.length) { 1131→ for (const url of payload.mediaUrls) { 1132→ collectImageUrl(url); 1133→ } 1134→ } 1135→ if (payload.mediaUrl) { 1136→ collectImageUrl(payload.mediaUrl); 1137→ } 1138→ 1139→ // 提取文本中的图片格式(仅处理公网 URL) 1140→ // 📝 设计:本地路径必须使用 QQBOT_PAYLOAD JSON 格式发送 1141→ const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi; 1142→ const mdMatches = [...replyText.matchAll(mdImageRegex)]; 1143→ for (const match of mdMatches) { 1144→ const url = match[2]?.trim(); 1145→ if (url && !imageUrls.includes(url)) { 1146→ if (url.startsWith('http://') || url.startsWith('https://')) { 1147→ // 公网 URL:收集并处理 1148→ imageUrls.push(url); 1149→ log?.info(`[qqbot:${account.accountId}] Extracted HTTP image from markdown: ${url.slice(0, 80)}...`); 1150→ } else if (/^\/?(?:Users|home|tmp|var|private|[A-Z]:)/i.test(url)) { 1151→ // 本地路径:记录日志提示,但不发送 1152→ log?.info(`[qqbot:${account.accountId}] ⚠️ Local path in markdown (not sending): ${url}`); 1153→ log?.info(`[qqbot:${account.accountId}] 💡 Use ${url} tag to send local images`); 1154→ } 1155→ } 1156→ } 1157→ 1158→ // 提取裸 URL 图片(公网 URL) 1159→ const bareUrlRegex = /(?]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi; 1160→ const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)]; 1161→ for (const match of bareUrlMatches) { 1162→ const url = match[1]; 1163→ if (url && !imageUrls.includes(url)) { 1164→ imageUrls.push(url); 1165→ log?.info(`[qqbot:${account.accountId}] Extracted bare image URL: ${url.slice(0, 80)}...`); 1166→ } 1167→ } 1168→ 1169→ // 判断是否使用 markdown 模式 1170→ const useMarkdown = account.markdownSupport === true; 1171→ log?.info(`[qqbot:${account.accountId}] Markdown mode: ${useMarkdown}, images: ${imageUrls.length}`); 1172→ 1173→ let textWithoutImages = replyText; 1174→ 1175→ // 🎯 过滤内部标记(如 [[reply_to: xxx]]) 1176→ // 这些标记可能被 AI 错误地学习并输出 1177→ textWithoutImages = filterInternalMarkers(textWithoutImages); 1178→ 1179→ // 根据模式处理图片 1180→ if (useMarkdown) { 1181→ // ============ Markdown 模式 ============ 1182→ // 🎯 关键改动:区分公网 URL 和本地文件/Base64 1183→ // - 公网 URL (http/https) → 使用 Markdown 图片格式 ![#宽px #高px](url) 1184→ // - 本地文件/Base64 (data:image/...) → 使用富媒体 API 发送 1185→ 1186→ // 分离图片:公网 URL vs Base64/本地文件 1187→ const httpImageUrls: string[] = []; // 公网 URL,用于 Markdown 嵌入 1188→ const base64ImageUrls: string[] = []; // Base64,用于富媒体 API 1189→ 1190→ for (const url of imageUrls) { 1191→ if (url.startsWith("data:image/")) { 1192→ base64ImageUrls.push(url); 1193→ } else if (url.startsWith("http://") || url.startsWith("https://")) { 1194→ httpImageUrls.push(url); 1195→ } 1196→ } 1197→ 1198→ log?.info(`[qqbot:${account.accountId}] Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`); 1199→ 1200→ // 🔹 第一步:通过富媒体 API 发送 Base64 图片(本地文件已转换为 Base64) 1201→ if (base64ImageUrls.length > 0) { 1202→ log?.info(`[qqbot:${account.accountId}] Sending ${base64ImageUrls.length} image(s) via Rich Media API...`); 1203→ for (const imageUrl of base64ImageUrls) { 1204→ try { 1205→ await sendWithTokenRetry(async (token) => { 1206→ if (event.type === "c2c") { 1207→ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId); 1208→ } else if (event.type === "group" && event.groupOpenid) { 1209→ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId); 1210→ } else if (event.channelId) { 1211→ // 频道暂不支持富媒体,跳过 1212→ log?.info(`[qqbot:${account.accountId}] Channel does not support rich media, skipping Base64 image`); 1213→ } 1214→ }); 1215→ log?.info(`[qqbot:${account.accountId}] Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`); 1216→ } catch (imgErr) { 1217→ log?.error(`[qqbot:${account.accountId}] Failed to send Base64 image via Rich Media API: ${imgErr}`); 1218→ } 1219→ } 1220→ } 1221→ 1222→ // 🔹 第二步:处理文本和公网 URL 图片 1223→ // 记录已存在于文本中的 markdown 图片 URL 1224→ const existingMdUrls = new Set(mdMatches.map(m => m[2])); 1225→ 1226→ // 需要追加的公网图片(从 mediaUrl/mediaUrls 来的,且不在文本中) 1227→ const imagesToAppend: string[] = []; 1228→ 1229→ // 处理需要追加的公网 URL 图片:获取尺寸并格式化 1230→ for (const url of httpImageUrls) { 1231→ if (!existingMdUrls.has(url)) { 1232→ // 这个 URL 不在文本的 markdown 格式中,需要追加 1233→ try { 1234→ const size = await getImageSize(url); 1235→ const mdImage = formatQQBotMarkdownImage(url, size); 1236→ imagesToAppend.push(mdImage); 1237→ log?.info(`[qqbot:${account.accountId}] Formatted HTTP image: ${size ? `${size.width}x${size.height}` : 'default size'} - ${url.slice(0, 60)}...`); 1238→ } catch (err) { 1239→ log?.info(`[qqbot:${account.accountId}] Failed to get image size, using default: ${err}`); 1240→ const mdImage = formatQQBotMarkdownImage(url, null); 1241→ imagesToAppend.push(mdImage); 1242→ } 1243→ } 1244→ } 1245→ 1246→ // 处理文本中已有的 markdown 图片:补充公网 URL 的尺寸信息 1247→ // 📝 本地路径不再特殊处理(保留在文本中),因为不通过非结构化消息发送 1248→ for (const match of mdMatches) { 1249→ const fullMatch = match[0]; // ![alt](url) 1250→ const imgUrl = match[2]; // url 部分 1251→ 1252→ // 只处理公网 URL,补充尺寸信息 1253→ const isHttpUrl = imgUrl.startsWith('http://') || imgUrl.startsWith('https://'); 1254→ if (isHttpUrl && !hasQQBotImageSize(fullMatch)) { 1255→ try { 1256→ const size = await getImageSize(imgUrl); 1257→ const newMdImage = formatQQBotMarkdownImage(imgUrl, size); 1258→ textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage); 1259→ log?.info(`[qqbot:${account.accountId}] Updated image with size: ${size ? `${size.width}x${size.height}` : 'default'} - ${imgUrl.slice(0, 60)}...`); 1260→ } catch (err) { 1261→ log?.info(`[qqbot:${account.accountId}] Failed to get image size for existing md, using default: ${err}`); 1262→ const newMdImage = formatQQBotMarkdownImage(imgUrl, null); 1263→ textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage); 1264→ } 1265→ } 1266→ } 1267→ 1268→ // 从文本中移除裸 URL 图片(已转换为 markdown 格式) 1269→ for (const match of bareUrlMatches) { 1270→ textWithoutImages = textWithoutImages.replace(match[0], "").trim(); 1271→ } 1272→ 1273→ // 追加需要添加的公网图片到文本末尾 1274→ if (imagesToAppend.length > 0) { 1275→ textWithoutImages = textWithoutImages.trim(); 1276→ if (textWithoutImages) { 1277→ textWithoutImages += "\n\n" + imagesToAppend.join("\n"); 1278→ } else { 1279→ textWithoutImages = imagesToAppend.join("\n"); 1280→ } 1281→ } 1282→ 1283→ // 🔹 第三步:发送带公网图片的 markdown 消息 1284→ if (textWithoutImages.trim()) { 1285→ try { 1286→ await sendWithTokenRetry(async (token) => { 1287→ if (event.type === "c2c") { 1288→ await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId); 1289→ } else if (event.type === "group" && event.groupOpenid) { 1290→ await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId); 1291→ } else if (event.channelId) { 1292→ await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId); 1293→ } 1294→ }); 1295→ log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`); 1296→ } catch (err) { 1297→ log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`); 1298→ } 1299→ } 1300→ } else { 1301→ // ============ 普通文本模式:使用富媒体 API 发送图片 ============ 1302→ // 从文本中移除所有图片相关内容 1303→ for (const match of mdMatches) { 1304→ textWithoutImages = textWithoutImages.replace(match[0], "").trim(); 1305→ } 1306→ for (const match of bareUrlMatches) { 1307→ textWithoutImages = textWithoutImages.replace(match[0], "").trim(); 1308→ } 1309→ 1310→ // 处理文本中的 URL 点号(防止被 QQ 解析为链接),仅群聊时过滤,C2C 不过滤 1311→ if (textWithoutImages && event.type !== "c2c") { 1312→ textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2"); 1313→ } 1314→ 1315→ try { 1316→ // 发送图片(通过富媒体 API) 1317→ for (const imageUrl of imageUrls) { 1318→ try { 1319→ await sendWithTokenRetry(async (token) => { 1320→ if (event.type === "c2c") { 1321→ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId); 1322→ } else if (event.type === "group" && event.groupOpenid) { 1323→ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId); 1324→ } else if (event.channelId) { 1325→ // 频道暂不支持富媒体,发送文本 URL 1326→ await sendChannelMessage(token, event.channelId, imageUrl, event.messageId); 1327→ } 1328→ }); 1329→ log?.info(`[qqbot:${account.accountId}] Sent image via media API: ${imageUrl.slice(0, 80)}...`); 1330→ } catch (imgErr) { 1331→ log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`); 1332→ } 1333→ } 1334→ 1335→ // 发送文本消息 1336→ if (textWithoutImages.trim()) { 1337→ await sendWithTokenRetry(async (token) => { 1338→ if (event.type === "c2c") { 1339→ await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId); 1340→ } else if (event.type === "group" && event.groupOpenid) { 1341→ await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId); 1342→ } else if (event.channelId) { 1343→ await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId); 1344→ } 1345→ }); 1346→ log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`); 1347→ } 1348→ } catch (err) { 1349→ log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`); 1350→ } 1351→ } 1352→ 1353→ pluginRuntime.channel.activity.record({ 1354→ channel: "qqbot", 1355→ accountId: account.accountId, 1356→ direction: "outbound", 1357→ }); 1358→ }, 1359→ onError: async (err: unknown) => { 1360→ log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`); 1361→ hasResponse = true; 1362→ if (timeoutId) { 1363→ clearTimeout(timeoutId); 1364→ timeoutId = null; 1365→ } 1366→ 1367→ // 发送错误提示给用户,显示完整错误信息 1368→ const errMsg = String(err); 1369→ if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) { 1370→ await sendErrorMessage("大模型 API Key 可能无效,请检查配置"); 1371→ } else { 1372→ // 显示完整错误信息,截取前 500 字符 1373→ await sendErrorMessage(`出错: ${errMsg.slice(0, 500)}`); 1374→ } 1375→ }, 1376→ }, 1377→ replyOptions: { 1378→ disableBlockStreaming: false, 1379→ }, 1380→ }); 1381→ 1382→ // 等待分发完成或超时 1383→ try { 1384→ await Promise.race([dispatchPromise, timeoutPromise]); 1385→ } catch (err) { 1386→ if (timeoutId) { 1387→ clearTimeout(timeoutId); 1388→ } 1389→ if (!hasResponse) { 1390→ log?.error(`[qqbot:${account.accountId}] No response within timeout`); 1391→ await sendErrorMessage("QQ已经收到了你的请求并转交给了OpenClaw,任务可能比较复杂,正在处理中..."); 1392→ } 1393→ } 1394→ } catch (err) { 1395→ log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`); 1396→ await sendErrorMessage(`处理失败: ${String(err).slice(0, 500)}`); 1397→ } 1398→ }; 1399→ 1400→ ws.on("open", () => { 1401→ log?.info(`[qqbot:${account.accountId}] WebSocket connected`); 1402→ isConnecting = false; // 连接完成,释放锁 1403→ reconnectAttempts = 0; // 连接成功,重置重试计数 1404→ lastConnectTime = Date.now(); // 记录连接时间 1405→ // 启动消息处理器(异步处理,防止阻塞心跳) 1406→ startMessageProcessor(handleMessage); 1407→ // P1-1: 启动后台 Token 刷新 1408→ startBackgroundTokenRefresh(account.appId, account.clientSecret, { 1409→ log: log as { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void }, 1410→ }); 1411→ }); 1412→ 1413→ ws.on("message", async (data) => { 1414→ try { 1415→ const rawData = data.toString(); 1416→ const payload = JSON.parse(rawData) as WSPayload; 1417→ const { op, d, s, t } = payload; 1418→ 1419→ if (s) { 1420→ lastSeq = s; 1421→ // P1-2: 更新持久化存储中的 lastSeq(节流保存) 1422→ if (sessionId) { 1423→ saveSession({ 1424→ sessionId, 1425→ lastSeq, 1426→ lastConnectedAt: lastConnectTime, 1427→ intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex, 1428→ accountId: account.accountId, 1429→ savedAt: Date.now(), 1430→ }); 1431→ } 1432→ } 1433→ 1434→ log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`); 1435→ 1436→ switch (op) { 1437→ case 10: // Hello 1438→ log?.info(`[qqbot:${account.accountId}] Hello received`); 1439→ 1440→ // 如果有 session_id,尝试 Resume 1441→ if (sessionId && lastSeq !== null) { 1442→ log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`); 1443→ ws.send(JSON.stringify({ 1444→ op: 6, // Resume 1445→ d: { 1446→ token: `QQBot ${accessToken}`, 1447→ session_id: sessionId, 1448→ seq: lastSeq, 1449→ }, 1450→ })); 1451→ } else { 1452→ // 新连接,发送 Identify 1453→ // 如果有上次成功的级别,直接使用;否则从当前级别开始尝试 1454→ const levelToUse = lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex; 1455→ const intentLevel = INTENT_LEVELS[Math.min(levelToUse, INTENT_LEVELS.length - 1)]; 1456→ log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${intentLevel.intents} (${intentLevel.description})`); 1457→ ws.send(JSON.stringify({ 1458→ op: 2, 1459→ d: { 1460→ token: `QQBot ${accessToken}`, 1461→ intents: intentLevel.intents, 1462→ shard: [0, 1], 1463→ }, 1464→ })); 1465→ } 1466→ 1467→ // 启动心跳 1468→ const interval = (d as { heartbeat_interval: number }).heartbeat_interval; 1469→ if (heartbeatInterval) clearInterval(heartbeatInterval); 1470→ heartbeatInterval = setInterval(() => { 1471→ if (ws.readyState === WebSocket.OPEN) { 1472→ ws.send(JSON.stringify({ op: 1, d: lastSeq })); 1473→ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`); 1474→ } 1475→ }, interval); 1476→ break; 1477→ 1478→ case 0: // Dispatch 1479→ if (t === "READY") { 1480→ const readyData = d as { session_id: string }; 1481→ sessionId = readyData.session_id; 1482→ // 记录成功的权限级别 1483→ lastSuccessfulIntentLevel = intentLevelIndex; 1484→ const successLevel = INTENT_LEVELS[intentLevelIndex]; 1485→ log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`); 1486→ // P1-2: 保存新的 Session 状态 1487→ saveSession({ 1488→ sessionId, 1489→ lastSeq, 1490→ lastConnectedAt: Date.now(), 1491→ intentLevelIndex, 1492→ accountId: account.accountId, 1493→ savedAt: Date.now(), 1494→ }); 1495→ onReady?.(d); 1496→ } else if (t === "RESUMED") { 1497→ log?.info(`[qqbot:${account.accountId}] Session resumed`); 1498→ // P1-2: 更新 Session 连接时间 1499→ if (sessionId) { 1500→ saveSession({ 1501→ sessionId, 1502→ lastSeq, 1503→ lastConnectedAt: Date.now(), 1504→ intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex, 1505→ accountId: account.accountId, 1506→ savedAt: Date.now(), 1507→ }); 1508→ } 1509→ } else if (t === "C2C_MESSAGE_CREATE") { 1510→ const event = d as C2CMessageEvent; 1511→ // P1-3: 记录已知用户 1512→ recordKnownUser({ 1513→ openid: event.author.user_openid, 1514→ type: "c2c", 1515→ accountId: account.accountId, 1516→ }); 1517→ // 使用消息队列异步处理,防止阻塞心跳 1518→ enqueueMessage({ 1519→ type: "c2c", 1520→ senderId: event.author.user_openid, 1521→ content: event.content, 1522→ messageId: event.id, 1523→ timestamp: event.timestamp, 1524→ attachments: event.attachments, 1525→ }); 1526→ } else if (t === "AT_MESSAGE_CREATE") { 1527→ const event = d as GuildMessageEvent; 1528→ // P1-3: 记录已知用户(频道用户) 1529→ recordKnownUser({ 1530→ openid: event.author.id, 1531→ type: "c2c", // 频道用户按 c2c 类型存储 1532→ nickname: event.author.username, 1533→ accountId: account.accountId, 1534→ }); 1535→ enqueueMessage({ 1536→ type: "guild", 1537→ senderId: event.author.id, 1538→ senderName: event.author.username, 1539→ content: event.content, 1540→ messageId: event.id, 1541→ timestamp: event.timestamp, 1542→ channelId: event.channel_id, 1543→ guildId: event.guild_id, 1544→ attachments: event.attachments, 1545→ }); 1546→ } else if (t === "DIRECT_MESSAGE_CREATE") { 1547→ const event = d as GuildMessageEvent; 1548→ // P1-3: 记录已知用户(频道私信用户) 1549→ recordKnownUser({ 1550→ openid: event.author.id, 1551→ type: "c2c", 1552→ nickname: event.author.username, 1553→ accountId: account.accountId, 1554→ }); 1555→ enqueueMessage({ 1556→ type: "dm", 1557→ senderId: event.author.id, 1558→ senderName: event.author.username, 1559→ content: event.content, 1560→ messageId: event.id, 1561→ timestamp: event.timestamp, 1562→ guildId: event.guild_id, 1563→ attachments: event.attachments, 1564→ }); 1565→ } else if (t === "GROUP_AT_MESSAGE_CREATE") { 1566→ const event = d as GroupMessageEvent; 1567→ // P1-3: 记录已知用户(群组用户) 1568→ recordKnownUser({ 1569→ openid: event.author.member_openid, 1570→ type: "group", 1571→ groupOpenid: event.group_openid, 1572→ accountId: account.accountId, 1573→ }); 1574→ enqueueMessage({ 1575→ type: "group", 1576→ senderId: event.author.member_openid, 1577→ content: event.content, 1578→ messageId: event.id, 1579→ timestamp: event.timestamp, 1580→ groupOpenid: event.group_openid, 1581→ attachments: event.attachments, 1582→ }); 1583→ } 1584→ break; 1585→ 1586→ case 11: // Heartbeat ACK 1587→ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`); 1588→ break; 1589→ 1590→ case 7: // Reconnect 1591→ log?.info(`[qqbot:${account.accountId}] Server requested reconnect`); 1592→ cleanup(); 1593→ scheduleReconnect(); 1594→ break; 1595→ 1596→ case 9: // Invalid Session 1597→ const canResume = d as boolean; 1598→ const currentLevel = INTENT_LEVELS[intentLevelIndex]; 1599→ log?.error(`[qqbot:${account.accountId}] Invalid session (${currentLevel.description}), can resume: ${canResume}, raw: ${rawData}`); 1600→ 1601→ if (!canResume) { 1602→ sessionId = null; 1603→ lastSeq = null; 1604→ // P1-2: 清除持久化的 Session 1605→ clearSession(account.accountId); 1606→ 1607→ // 尝试降级到下一个权限级别 1608→ if (intentLevelIndex < INTENT_LEVELS.length - 1) { 1609→ intentLevelIndex++; 1610→ const nextLevel = INTENT_LEVELS[intentLevelIndex]; 1611→ log?.info(`[qqbot:${account.accountId}] Downgrading intents to: ${nextLevel.description}`); 1612→ } else { 1613→ // 已经是最低权限级别了 1614→ log?.error(`[qqbot:${account.accountId}] All intent levels failed. Please check AppID/Secret.`); 1615→ shouldRefreshToken = true; 1616→ } 1617→ } 1618→ cleanup(); 1619→ // Invalid Session 后等待一段时间再重连 1620→ scheduleReconnect(3000); 1621→ break; 1622→ } 1623→ } catch (err) { 1624→ log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`); 1625→ } 1626→ }); 1627→ 1628→ ws.on("close", (code, reason) => { 1629→ log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`); 1630→ isConnecting = false; // 释放锁 1631→ 1632→ // 根据错误码处理(参考 QQ 官方文档) 1633→ // 4004: CODE_INVALID_TOKEN - Token 无效,需刷新 token 重新连接 1634→ // 4006: CODE_SESSION_NO_LONGER_VALID - 会话失效,需重新 identify 1635→ // 4007: CODE_INVALID_SEQ - Resume 时 seq 无效,需重新 identify 1636→ // 4008: CODE_RATE_LIMITED - 限流断开,等待后重连 1637→ // 4009: CODE_SESSION_TIMED_OUT - 会话超时,需重新 identify 1638→ // 4900-4913: 内部错误,需要重新 identify 1639→ // 4914: 机器人已下架 1640→ // 4915: 机器人已封禁 1641→ if (code === 4914 || code === 4915) { 1642→ log?.error(`[qqbot:${account.accountId}] Bot is ${code === 4914 ? "offline/sandbox-only" : "banned"}. Please contact QQ platform.`); 1643→ cleanup(); 1644→ // 不重连,直接退出 1645→ return; 1646→ } 1647→ 1648→ // 4004: Token 无效,强制刷新 token 后重连 1649→ if (code === 4004) { 1650→ log?.info(`[qqbot:${account.accountId}] Invalid token (4004), will refresh token and reconnect`); 1651→ shouldRefreshToken = true; 1652→ cleanup(); 1653→ if (!isAborted) { 1654→ scheduleReconnect(); 1655→ } 1656→ return; 1657→ } 1658→ 1659→ // 4008: 限流断开,等待后重连(不需要重新 identify) 1660→ if (code === 4008) { 1661→ log?.info(`[qqbot:${account.accountId}] Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms before reconnect`); 1662→ cleanup(); 1663→ if (!isAborted) { 1664→ scheduleReconnect(RATE_LIMIT_DELAY); 1665→ } 1666→ return; 1667→ } 1668→ 1669→ // 4006/4007/4009: 会话失效或超时,需要清除 session 重新 identify 1670→ if (code === 4006 || code === 4007 || code === 4009) { 1671→ const codeDesc: Record = { 1672→ 4006: "session no longer valid", 1673→ 4007: "invalid seq on resume", 1674→ 4009: "session timed out", 1675→ }; 1676→ log?.info(`[qqbot:${account.accountId}] Error ${code} (${codeDesc[code]}), will re-identify`); 1677→ sessionId = null; 1678→ lastSeq = null; 1679→ // 清除持久化的 Session 1680→ clearSession(account.accountId); 1681→ shouldRefreshToken = true; 1682→ } else if (code >= 4900 && code <= 4913) { 1683→ // 4900-4913 内部错误,清除 session 重新 identify 1684→ log?.info(`[qqbot:${account.accountId}] Internal error (${code}), will re-identify`); 1685→ sessionId = null; 1686→ lastSeq = null; 1687→ // 清除持久化的 Session 1688→ clearSession(account.accountId); 1689→ shouldRefreshToken = true; 1690→ } 1691→ 1692→ // 检测是否是快速断开(连接后很快就断了) 1693→ const connectionDuration = Date.now() - lastConnectTime; 1694→ if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && lastConnectTime > 0) { 1695→ quickDisconnectCount++; 1696→ log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`); 1697→ 1698→ // 如果连续快速断开超过阈值,等待更长时间 1699→ if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) { 1700→ log?.error(`[qqbot:${account.accountId}] Too many quick disconnects. This may indicate a permission issue.`); 1701→ log?.error(`[qqbot:${account.accountId}] Please check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform`); 1702→ quickDisconnectCount = 0; 1703→ cleanup(); 1704→ // 快速断开太多次,等待更长时间再重连 1705→ if (!isAborted && code !== 1000) { 1706→ scheduleReconnect(RATE_LIMIT_DELAY); 1707→ } 1708→ return; 1709→ } 1710→ } else { 1711→ // 连接持续时间够长,重置计数 1712→ quickDisconnectCount = 0; 1713→ } 1714→ 1715→ cleanup(); 1716→ 1717→ // 非正常关闭则重连 1718→ if (!isAborted && code !== 1000) { 1719→ scheduleReconnect(); 1720→ } 1721→ }); 1722→ 1723→ ws.on("error", (err) => { 1724→ log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`); 1725→ onError?.(err); 1726→ }); 1727→ 1728→ } catch (err) { 1729→ isConnecting = false; // 释放锁 1730→ const errMsg = String(err); 1731→ log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`); 1732→ 1733→ // 如果是频率限制错误,等待更长时间 1734→ if (errMsg.includes("Too many requests") || errMsg.includes("100001")) { 1735→ log?.info(`[qqbot:${account.accountId}] Rate limited, waiting ${RATE_LIMIT_DELAY}ms before retry`); 1736→ scheduleReconnect(RATE_LIMIT_DELAY); 1737→ } else { 1738→ scheduleReconnect(); 1739→ } 1740→ } 1741→ }; 1742→ 1743→ // 开始连接 1744→ await connect(); 1745→ 1746→ // 等待 abort 信号 1747→ return new Promise((resolve) => { 1748→ abortSignal.addEventListener("abort", () => resolve()); 1749→ }); 1750→} 1751→ Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.