   560→
   561→            if (target.type === "c2c") {
   562→              const result = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
   563→              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   564→            } else if (target.type === "group") {
   565→              const result = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
   566→              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   567→            } else {
   568→              const result = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
   569→              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   570→            }
   571→          }
   572→          console.log(`[qqbot] sendText: Sent video via <qqvideo> tag: ${videoPath.slice(0, 60)}...`);
   573→        } else if (item.type === "file") {
   574→          // 发送文件
   575→          const filePath = item.content;
   576→          const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
   577→          const fileName = sanitizeFileName(path.basename(filePath));
   578→
   579→          if (isHttpUrl) {
   580→            // 公网 URL：直接通过 url 参数上传
   581→            if (target.type === "c2c") {
   582→              const result = await sendC2CFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
   583→              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   584→            } else if (target.type === "group") {
   585→              const result = await sendGroupFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
   586→              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   587→            } else {
   588→              const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
   589→              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   590→            }
   591→          } else {
   592→            // 本地文件：读取转 Base64 上传
   593→            if (!(await fileExistsAsync(filePath))) {
   594→              console.error(`[qqbot] sendText: File not found: ${filePath}`);
   595→              continue;
   596→            }
   597→            const fileSizeCheck = checkFileSize(filePath);
   598→            if (!fileSizeCheck.ok) {
   599→              console.error(`[qqbot] sendText: ${fileSizeCheck.error}`);
   600→              continue;
   601→            }
   602→            // 大文件进度提示
   603→            if (isLargeFile(fileSizeCheck.size)) {
   604→              try {
   605→                const hint = `⏳ 正在上传文件 ${fileName} (${formatFileSize(fileSizeCheck.size)})...`;
   606→                if (target.type === "c2c") {
   607→                  await sendC2CMessage(accessToken, target.id, hint, replyToId ?? undefined);
   608→                } else if (target.type === "group") {
   609→                  await sendGroupMessage(accessToken, target.id, hint, replyToId ?? undefined);
   610→                }
   611→              } catch {}
   612→            }
   613→            const fileBuffer = await readFileAsync(filePath);
   614→            const fileBase64 = fileBuffer.toString("base64");
   615→            console.log(`[qqbot] sendText: Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);
   616→
   617→            if (target.type === "c2c") {
   618→              const result = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
   619→              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   620→            } else if (target.type === "group") {
   621→              const result = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
   622→              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   623→            } else {
   624→              const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
   625→              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   626→            }
   627→          }
   628→          console.log(`[qqbot] sendText: Sent file via <qqfile> tag: ${filePath.slice(0, 60)}...`);
   629→        }
   630→      } catch (err) {
   631→        const errMsg = err instanceof Error ? err.message : String(err);
   632→        console.error(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
   633→        // 继续发送队列中的其他内容
   634→      }
   635→    }
   636→
   637→    return lastResult;
   638→  }
   639→
   640→  // ============ 主动消息校验（参考 Telegram 机制） ============
   641→  // 如果是主动消息（无 replyToId 或降级后），必须有消息内容
   642→  if (!replyToId) {
   643→    if (!text || text.trim().length === 0) {
   644→      console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)");
   645→      return {
   646→        channel: "qqbot",
   647→        error: "主动消息必须有内容 (--message 参数不能为空)"
   648→      };
   649→    }
   650→    if (fallbackToProactive) {
   651→      console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`);
   652→    } else {
   653→      console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`);
   654→    }
   655→  }
   656→
   657→  if (!account.appId || !account.clientSecret) {
   658→    return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
   659→  }
   660→
   661→  try {
   662→    const accessToken = await getAccessToken(account.appId, account.clientSecret);
   663→    const target = parseTarget(to);
   664→    console.log("[qqbot] sendText target:", JSON.stringify(target));
   665→
   666→    // 如果没有 replyToId，使用主动发送接口
   667→    if (!replyToId) {
   668→      if (target.type === "c2c") {
   669→        const result = await sendProactiveC2CMessage(accessToken, target.id, text);
   670→        return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   671→      } else if (target.type === "group") {
   672→        const result = await sendProactiveGroupMessage(accessToken, target.id, text);
   673→        return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   674→      } else {
   675→        // 频道暂不支持主动消息
   676→        const result = await sendChannelMessage(accessToken, target.id, text);
   677→        return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   678→      }
   679→    }
   680→
   681→    // 有 replyToId，使用被动回复接口
   682→    if (target.type === "c2c") {
   683→      const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
   684→      // 记录回复次数
   685→      recordMessageReply(replyToId);
   686→      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   687→    } else if (target.type === "group") {
   688→      const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
   689→      // 记录回复次数
   690→      recordMessageReply(replyToId);
   691→      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   692→    } else {
   693→      const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
   694→      // 记录回复次数
   695→      recordMessageReply(replyToId);
   696→      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   697→    }
   698→  } catch (err) {
   699→    const message = err instanceof Error ? err.message : String(err);

<system-reminder>
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.
</system-reminder>

            if (target.type === "c2c") {
              const result = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
            } else if (target.type === "group") {
              const result = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
            } else {
              const result = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
            }
          }
          console.log(`[qqbot] sendText: Sent video via <qqvideo> tag: ${videoPath.slice(0, 60)}...`);
        } else if (item.type === "file") {
          // 发送文件
          const filePath = item.content;
          const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
          const fileName = sanitizeFileName(path.basename(filePath));

          if (isHttpUrl) {
            // 公网 URL：直接通过 url 参数上传
            if (target.type === "c2c") {
              const result = await sendC2CFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
            } else if (target.type === "group") {
              const result = await sendGroupFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
            } else {
              const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
            }
          } else {
            // 本地文件：读取转 Base64 上传
            if (!(await fileExistsAsync(filePath))) {
              console.error(`[qqbot] sendText: File not found: ${filePath}`);
              continue;
            }
            const fileSizeCheck = checkFileSize(filePath);
            if (!fileSizeCheck.ok) {
              console.error(`[qqbot] sendText: ${fileSizeCheck.error}`);
              continue;
            }
            // 大文件进度提示
            if (isLargeFile(fileSizeCheck.size)) {
              try {
                const hint = `⏳ 正在上传文件 ${fileName} (${formatFileSize(fileSizeCheck.size)})...`;
                if (target.type === "c2c") {
                  await sendC2CMessage(accessToken, target.id, hint, replyToId ?? undefined);
                } else if (target.type === "group") {
                  await sendGroupMessage(accessToken, target.id, hint, replyToId ?? undefined);
                }
              } catch {}
            }
            const fileBuffer = await readFileAsync(filePath);
            const fileBase64 = fileBuffer.toString("base64");
            console.log(`[qqbot] sendText: Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);

            if (target.type === "c2c") {
              const result = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
            } else if (target.type === "group") {
              const result = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
            } else {
              const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
              lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
            }
          }
          console.log(`[qqbot] sendText: Sent file via <qqfile> tag: ${filePath.slice(0, 60)}...`);
        }
      } catch (err) {
        const errMsg = err instanceof Error ? err.message : String(err);
        console.error(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
        // 继续发送队列中的其他内容
      }
    }

    return lastResult;
  }

  // ============ 主动消息校验（参考 Telegram 机制） ============
  // 如果是主动消息（无 replyToId 或降级后），必须有消息内容
  if (!replyToId) {
    if (!text || text.trim().length === 0) {
      console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)");
      return {
        channel: "qqbot",
        error: "主动消息必须有内容 (--message 参数不能为空)"
      };
    }
    if (fallbackToProactive) {
      console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`);
    } else {
      console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`);
    }
  }

  if (!account.appId || !account.clientSecret) {
    return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
  }

  try {
    const accessToken = await getAccessToken(account.appId, account.clientSecret);
    const target = parseTarget(to);
    console.log("[qqbot] sendText target:", JSON.stringify(target));

    // 如果没有 replyToId，使用主动发送接口
    if (!replyToId) {
      if (target.type === "c2c") {
        const result = await sendProactiveC2CMessage(accessToken, target.id, text);
        return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
      } else if (target.type === "group") {
        const result = await sendProactiveGroupMessage(accessToken, target.id, text);
        return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
      } else {
        // 频道暂不支持主动消息
        const result = await sendChannelMessage(accessToken, target.id, text);
        return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
      }
    }

    // 有 replyToId，使用被动回复接口
    if (target.type === "c2c") {
      const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
      // 记录回复次数
      recordMessageReply(replyToId);
      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
    } else if (target.type === "group") {
      const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
      // 记录回复次数
      recordMessageReply(replyToId);
      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
    } else {
      const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
      // 记录回复次数
      recordMessageReply(replyToId);
      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
    }
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);


extensions/qqbot/src/utils/audio-convert.ts:122:  if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) {
extensions/qqbot/src/utils/audio-convert.ts:153:  voice: string;
extensions/qqbot/src/utils/audio-convert.ts:169:  const voice: string = block?.voice || "alloy";
extensions/qqbot/src/utils/audio-convert.ts:180:    voice,
extensions/qqbot/src/utils/audio-convert.ts:242:  console.log(`[tts] Request: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, url=${url}`);
extensions/qqbot/src/utils/audio-convert.ts:262:        voice: ttsCfg.voice,
extensions/qqbot/src/utils/media-tags.ts:10:const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile"] as const;
extensions/qqbot/src/utils/media-tags.ts:29:  // ---- qqvoice 变体 ----
extensions/qqbot/src/utils/media-tags.ts:30:  "qq_voice": "qqvoice",
extensions/qqbot/src/utils/media-tags.ts:31:  "qqaudio": "qqvoice",
extensions/qqbot/src/utils/media-tags.ts:32:  "qq_audio": "qqvoice",
extensions/qqbot/src/utils/media-tags.ts:33:  "voice": "qqvoice",
extensions/qqbot/src/utils/media-tags.ts:34:  "audio": "qqvoice",
extensions/qqbot/src/utils/media-tags.ts:60: *  3. 闭合标签不匹配：<qqimg>url</qqvoice>, <qqimg>url</img>
extensions/qqbot/src/api.ts:555:export async function sendC2CVoiceMessage(accessToken: string, openid: string, voiceBase64: string, msgId?: string): Promise<{ id: string; timestamp: number }> {
extensions/qqbot/src/api.ts:556:  const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.VOICE, undefined, voiceBase64, false);
extensions/qqbot/src/api.ts:560:export async function sendGroupVoiceMessage(accessToken: string, groupOpenid: string, voiceBase64: string, msgId?: string): Promise<{ id: string; timestamp: string }> {
extensions/qqbot/src/api.ts:561:  const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.VOICE, undefined, voiceBase64, false);
extensions/qqbot/src/gateway.ts:304:  attachments?: Array<{ content_type: string; url: string; filename?: string; voice_wav_url?: string }>;
extensions/qqbot/src/gateway.ts:363:    log?.info(`[qqbot:${account.accountId}] TTS configured: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, baseUrl=${ttsCfg.baseUrl}`);
extensions/qqbot/src/gateway.ts:366:    log?.info(`[qqbot:${account.accountId}] TTS not configured (voice messages will be unavailable)`);
extensions/qqbot/src/gateway.ts:588:        attachments?: Array<{ content_type: string; url: string; filename?: string; voice_wav_url?: string }>;
extensions/qqbot/src/gateway.ts:660:        const voiceTranscripts: string[] = [];
extensions/qqbot/src/gateway.ts:671:            // 语音附件：优先下载 WAV（voice_wav_url），减少 SILK→WAV 转换
extensions/qqbot/src/gateway.ts:676:            if (isVoice && att.voice_wav_url) {
extensions/qqbot/src/gateway.ts:677:              const wavUrl = att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url;
extensions/qqbot/src/gateway.ts:684:                log?.error(`[qqbot:${account.accountId}] Failed to download voice_wav_url, falling back to original URL`);
extensions/qqbot/src/gateway.ts:702:                  voiceTranscripts.push("[语音消息 - 语音识别未配置，无法转录]");
extensions/qqbot/src/gateway.ts:704:                  // 如果还没有 WAV 路径（voice_wav_url 不可用），需要 SILK→WAV 转换
extensions/qqbot/src/gateway.ts:718:                      voiceTranscripts.push("[语音消息 - 格式转换失败]");
extensions/qqbot/src/gateway.ts:728:                      voiceTranscripts.push(transcript);
extensions/qqbot/src/gateway.ts:731:                      voiceTranscripts.push("[语音消息 - 转录结果为空]");
extensions/qqbot/src/gateway.ts:735:                    voiceTranscripts.push("[语音消息 - 转录失败]");
extensions/qqbot/src/gateway.ts:760:        let voiceText = "";
extensions/qqbot/src/gateway.ts:761:        if (voiceTranscripts.length > 0) {
extensions/qqbot/src/gateway.ts:762:          voiceText = voiceTranscripts.length === 1
extensions/qqbot/src/gateway.ts:763:            ? `[语音消息] ${voiceTranscripts[0]}`
extensions/qqbot/src/gateway.ts:764:            : voiceTranscripts.map((t, i) => `[语音${i + 1}] ${t}`).join("\n");
extensions/qqbot/src/gateway.ts:769:        const userContent = voiceText
extensions/qqbot/src/gateway.ts:770:          ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
extensions/qqbot/src/gateway.ts:805:        // 语音能力说明：<qqvoice> 标签本身只负责发送已有的音频文件，不依赖插件 TTS。
extensions/qqbot/src/gateway.ts:807:        // 因此始终暴露 <qqvoice> 能力，但根据 TTS 状态给出不同的使用指引。
extensions/qqbot/src/gateway.ts:809:          ? `6. 🎤 插件 TTS 已启用: 如果你有 TTS 工具（如 audio_speech），可用它生成音频文件后用 <qqvoice> 发送`
extensions/qqbot/src/gateway.ts:810:          : `6. ⚠ 插件 TTS 未配置: 如果你有 TTS 工具（如 audio_speech），仍可用它生成音频文件后用 <qqvoice> 发送；若无 TTS 工具，则无法主动生成语音`;
extensions/qqbot/src/gateway.ts:814:        const voiceSection = `
extensions/qqbot/src/gateway.ts:817:1. 发语音方法: 在回复文本中写 <qqvoice>本地音频文件路径</qqvoice>，系统自动处理
extensions/qqbot/src/gateway.ts:818:2. 示例: "来听听吧！ <qqvoice>/tmp/tts/voice.mp3</qqvoice>"
extensions/qqbot/src/gateway.ts:820:4. ⚠ <qqvoice> 只用于语音文件，图片请用 <qqimg>；两者不要混用
extensions/qqbot/src/gateway.ts:839:5. 不要说"无法发送图片"，直接用 <qqimg> 标签发${voiceSection}
extensions/qqbot/src/gateway.ts:846:5. ⚠ 图片用 <qqimg>，语音用 <qqvoice>，其他文件用 <qqfile>
extensions/qqbot/src/gateway.ts:852:4. ⚠ 视频用 <qqvideo>，图片用 <qqimg>，语音用 <qqvoice>，文件用 <qqfile>
extensions/qqbot/src/gateway.ts:1006:                // 不应直接发送给用户。AI 会在后续的 "block" deliver 中用 <qqvoice> 等标签
extensions/qqbot/src/gateway.ts:1018:                //   <qqvoice>路径</qqvoice>                   — 语音
extensions/qqbot/src/gateway.ts:1026:                const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
extensions/qqbot/src/gateway.ts:1031:                  const voiceCount = mediaTagMatches.filter(m => m[1]!.toLowerCase() === "qqvoice").length;
extensions/qqbot/src/gateway.ts:1034:                  log?.info(`[qqbot:${account.accountId}] Detected media tags: ${imgCount} <qqimg>, ${voiceCount} <qqvoice>, ${videoCount} <qqvideo>, ${fileCount} <qqfile>`);
extensions/qqbot/src/gateway.ts:1037:                  const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file"; content: string }> = [];
extensions/qqbot/src/gateway.ts:1040:                  const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
extensions/qqbot/src/gateway.ts:1050:                    const tagName = match[1]!.toLowerCase(); // "qqimg" or "qqvoice" or "qqfile"
extensions/qqbot/src/gateway.ts:1102:                      if (tagName === "qqvoice") {
extensions/qqbot/src/gateway.ts:1103:                        sendQueue.push({ type: "voice", content: mediaPath });
extensions/qqbot/src/gateway.ts:1104:                        log?.info(`[qqbot:${account.accountId}] Found voice path in <qqvoice>: ${mediaPath}`);
extensions/qqbot/src/gateway.ts:1231:                    } else if (item.type === "voice") {
extensions/qqbot/src/gateway.ts:1233:                      const voicePath = normalizePath(item.content);
extensions/qqbot/src/gateway.ts:1236:                        const fileSize = await waitForFile(voicePath);
extensions/qqbot/src/gateway.ts:1238:                          log?.error(`[qqbot:${account.accountId}] Voice file not ready after waiting: ${voicePath}`);
extensions/qqbot/src/gateway.ts:1244:                        const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
extensions/qqbot/src/gateway.ts:1245:                        const silkBase64 = await audioFileToSilkBase64(voicePath, uploadFormats);
extensions/qqbot/src/gateway.ts:1247:                          const ext = path.extname(voicePath).toLowerCase();
extensions/qqbot/src/gateway.ts:1263:                        log?.info(`[qqbot:${account.accountId}] Sent voice via <qqvoice> tag: ${voicePath.slice(0, 60)}...`);
extensions/qqbot/src/gateway.ts:1265:                        log?.error(`[qqbot:${account.accountId}] Failed to send voice from <qqvoice>: ${err}`);
extensions/qqbot/src/gateway.ts:1541:                              log?.info(`[qqbot:${account.accountId}] TTS done: ${formatDuration(duration)}, uploading voice...`);
extensions/qqbot/src/gateway.ts:1556:                          log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
extensions/qqbot/src/gateway.ts:1720:                      suggestedTag = "qqvoice";
extensions/qqbot/src/gateway.ts:1763:                        suggestedTag = "qqvoice";
extensions/qqbot/src/types.ts:50:  voiceDirectUploadFormats?: string[];
extensions/qqbot/src/types.ts:87:  voice_wav_url?: string;  // QQ 提供的 WAV 格式语音直链，有值时优先使用以避免 SILK→WAV 转换
extensions/qqbot/src/outbound.ts:278:  //   <qqvoice>路径</qqvoice>                   — 语音
extensions/qqbot/src/outbound.ts:285:  const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
extensions/qqbot/src/outbound.ts:292:    const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file"; content: string }> = [];
extensions/qqbot/src/outbound.ts:295:    const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
extensions/qqbot/src/outbound.ts:305:      const tagName = match[1]!.toLowerCase(); // "qqimg" or "qqvoice" or "qqfile"
extensions/qqbot/src/outbound.ts:357:        if (tagName === "qqvoice") {
extensions/qqbot/src/outbound.ts:358:          sendQueue.push({ type: "voice", content: mediaPath });
extensions/qqbot/src/outbound.ts:359:          console.log(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
extensions/qqbot/src/outbound.ts:472:        } else if (item.type === "voice") {
extensions/qqbot/src/outbound.ts:474:          const voicePath = item.content;
extensions/qqbot/src/outbound.ts:477:          const fileSize = await waitForFile(voicePath);
extensions/qqbot/src/outbound.ts:479:            console.error(`[qqbot] sendText: Voice file not ready after waiting: ${voicePath}`);
extensions/qqbot/src/outbound.ts:492:          const silkBase64 = await audioFileToSilkBase64(voicePath);
extensions/qqbot/src/outbound.ts:494:            const ext = path.extname(voicePath).toLowerCase();
extensions/qqbot/src/outbound.ts:517:          console.log(`[qqbot] sendText: Sent voice via <qqvoice> tag: ${voicePath.slice(0, 60)}...`);
extensions/qqbot/src/outbound.ts:953:    const directFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
extensions/qqbot/src/outbound.ts:982:    let voiceResult: { id: string; timestamp: number | string };
extensions/qqbot/src/outbound.ts:984:      voiceResult = await sendC2CVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
extensions/qqbot/src/outbound.ts:986:      voiceResult = await sendGroupVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
extensions/qqbot/src/outbound.ts:1001:        console.error(`[qqbot] Failed to send text after voice: ${textErr}`);
extensions/qqbot/src/outbound.ts:1005:    console.log(`[qqbot] sendVoiceFile: voice message sent`);
extensions/qqbot/src/outbound.ts:1006:    return { channel: "qqbot", messageId: voiceResult.id, timestamp: voiceResult.timestamp };


好的，我看到了！QQ Bot 插件确实支持发送语音消息，但有一个问题！

在 `src/outbound.ts` 中有一个专门的 `sendMedia` 函数（我之前看到过），但这个函数只处理**图片**类型的媒体！而当 OpenClaw 的 TTS 功能生成音频文件后，它会调用 `sendMedia` 函数来发送这个音频文件，但是 `sendMedia` 函数只会上传图片，不会处理音频！

让我检查一下 `sendMedia` 函数的实现，确认这个问题。


我需要检查 `sendMedia` 函数的实现，看看它是否只处理图片类型的媒体。


Bash

grep -A 100 "export async function sendMedia" /root/.openclaw/extensions/qqbot/src/outbound.ts

查看sendMedia函数的实现


export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
  const { to, text, replyToId, account } = ctx;
  // 展开波浪线路径：~/Desktop/file.png → /Users/xxx/Desktop/file.png
  const mediaUrl = normalizePath(ctx.mediaUrl);

  if (!account.appId || !account.clientSecret) {
    return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
  }

  if (!mediaUrl) {
    return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
  }

  // 判断是否为语音文件（本地文件路径 + 音频扩展名）
  const isLocalPath = isLocalFilePath(mediaUrl);
  const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");

  if (isLocalPath && isAudioFile(mediaUrl)) {
    return sendVoiceFile(ctx);
  }

  // 判断是否为视频（公网 URL 或本地视频文件）
  if (isVideoFile(mediaUrl)) {
    if (isHttpUrl) {
      return sendVideoUrl(ctx);
    }
    if (isLocalPath) {
      return sendVideoFile(ctx);
    }
  }

  // 判断是否为文档/文件（非图片、非音频、非视频的本地文件）
  if (isLocalPath && !isImageFile(mediaUrl) && !isAudioFile(mediaUrl)) {
    return sendDocumentFile(ctx);
  }

  // === 以下为图片发送逻辑（原有逻辑） ===

  const isDataUrl = mediaUrl.startsWith("data:");

  let processedMediaUrl = mediaUrl;

  if (isLocalPath) {
    console.log(`[qqbot] sendMedia: local file path detected: ${mediaUrl}`);

    try {
      if (!(await fileExistsAsync(mediaUrl))) {
        return { channel: "qqbot", error: `本地文件不存在: ${mediaUrl}` };
      }

      // 文件大小校验
      const sizeCheck = checkFileSize(mediaUrl);
      if (!sizeCheck.ok) {
        return { channel: "qqbot", error: sizeCheck.error! };
      }

      const fileBuffer = await readFileAsync(mediaUrl);
      const base64Data = fileBuffer.toString("base64");

      const ext = path.extname(mediaUrl).toLowerCase();
      const mimeTypes: Record<string, string> = {
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".png": "image/png",
        ".gif": "image/gif",
        ".webp": "image/webp",
        ".bmp": "image/bmp",
      };

      const mimeType = mimeTypes[ext];
      if (!mimeType) {
        return {
          channel: "qqbot",
          error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`
        };
      }

      processedMediaUrl = `data:${mimeType};base64,${base64Data}`;
      console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`);

    } catch (readErr) {
      const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
      console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`);
      return { channel: "qqbot", error: `读取本地文件失败: ${errMsg}` };
    }
  } else if (!isHttpUrl && !isDataUrl) {
    console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`);
    return {
      channel: "qqbot",
      error: `不支持的媒体格式: ${mediaUrl.slice(0, 50)}...。支持: 公网 URL、Base64 Data URL 或本地文件路径（图片/音频）。`
    };
  } else if (isDataUrl) {
    console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`);
  } else {
    console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`);
  }

  try {
    const accessToken = await getAccessToken(account.appId, account.clientSecret);
    const target = parseTarget(to);


好的，我看到 `sendMedia` 函数确实有处理语音文件的逻辑！但是让我继续往下看，看看它是否正确调用了 `sendVoiceFile`，以及 `sendVoiceFile` 函数的实现是怎样的。


我需要查看 `sendMedia` 函数的剩余部分以及 `sendVoiceFile` 函数的实现。


grep -A 100 "function sendVoiceFile" /root/.openclaw/extensions/qqbot/src/outbound.ts

查看sendVoiceFile函数的实现


   730→    console.log(`[${timestamp}] [qqbot] sendProactiveMessage: parsing target=${to}`);
   731→    const target = parseTarget(to);
   732→    console.log(`[${timestamp}] [qqbot] sendProactiveMessage: target parsed, type=${target.type}, id=${target.id}`);
   733→
   734→    if (target.type === "c2c") {
   735→      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive C2C message to user=${target.id}`);
   736→      const result = await sendProactiveC2CMessage(accessToken, target.id, text);
   737→      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: proactive C2C message sent successfully, messageId=${result.id}`);
   738→      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   739→    } else if (target.type === "group") {
   740→      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive group message to group=${target.id}`);
   741→      const result = await sendProactiveGroupMessage(accessToken, target.id, text);
   742→      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: proactive group message sent successfully, messageId=${result.id}`);
   743→      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   744→    } else {
   745→      // 频道暂不支持主动消息，使用普通发送
   746→      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending channel message to channel=${target.id}`);
   747→      const result = await sendChannelMessage(accessToken, target.id, text);
   748→      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: channel message sent successfully, messageId=${result.id}`);
   749→      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
   750→    }
   751→  } catch (err) {
   752→    const errorMessage = err instanceof Error ? err.message : String(err);
   753→    console.error(`[${timestamp}] [qqbot] sendProactiveMessage: error: ${errorMessage}`);
   754→    console.error(`[${timestamp}] [qqbot] sendProactiveMessage: error stack: ${err instanceof Error ? err.stack : 'No stack trace'}`);
   755→    return { channel: "qqbot", error: errorMessage };
   756→  }
   757→}
   758→
   759→/**
   760→ * 发送富媒体消息（图片）
   761→ *
   762→ * 支持以下 mediaUrl 格式：
   763→ * - 公网 URL: https://example.com/image.png
   764→ * - Base64 Data URL: data:image/png;base64,xxxxx
   765→ * - 本地文件路径: /path/to/image.png（自动读取并转换为 Base64）
   766→ *
   767→ * @param ctx - 发送上下文，包含 mediaUrl
   768→ * @returns 发送结果
   769→ *
   770→ * @example
   771→ * ```typescript
   772→ * // 发送网络图片
   773→ * const result = await sendMedia({
   774→ *   to: "group:xxx",
   775→ *   text: "这是图片说明",
   776→ *   mediaUrl: "https://example.com/image.png",
   777→ *   account,
   778→ *   replyToId: msgId,
   779→ * });
   780→ *
   781→ * // 发送 Base64 图片
   782→ * const result = await sendMedia({
   783→ *   to: "group:xxx",
   784→ *   text: "这是图片说明",
   785→ *   mediaUrl: "data:image/png;base64,iVBORw0KGgo...",
   786→ *   account,
   787→ *   replyToId: msgId,
   788→ * });
   789→ *
   790→ * // 发送本地文件（自动读取并转换为 Base64）
   791→ * const result = await sendMedia({
   792→ *   to: "group:xxx",
   793→ *   text: "这是图片说明",
   794→ *   mediaUrl: "/tmp/generated-chart.png",
   795→ *   account,
   796→ *   replyToId: msgId,
   797→ * });
   798→ * ```
   799→ */
   800→export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
   801→  const { to, text, replyToId, account } = ctx;
   802→  // 展开波浪线路径：~/Desktop/file.png → /Users/xxx/Desktop/file.png
   803→  const mediaUrl = normalizePath(ctx.mediaUrl);
   804→
   805→  if (!account.appId || !account.clientSecret) {
   806→    return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
   807→  }
   808→
   809→  if (!mediaUrl) {
   810→    return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
   811→  }
   812→
   813→  // 判断是否为语音文件（本地文件路径 + 音频扩展名）
   814→  const isLocalPath = isLocalFilePath(mediaUrl);
   815→  const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
   816→
   817→  if (isLocalPath && isAudioFile(mediaUrl)) {
   818→    return sendVoiceFile(ctx);
   819→  }
   820→
   821→  // 判断是否为视频（公网 URL 或本地视频文件）
   822→  if (isVideoFile(mediaUrl)) {
   823→    if (isHttpUrl) {
   824→      return sendVideoUrl(ctx);
   825→    }
   826→    if (isLocalPath) {
   827→      return sendVideoFile(ctx);
   828→    }
   829→  }

<system-reminder>
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.
</system-reminder>

    console.log(`[${timestamp}] [qqbot] sendProactiveMessage: parsing target=${to}`);
    const target = parseTarget(to);
    console.log(`[${timestamp}] [qqbot] sendProactiveMessage: target parsed, type=${target.type}, id=${target.id}`);

    if (target.type === "c2c") {
      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive C2C message to user=${target.id}`);
      const result = await sendProactiveC2CMessage(accessToken, target.id, text);
      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: proactive C2C message sent successfully, messageId=${result.id}`);
      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
    } else if (target.type === "group") {
      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive group message to group=${target.id}`);
      const result = await sendProactiveGroupMessage(accessToken, target.id, text);
      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: proactive group message sent successfully, messageId=${result.id}`);
      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
    } else {
      // 频道暂不支持主动消息，使用普通发送
      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending channel message to channel=${target.id}`);
      const result = await sendChannelMessage(accessToken, target.id, text);
      console.log(`[${timestamp}] [qqbot] sendProactiveMessage: channel message sent successfully, messageId=${result.id}`);
      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
    }
  } catch (err) {
    const errorMessage = err instanceof Error ? err.message : String(err);
    console.error(`[${timestamp}] [qqbot] sendProactiveMessage: error: ${errorMessage}`);
    console.error(`[${timestamp}] [qqbot] sendProactiveMessage: error stack: ${err instanceof Error ? err.stack : 'No stack trace'}`);
    return { channel: "qqbot", error: errorMessage };
  }
}

/**
 * 发送富媒体消息（图片）
 *
 * 支持以下 mediaUrl 格式：
 * - 公网 URL: https://example.com/image.png
 * - Base64 Data URL: data:image/png;base64,xxxxx
 * - 本地文件路径: /path/to/image.png（自动读取并转换为 Base64）
 *
 * @param ctx - 发送上下文，包含 mediaUrl
 * @returns 发送结果
 *
 * @example
 * ```typescript
 * // 发送网络图片
 * const result = await sendMedia({
 *   to: "group:xxx",
 *   text: "这是图片说明",
 *   mediaUrl: "https://example.com/image.png",
 *   account,
 *   replyToId: msgId,
 * });
 *
 * // 发送 Base64 图片
 * const result = await sendMedia({
 *   to: "group:xxx",
 *   text: "这是图片说明",
 *   mediaUrl: "data:image/png;base64,iVBORw0KGgo...",
 *   account,
 *   replyToId: msgId,
 * });
 *
 * // 发送本地文件（自动读取并转换为 Base64）
 * const result = await sendMedia({
 *   to: "group:xxx",
 *   text: "这是图片说明",
 *   mediaUrl: "/tmp/generated-chart.png",
 *   account,
 *   replyToId: msgId,
 * });
 * ```
 */
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
  const { to, text, replyToId, account } = ctx;
  // 展开波浪线路径：~/Desktop/file.png → /Users/xxx/Desktop/file.png
  const mediaUrl = normalizePath(ctx.mediaUrl);

  if (!account.appId || !account.clientSecret) {
    return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
  }

  if (!mediaUrl) {
    return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
  }

  // 判断是否为语音文件（本地文件路径 + 音频扩展名）
  const isLocalPath = isLocalFilePath(mediaUrl);
  const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");

  if (isLocalPath && isAudioFile(mediaUrl)) {
    return sendVoiceFile(ctx);
  }

  // 判断是否为视频（公网 URL 或本地视频文件）
  if (isVideoFile(mediaUrl)) {
    if (isHttpUrl) {
      return sendVideoUrl(ctx);
    }
    if (isLocalPath) {
      return sendVideoFile(ctx);
    }
  }


async function sendVoiceFile(ctx: MediaOutboundContext): Promise<OutboundResult> {
  const { to, text, replyToId, account, mediaUrl } = ctx;

  console.log(`[qqbot] sendVoiceFile: ${mediaUrl}`);

  // 等待文件就绪（TTS 工具异步生成，文件可能还没写完）
  const fileSize = await waitForFile(mediaUrl);
  if (fileSize === 0) {
    return { channel: "qqbot", error: `语音生成失败，请稍后重试` };
  }

  try {
    // 尝试转换为 SILK 格式（QQ 语音要求 SILK 格式），支持配置直传格式跳过转换
    const directFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
    const silkBase64 = await audioFileToSilkBase64(mediaUrl, directFormats);
    if (!silkBase64) {
      // 如果无法转换为 SILK，直接读取文件作为 Base64 上传（让 API 尝试处理）
      const buf = await readFileAsync(mediaUrl);
      const fallbackBase64 = buf.toString("base64");
      console.log(`[qqbot] sendVoiceFile: not SILK format, uploading raw file (${formatFileSize(buf.length)})`);

      const accessToken = await getAccessToken(account.appId!, account.clientSecret!);
      const target = parseTarget(to);

      let result: { id: string; timestamp: number | string };
      if (target.type === "c2c") {
        result = await sendC2CVoiceMessage(accessToken, target.id, fallbackBase64, replyToId ?? undefined);
      } else if (target.type === "group") {
        result = await sendGroupVoiceMessage(accessToken, target.id, fallbackBase64, replyToId ?? undefined);
      } else {
        const r = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
        return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
      }

      return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
    }

    console.log(`[qqbot] sendVoiceFile: SILK format ready, uploading...`);

    const accessToken = await getAccessToken(account.appId!, account.clientSecret!);
    const target = parseTarget(to);

    let voiceResult: { id: string; timestamp: number | string };
    if (target.type === "c2c") {
      voiceResult = await sendC2CVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
    } else if (target.type === "group") {
      voiceResult = await sendGroupVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
    } else {
      const r = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
      return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
    }

    // 如果有文本说明，再发送一条文本消息
    if (text?.trim()) {
      try {
        if (target.type === "c2c") {
          await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
        } else if (target.type === "group") {
          await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
        }
      } catch (textErr) {
        console.error(`[qqbot] Failed to send text after voice: ${textErr}`);
      }
    }

    console.log(`[qqbot] sendVoiceFile: voice message sent`);
    return { channel: "qqbot", messageId: voiceResult.id, timestamp: voiceResult.timestamp };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    console.error(`[qqbot] sendVoiceFile: failed: ${message}`);
    return { channel: "qqbot", error: message };
  }
}

/** 判断文件是否为图片格式 */
function isImageFile(filePath: string): boolean {
  const ext = path.extname(filePath).toLowerCase();
  return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext);
}

/** 判断文件/URL 是否为视频格式 */
function isVideoFile(filePath: string): boolean {
  // 去掉 URL query 参数后判断扩展名
  const cleanPath = filePath.split("?")[0]!;
  const ext = path.extname(cleanPath).toLowerCase();
  return [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"].includes(ext);
}

/**
 * 发送视频消息（公网 URL）
 */
async function sendVideoUrl(ctx: MediaOutboundContext): Promise<OutboundResult> {
  const { to, text, replyToId, account, mediaUrl } = ctx;

  console.log(`[qqbot] sendVideoUrl: ${mediaUrl}`);

  if (!account.appId || !account.clientSecret) {
    return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
  }

  try {


好的，我看到 `sendMedia` 函数确实会调用 `sendVoiceFile` 函数来处理语音文件！而且 `sendVoiceFile` 函数也看起来实现得很完善。

但是，我需要看看 `isAudioFile` 函数是怎么定义的，以及 OpenClaw 核心的 TTS 功能是如何调用 `sendMedia` 的！

还有一个关键点，我在之前看到的 `maybeApplyTtsToPayload` 函数中，有这样一行代码：

```javascript
const shouldVoice = resolveChannelId(params.channel) === "telegram" && result.voiceCompatible === true;
```

这说明当 `params.channel` 不是 "telegram" 时，`shouldVoice` 就是 false！然后返回的 `ReplyPayload` 中会有 `mediaUrl`，但不会有 `audioAsVoice`！

让我搜索一下 `audioAsVoice` 在各个通道插件中是怎么被使用的！


现在我需要查看 `isAudioFile` 函数的定义，以及检查 OpenClaw 核心代码中 `audioAsVoice` 是如何被使用的。


grep -A 20 "function isAudioFile" /root/.openclaw/extensions/qqbot/src/outbound.ts

查看isAudioFile函数的定义


extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:11613:			audioAsVoice: shouldVoice || params.payload.audioAsVoice
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:16676:					audioAsVoice: ttsSyntheticReply.audioAsVoice
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:22555:		if (payload.audioAsVoice) {
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:41771:			if (reply?.audioAsVoice) {
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:41772:				logVerbose("telegram reply has audioAsVoice without media/text; skipping");
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:41855:					wantsVoice: reply.audioAsVoice === true,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:65321:		audioAsVoice: split.audioAsVoice,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:65325:const hasRenderableContent = (parsed) => Boolean(parsed.text) || Boolean(parsed.mediaUrl) || (parsed.mediaUrls?.length ?? 0) > 0 || Boolean(parsed.audioAsVoice);
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:65792:		const hasAudio = Boolean(parsedDelta?.audioAsVoice);
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:65926:				const { text: cleanedText, mediaUrls, audioAsVoice, replyToId, replyToTag, replyToCurrent } = splitResult;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:65927:				if (cleanedText || mediaUrls && mediaUrls.length > 0 || audioAsVoice) onBlockReply({
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:65930:					audioAsVoice,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:65943:			const { text: cleanedText, mediaUrls, audioAsVoice, replyToId, replyToTag, replyToCurrent } = tailResult;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:65944:			if (cleanedText || mediaUrls && mediaUrls.length > 0 || audioAsVoice) onBlockReply({
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:65947:				audioAsVoice,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:66970:		const { text: cleanedText, mediaUrls, audioAsVoice, replyToId, replyToTag, replyToCurrent } = splitResult;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:66971:		if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice) return;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:66975:			audioAsVoice,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:68678:		const { text: cleanedText, mediaUrls, audioAsVoice, replyToId, replyToTag, replyToCurrent } = parseReplyDirectives(formatToolAggregate(toolName, meta ? [meta] : [], { markdown: useMarkdown }));
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:68682:			audioAsVoice,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:68723:		const { text: cleanedText, mediaUrls, audioAsVoice, replyToId, replyToTag, replyToCurrent } = parseReplyDirectives(text);
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:68724:		if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice) continue;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:68728:			audioAsVoice,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:68756:	const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:68765:		audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length)
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:73553:					audioAsVoice: result.voiceCompatible === true
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75132:			audioAsVoice: bufferAudioAsVoice
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75151:			bufferAudioAsVoice = payload.audioAsVoice;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75157:		if (bufferText && (replyToConflict || bufferAudioAsVoice !== payload.audioAsVoice)) flush({ force: true });
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75160:			bufferAudioAsVoice = payload.audioAsVoice;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75167:				bufferAudioAsVoice = payload.audioAsVoice;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75200:			if (payload.audioAsVoice) seenAudioAsVoice = true;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75205:			audioAsVoice: true
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75365:			audioAsVoice: Boolean(params.payload.audioAsVoice || parsed?.audioAsVoice)
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75381:		if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/reply-Bs9SaomU.js:75391:		if (!blockPayload.text && !blockHasMedia && !blockPayload.audioAsVoice) return;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/deliver-Bo5iHu6D.js:568:		audioAsVoice: result.audioAsVoice,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/deliver-Bo5iHu6D.js:693:	const hasAudioAsVoice = audioTagResult.audioAsVoice;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/deliver-Bo5iHu6D.js:697:		if (hasAudioAsVoice) result.audioAsVoice = true;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/deliver-Bo5iHu6D.js:704:		...hasAudioAsVoice ? { audioAsVoice: true } : {}
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/deliver-Bo5iHu6D.js:729:		audioAsVoice: split.audioAsVoice,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/deliver-Bo5iHu6D.js:842:	return Boolean(payload.text || payload.mediaUrl || payload.mediaUrls && payload.mediaUrls.length > 0 || payload.audioAsVoice || payload.channelData);
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/deliver-Bo5iHu6D.js:937:			audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice)
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/sessions-CUeRK6_V.js:687:		audioAsVoice: false,
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/sessions-CUeRK6_V.js:693:	let audioAsVoice = false;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/sessions-CUeRK6_V.js:699:		audioAsVoice = true;
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/sessions-CUeRK6_V.js:716:		audioAsVoice,


好的，我看到 `audioAsVoice` 确实在代码中被使用了！让我查看一下 deliver-Bo5iHu6D.js 文件中关于 `audioAsVoice` 的使用，以及看看 QQ Bot 插件是否应该处理这个字段！


我需要查看 deliver-Bo5iHu6D.js 文件中关于 `audioAsVoice` 的使用情况，以及了解 QQ Bot 插件是否需要处理这个字段。


grep -A 50 -B 5 "audioAsVoice" /root/.openclaw/extensions/wecom/node_modules/openclaw/dist/plugin-sdk/deliver-Bo5iHu6D.js


     1→/**
     2→ * QQ Bot 配置类型
     3→ */
     4→export interface QQBotConfig {
     5→  appId: string;
     6→  clientSecret?: string;
     7→  clientSecretFile?: string;
     8→}
     9→
    10→/**
    11→ * 解析后的 QQ Bot 账户
    12→ */
    13→export interface ResolvedQQBotAccount {
    14→  accountId: string;
    15→  name?: string;
    16→  enabled: boolean;
    17→  appId: string;
    18→  clientSecret: string;
    19→  secretSource: "config" | "file" | "env" | "none";
    20→  /** 系统提示词 */
    21→  systemPrompt?: string;
    22→  /** 图床服务器公网地址 */
    23→  imageServerBaseUrl?: string;
    24→  /** 是否支持 markdown 消息（默认 true） */
    25→  markdownSupport: boolean;
    26→  config: QQBotAccountConfig;
    27→}
    28→
    29→/**
    30→ * QQ Bot 账户配置
    31→ */
    32→export interface QQBotAccountConfig {
    33→  enabled?: boolean;
    34→  name?: string;
    35→  appId?: string;
    36→  clientSecret?: string;
    37→  clientSecretFile?: string;
    38→  dmPolicy?: "open" | "pairing" | "allowlist";
    39→  allowFrom?: string[];
    40→  /** 系统提示词，会添加在用户消息前面 */
    41→  systemPrompt?: string;
    42→  /** 图床服务器公网地址，用于发送图片，例如 http://your-ip:18765 */
    43→  imageServerBaseUrl?: string;
    44→  /** 是否支持 markdown 消息（默认 true，设为 false 可禁用） */
    45→  markdownSupport?: boolean;
    46→  /**
    47→   * @deprecated 请使用 audioFormatPolicy.uploadDirectFormats
    48→   * 可直接上传的音频格式（不转换为 SILK），向后兼容
    49→   */
    50→  voiceDirectUploadFormats?: string[];
    51→  /**
    52→   * 音频格式策略配置
    53→   * 统一管理入站（STT）和出站（上传）的音频格式转换行为
    54→   */
    55→  audioFormatPolicy?: AudioFormatPolicy;
    56→}
    57→
    58→/**
    59→ * 音频格式策略：控制哪些格式可跳过转换
    60→ */
    61→export interface AudioFormatPolicy {
    62→  /**
    63→   * STT 模型直接支持的音频格式（入站：跳过 SILK→WAV 转换）
    64→   * 如果 STT 服务支持直接处理某些格式（如 silk/amr），可将其加入此列表
    65→   * 例如: [".silk", ".amr", ".wav", ".mp3", ".ogg"]
    66→   * 默认为空（所有语音都先转换为 WAV 再送 STT）
    67→   */
    68→  sttDirectFormats?: string[];
    69→  /**
    70→   * QQ 平台支持直传的音频格式（出站：跳过→SILK 转换）
    71→   * 默认为 [".wav", ".mp3", ".silk"]（QQ Bot API 原生支持的三种格式）
    72→   * 仅当需要覆盖默认值时才配置此项
    73→   */
    74→  uploadDirectFormats?: string[];
    75→}
    76→
    77→/**
    78→ * 富媒体附件
    79→ */
    80→export interface MessageAttachment {
    81→  content_type: string;  // 如 "image/png"
    82→  filename?: string;
    83→  height?: number;
    84→  width?: number;
    85→  size?: number;
    86→  url: string;
    87→  voice_wav_url?: string;  // QQ 提供的 WAV 格式语音直链，有值时优先使用以避免 SILK→WAV 转换
    88→}
    89→
    90→/**
    91→ * C2C 消息事件
    92→ */
    93→export interface C2CMessageEvent {
    94→  author: {
    95→    id: string;
    96→    union_openid: string;
    97→    user_openid: string;
    98→  };
    99→  content: string;
   100→  id: string;
   101→  timestamp: string;
   102→  message_scene?: {
   103→    source: string;
   104→  };
   105→  attachments?: MessageAttachment[];
   106→}
   107→
   108→/**
   109→ * 频道 AT 消息事件
   110→ */
   111→export interface GuildMessageEvent {
   112→  id: string;
   113→  channel_id: string;
   114→  guild_id: string;
   115→  content: string;
   116→  timestamp: string;
   117→  author: {
   118→    id: string;
   119→    username?: string;
   120→    bot?: boolean;
   121→  };
   122→  member?: {
   123→    nick?: string;
   124→    joined_at?: string;
   125→  };
   126→  attachments?: MessageAttachment[];
   127→}
   128→
   129→/**
   130→ * 群聊 AT 消息事件
   131→ */
   132→export interface GroupMessageEvent {
   133→  author: {
   134→    id: string;
   135→    member_openid: string;
   136→  };
   137→  content: string;
   138→  id: string;
   139→  timestamp: string;
   140→  group_id: string;
   141→  group_openid: string;
   142→  attachments?: MessageAttachment[];
   143→}
   144→
   145→/**
   146→ * WebSocket 事件负载
   147→ */
   148→export interface WSPayload {
   149→  op: number;
   150→  d?: unknown;
   151→  s?: number;
   152→  t?: string;
   153→}
   154→

<system-reminder>
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.
</system-reminder>

/**
 * QQ Bot 配置类型
 */
export interface QQBotConfig {
  appId: string;
  clientSecret?: string;
  clientSecretFile?: string;
}

/**
 * 解析后的 QQ Bot 账户
 */
export interface ResolvedQQBotAccount {
  accountId: string;
  name?: string;
  enabled: boolean;
  appId: string;
  clientSecret: string;
  secretSource: "config" | "file" | "env" | "none";
  /** 系统提示词 */
  systemPrompt?: string;
  /** 图床服务器公网地址 */
  imageServerBaseUrl?: string;
  /** 是否支持 markdown 消息（默认 true） */
  markdownSupport: boolean;
  config: QQBotAccountConfig;
}

/**
 * QQ Bot 账户配置
 */
export interface QQBotAccountConfig {
  enabled?: boolean;
  name?: string;
  appId?: string;
  clientSecret?: string;
  clientSecretFile?: string;
  dmPolicy?: "open" | "pairing" | "allowlist";
  allowFrom?: string[];
  /** 系统提示词，会添加在用户消息前面 */
  systemPrompt?: string;
  /** 图床服务器公网地址，用于发送图片，例如 http://your-ip:18765 */
  imageServerBaseUrl?: string;
  /** 是否支持 markdown 消息（默认 true，设为 false 可禁用） */
  markdownSupport?: boolean;
  /**
   * @deprecated 请使用 audioFormatPolicy.uploadDirectFormats
   * 可直接上传的音频格式（不转换为 SILK），向后兼容
   */
  voiceDirectUploadFormats?: string[];
  /**
   * 音频格式策略配置
   * 统一管理入站（STT）和出站（上传）的音频格式转换行为
   */
  audioFormatPolicy?: AudioFormatPolicy;
}

/**
 * 音频格式策略：控制哪些格式可跳过转换
 */
export interface AudioFormatPolicy {
  /**
   * STT 模型直接支持的音频格式（入站：跳过 SILK→WAV 转换）
   * 如果 STT 服务支持直接处理某些格式（如 silk/amr），可将其加入此列表
   * 例如: [".silk", ".amr", ".wav", ".mp3", ".ogg"]
   * 默认为空（所有语音都先转换为 WAV 再送 STT）
   */
  sttDirectFormats?: string[];
  /**
   * QQ 平台支持直传的音频格式（出站：跳过→SILK 转换）
   * 默认为 [".wav", ".mp3", ".silk"]（QQ Bot API 原生支持的三种格式）
   * 仅当需要覆盖默认值时才配置此项
   */
  uploadDirectFormats?: string[];
}

/**
 * 富媒体附件
 */
export interface MessageAttachment {
  content_type: string;  // 如 "image/png"
  filename?: string;
  height?: number;
  width?: number;
  size?: number;
  url: string;
  voice_wav_url?: string;  // QQ 提供的 WAV 格式语音直链，有值时优先使用以避免 SILK→WAV 转换
}

/**
 * C2C 消息事件
 */
export interface C2CMessageEvent {
  author: {
    id: string;
    union_openid: string;
    user_openid: string;
  };
  content: string;
  id: string;
  timestamp: string;
  message_scene?: {
    source: string;
  };
  attachments?: MessageAttachment[];
}

/**
 * 频道 AT 消息事件
 */
export interface GuildMessageEvent {
  id: string;
  channel_id: string;
  guild_id: string;
  content: string;
  timestamp: string;
  author: {
    id: string;
    username?: string;
    bot?: boolean;
  };
  member?: {
    nick?: string;
    joined_at?: string;
  };
  attachments?: MessageAttachment[];
}

/**
 * 群聊 AT 消息事件
 */
export interface GroupMessageEvent {
  author: {
    id: string;
    member_openid: string;
  };
  content: string;
  id: string;
  timestamp: string;
  group_id: string;
  group_openid: string;
  attachments?: MessageAttachment[];
}

/**
 * WebSocket 事件负载
 */
export interface WSPayload {
  op: number;
  d?: unknown;
  s?: number;
  t?: string;
}


*/
function parseAudioTag(text) {
	const result = parseInlineDirectives(text, { stripReplyTags: false });
	return {
		text: result.text,
		audioAsVoice: result.audioAsVoice,
		hadTag: result.hasAudioTag
	};
}

//#endregion
//#region src/media/parse.ts
const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
function normalizeMediaSource(src) {
	return src.startsWith("file://") ? src.replace("file://", "") : src;
}
function cleanCandidate(raw) {
	return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
}
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
const HAS_FILE_EXT = /\.\w{1,10}$/;
function isLikelyLocalPath(candidate) {
	return candidate.startsWith("/") || candidate.startsWith("./") || candidate.startsWith("../") || candidate.startsWith("~") || WINDOWS_DRIVE_RE.test(candidate) || candidate.startsWith("\\\\") || !SCHEME_RE.test(candidate) && (candidate.includes("/") || candidate.includes("\\"));
}
function isValidMedia(candidate, opts) {
	if (!candidate) return false;
	if (candidate.length > 4096) return false;
	if (!opts?.allowSpaces && /\s/.test(candidate)) return false;
	if (/^https?:\/\//i.test(candidate)) return true;
	if (isLikelyLocalPath(candidate)) return true;
	if (opts?.allowBareFilename && !SCHEME_RE.test(candidate) && HAS_FILE_EXT.test(candidate)) return true;
	return false;
}
function unwrapQuoted(value) {
	const trimmed = value.trim();
	if (trimmed.length < 2) return;
	const first = trimmed[0];
	if (first !== trimmed[trimmed.length - 1]) return;
	if (first !== `"` && first !== "'" && first !== "`") return;
	return trimmed.slice(1, -1).trim();
}
function isInsideFence(fenceSpans, offset) {
	return fenceSpans.some((span) => offset >= span.start && offset < span.end);
}
function splitMediaFromOutput(raw) {
	const trimmedRaw = raw.trimEnd();
	if (!trimmedRaw.trim()) return { text: "" };
	const media = [];
	let foundMediaToken = false;
	const fenceSpans = parseFenceSpans(trimmedRaw);
	const lines = trimmedRaw.split("\n");
	const keptLines = [];
	let lineOffset = 0;
	for (const line of lines) {
		if (isInsideFence(fenceSpans, lineOffset)) {
--
		if (cleanedLine) keptLines.push(cleanedLine);
		lineOffset += line.length + 1;
	}
	let cleanedText = keptLines.join("\n").replace(/[ \t]+\n/g, "\n").replace(/[ \t]{2,}/g, " ").replace(/\n{2,}/g, "\n").trim();
	const audioTagResult = parseAudioTag(cleanedText);
	const hasAudioAsVoice = audioTagResult.audioAsVoice;
	if (audioTagResult.hadTag) cleanedText = audioTagResult.text.replace(/\n{2,}/g, "\n").trim();
	if (media.length === 0) {
		const result = { text: foundMediaToken || hasAudioAsVoice ? cleanedText : trimmedRaw };
		if (hasAudioAsVoice) result.audioAsVoice = true;
		return result;
	}
	return {
		text: cleanedText,
		mediaUrls: media,
		mediaUrl: media[0],
		...hasAudioAsVoice ? { audioAsVoice: true } : {}
	};
}

//#endregion
//#region src/auto-reply/reply/reply-directives.ts
function parseReplyDirectives(raw, options = {}) {
	const split = splitMediaFromOutput(raw);
	let text = split.text ?? "";
	const replyParsed = parseInlineDirectives(text, {
		currentMessageId: options.currentMessageId,
		stripAudioTag: false,
		stripReplyTags: true
	});
	if (replyParsed.hasReplyTag) text = replyParsed.text;
	const silentToken = options.silentToken ?? SILENT_REPLY_TOKEN;
	const isSilent = isSilentReplyText(text, silentToken);
	if (isSilent) text = "";
	return {
		text,
		mediaUrls: split.mediaUrls,
		mediaUrl: split.mediaUrl,
		replyToId: replyParsed.replyToId,
		replyToCurrent: replyParsed.replyToCurrent,
		replyToTag: replyParsed.hasReplyTag,
		audioAsVoice: split.audioAsVoice,
		isSilent
	};
}

//#endregion
//#region src/infra/outbound/target-normalization.ts
function normalizeChannelTargetInput(raw) {
	return raw.trim();
}
function normalizeTargetForProvider(provider, raw) {
	if (!raw) return;
	const providerId = normalizeChannelId(provider);
	return ((providerId ? getChannelPlugin(providerId) : void 0)?.messaging?.normalizeTarget?.(raw) ?? (raw.trim() || void 0)) || void 0;
}
function buildTargetResolverSignature(channel) {
	const resolver = getChannelPlugin(channel)?.messaging?.targetResolver;
	const hint = resolver?.hint ?? "";
	const looksLike = resolver?.looksLikeId;
	return hashSignature(`${hint}|${looksLike ? looksLike.toString() : ""}`);
}
function hashSignature(value) {
	let hash = 5381;
	for (let i = 0; i < value.length; i += 1) hash = (hash << 5) + hash ^ value.charCodeAt(i);
	return (hash >>> 0).toString(36);
}

//#endregion
//#region src/auto-reply/reply/reply-tags.ts
function extractReplyToTag(text, currentMessageId) {
	const result = parseInlineDirectives(text, {
		currentMessageId,
		stripAudioTag: false
	});
	return {
		cleaned: result.text,
		replyToId: result.replyToId,
		replyToCurrent: result.replyToCurrent,
		hasTag: result.hasReplyTag
	};
}

//#endregion
//#region src/auto-reply/reply/reply-threading.ts
function resolveReplyToMode(cfg, channel, accountId, chatType) {
	const provider = normalizeChannelId(channel);
	if (!provider) return "all";
	return getChannelDock(provider)?.threading?.resolveReplyToMode?.({
		cfg,
		accountId,
		chatType
--
		payload,
		currentMessageId
	});
}
function isRenderablePayload(payload) {
	return Boolean(payload.text || payload.mediaUrl || payload.mediaUrls && payload.mediaUrls.length > 0 || payload.audioAsVoice || payload.channelData);
}
function applyReplyThreading(params) {
	const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
	const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
	const implicitReplyToId = currentMessageId?.trim() || void 0;
	return payloads.map((payload) => resolveReplyThreadingForPayload({
		payload,
		implicitReplyToId,
		currentMessageId
	})).filter(isRenderablePayload).map(applyReplyToMode);
}
function filterMessagingToolDuplicates(params) {
	const { payloads, sentTexts } = params;
	if (sentTexts.length === 0) return payloads;
	return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts));
}
function filterMessagingToolMediaDuplicates(params) {
	const normalizeMediaForDedupe = (value) => {
		const trimmed = value.trim();
		if (!trimmed) return "";
		if (!trimmed.toLowerCase().startsWith("file://")) return trimmed;
		try {
			const parsed = new URL(trimmed);
			if (parsed.protocol === "file:") return decodeURIComponent(parsed.pathname || "");
		} catch {}
		return trimmed.replace(/^file:\/\//i, "");
	};
	const { payloads, sentMediaUrls } = params;
	if (sentMediaUrls.length === 0) return payloads;
	const sentSet = new Set(sentMediaUrls.map(normalizeMediaForDedupe).filter(Boolean));
	return payloads.map((payload) => {
		const mediaUrl = payload.mediaUrl;
		const mediaUrls = payload.mediaUrls;
		const stripSingle = mediaUrl && sentSet.has(normalizeMediaForDedupe(mediaUrl));
		const filteredUrls = mediaUrls?.filter((u) => !sentSet.has(normalizeMediaForDedupe(u)));
		if (!stripSingle && (!mediaUrls || filteredUrls?.length === mediaUrls.length)) return payload;
		return {
			...payload,
			mediaUrl: stripSingle ? void 0 : mediaUrl,
			mediaUrls: filteredUrls?.length ? filteredUrls : void 0
		};
	});
}
function shouldSuppressMessagingToolReplies(params) {
	const provider = params.messageProvider?.trim().toLowerCase();
	if (!provider) return false;
	const originTarget = normalizeTargetForProvider(provider, params.originatingTo);
	if (!originTarget) return false;
	const originAccount = normalizeOptionalAccountId(params.accountId);
	const sentTargets = params.messagingToolSentTargets ?? [];
--
			mediaUrls: mergedMedia.length ? mergedMedia : void 0,
			mediaUrl: resolvedMediaUrl,
			replyToId: payload.replyToId ?? parsed.replyToId,
			replyToTag: payload.replyToTag || parsed.replyToTag,
			replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent,
			audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice)
		};
		if (parsed.isSilent && mergedMedia.length === 0) return [];
		if (!isRenderablePayload(next)) return [];
		return [next];
	});
}
function normalizeOutboundPayloads(payloads) {
	return normalizeReplyPayloadsForDelivery(payloads).map((payload) => {
		const channelData = payload.channelData;
		const normalized = {
			text: payload.text ?? "",
			mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : [])
		};
		if (channelData && Object.keys(channelData).length > 0) normalized.channelData = channelData;
		return normalized;
	}).filter((payload) => payload.text || payload.mediaUrls.length > 0 || Boolean(payload.channelData && Object.keys(payload.channelData).length > 0));
}
function normalizeOutboundPayloadsForJson(payloads) {
	return normalizeReplyPayloadsForDelivery(payloads).map((payload) => ({
		text: payload.text ?? "",
		mediaUrl: payload.mediaUrl ?? null,
		mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : void 0),
		channelData: payload.channelData
	}));
}
function formatOutboundPayloadLog(payload) {
	const lines = [];
	if (payload.text) lines.push(payload.text.trimEnd());
	for (const url of payload.mediaUrls) lines.push(`MEDIA:${url}`);
	return lines.join("\n");
}

//#endregion
//#region src/infra/outbound/deliver.ts
var deliver_exports = /* @__PURE__ */ __exportAll({ deliverOutboundPayloads: () => deliverOutboundPayloads });
async function createChannelHandler(params) {
	const outbound = await loadChannelOutboundAdapter(params.channel);
	const handler = createPluginHandler({
		...params,
		outbound
	});
	if (!handler) throw new Error(`Outbound not configured for channel: ${params.channel}`);
	return handler;
}
function createPluginHandler(params) {
	const outbound = params.outbound;
	if (!outbound?.sendText || !outbound?.sendMedia) return null;
	const baseCtx = createChannelOutboundContextBase(params);
	const sendText = outbound.sendText;
	const sendMedia = outbound.sendMedia;


好的，现在我需要查看一下 `isAudioFile` 函数的定义，看看它支持哪些音频文件扩展名！让我搜索一下。


我需要查看 `isAudioFile` 函数的定义，了解它支持哪些音频文件扩展名。


grep -A 10 -B 5 "isAudioFile" /root/.openclaw/extensions/qqbot/src/outbound.ts

查找isAudioFile函数的定义


  sendC2CVideoMessage,
  sendGroupVideoMessage,
  sendC2CFileMessage,
  sendGroupFileMessage,
} from "./api.js";
import { isAudioFile, audioFileToSilkBase64, waitForFile } from "./utils/audio-convert.js";
import { normalizeMediaTags } from "./utils/media-tags.js";
import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
import { isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName } from "./utils/platform.js";

// ============ 消息回复限流器 ============
// 同一 message_id 1小时内最多回复 4 次，超过 1 小时无法被动回复（需改为主动消息）
const MESSAGE_REPLY_LIMIT = 4;
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时

interface MessageReplyRecord {
--

  // 判断是否为语音文件（本地文件路径 + 音频扩展名）
  const isLocalPath = isLocalFilePath(mediaUrl);
  const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");

  if (isLocalPath && isAudioFile(mediaUrl)) {
    return sendVoiceFile(ctx);
  }

  // 判断是否为视频（公网 URL 或本地视频文件）
  if (isVideoFile(mediaUrl)) {
    if (isHttpUrl) {
      return sendVideoUrl(ctx);
    }
    if (isLocalPath) {
      return sendVideoFile(ctx);
    }
  }

  // 判断是否为文档/文件（非图片、非音频、非视频的本地文件）
  if (isLocalPath && !isImageFile(mediaUrl) && !isAudioFile(mediaUrl)) {
    return sendDocumentFile(ctx);
  }

  // === 以下为图片发送逻辑（原有逻辑） ===

  const isDataUrl = mediaUrl.startsWith("data:");

  let processedMediaUrl = mediaUrl;

  if (isLocalPath) {


好的，`isAudioFile` 函数是从 `./utils/audio-convert.js` 导入的，我需要查看这个文件！


我需要查看 `./utils/audio-convert.js` 文件，因为 `isAudioFile` 函数是从那里导入的。