     1→/**
     2→ * QQ Bot API 鉴权和请求封装
     3→ */
     4→
     5→const API_BASE = "https://api.sgroup.qq.com";
     6→const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
     7→
     8→// 运行时配置
     9→let currentMarkdownSupport = false;
    10→
    11→/**
    12→ * 初始化 API 配置
    13→ * @param options.markdownSupport - 是否支持 markdown 消息（默认 false，需要机器人具备该权限才能启用）
    14→ */
    15→export function initApiConfig(options: { markdownSupport?: boolean }): void {
    16→  currentMarkdownSupport = options.markdownSupport === true; // 默认为 false，需要机器人具备 markdown 消息权限才能启用
    17→}
    18→
    19→/**
    20→ * 获取当前是否支持 markdown
    21→ */
    22→export function isMarkdownSupport(): boolean {
    23→  return currentMarkdownSupport;
    24→}
    25→
    26→let cachedToken: { token: string; expiresAt: number } | null = null;
    27→// Singleflight: 防止并发获取 Token 的 Promise 缓存
    28→let tokenFetchPromise: Promise<string> | null = null;
    29→
    30→/**
    31→ * 获取 AccessToken（带缓存 + singleflight 并发安全）
    32→ *
    33→ * 使用 singleflight 模式：当多个请求同时发现 Token 过期时，
    34→ * 只有第一个请求会真正去获取新 Token，其他请求复用同一个 Promise。
    35→ */
    36→export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
    37→  // 检查缓存，提前 5 分钟刷新
    38→  if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
    39→    return cachedToken.token;
    40→  }
    41→
    42→  // Singleflight: 如果已有进行中的 Token 获取请求，复用它
    43→  if (tokenFetchPromise) {
    44→    console.log(`[qqbot-api] Token fetch in progress, waiting for existing request...`);
    45→    return tokenFetchPromise;
    46→  }
    47→
    48→  // 创建新的 Token 获取 Promise（singleflight 入口）
    49→  tokenFetchPromise = (async () => {
    50→    try {
    51→      return await doFetchToken(appId, clientSecret);
    52→    } finally {
    53→      // 无论成功失败，都清除 Promise 缓存
    54→      tokenFetchPromise = null;
    55→    }
    56→  })();
    57→
    58→  return tokenFetchPromise;
    59→}
    60→
    61→/**
    62→ * 实际执行 Token 获取的内部函数
    63→ */
    64→async function doFetchToken(appId: string, clientSecret: string): Promise<string> {
    65→
    66→  const requestBody = { appId, clientSecret };
    67→  const requestHeaders = { "Content-Type": "application/json" };
    68→
    69→  // 打印请求信息（隐藏敏感信息）
    70→  console.log(`[qqbot-api] >>> POST ${TOKEN_URL}`);
    71→  console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(requestHeaders, null, 2));
    72→  console.log(`[qqbot-api] >>> Body:`, JSON.stringify({ appId, clientSecret: "***" }, null, 2));
    73→
    74→  let response: Response;
    75→  try {
    76→    response = await fetch(TOKEN_URL, {
    77→      method: "POST",
    78→      headers: requestHeaders,
    79→      body: JSON.stringify(requestBody),
    80→    });
    81→  } catch (err) {
    82→    console.error(`[qqbot-api] <<< Network error:`, err);
    83→    throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
    84→  }
    85→
    86→  // 打印响应头
    87→  const responseHeaders: Record<string, string> = {};
    88→  response.headers.forEach((value, key) => {
    89→    responseHeaders[key] = value;
    90→  });
    91→  console.log(`[qqbot-api] <<< Status: ${response.status} ${response.statusText}`);
    92→  console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
    93→
    94→  let data: { access_token?: string; expires_in?: number };
    95→  let rawBody: string;
    96→  try {
    97→    rawBody = await response.text();
    98→    // 隐藏 token 值
    99→    const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
   100→    console.log(`[qqbot-api] <<< Body:`, logBody);
   101→    data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number };
   102→  } catch (err) {
   103→    console.error(`[qqbot-api] <<< Parse error:`, err);
   104→    throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
   105→  }
   106→
   107→  if (!data.access_token) {
   108→    throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
   109→  }
   110→
   111→  cachedToken = {
   112→    token: data.access_token,
   113→    expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
   114→  };
   115→
   116→  console.log(`[qqbot-api] Token cached, expires at: ${new Date(cachedToken.expiresAt).toISOString()}`);
   117→  return cachedToken.token;
   118→}
   119→
   120→/**
   121→ * 清除 Token 缓存
   122→ */
   123→export function clearTokenCache(): void {
   124→  cachedToken = null;
   125→  // 注意：不清除 tokenFetchPromise，让进行中的请求完成
   126→  // 下次调用 getAccessToken 时会自动获取新 Token
   127→}
   128→
   129→/**
   130→ * 获取 Token 缓存状态（用于监控）
   131→ */
   132→export function getTokenStatus(): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } {
   133→  if (tokenFetchPromise) {
   134→    return { status: "refreshing", expiresAt: cachedToken?.expiresAt ?? null };
   135→  }
   136→  if (!cachedToken) {
   137→    return { status: "none", expiresAt: null };
   138→  }
   139→  const isValid = Date.now() < cachedToken.expiresAt - 5 * 60 * 1000;
   140→  return { status: isValid ? "valid" : "expired", expiresAt: cachedToken.expiresAt };
   141→}
   142→
   143→/**
   144→ * msg_seq 追踪器 - 用于对同一条消息的多次回复
   145→ * key: msg_id, value: 当前 seq 值
   146→ * 使用时间戳作为基础值，确保进程重启后不会重复
   147→ */
   148→const msgSeqTracker = new Map<string, number>();
   149→const seqBaseTime = Math.floor(Date.now() / 1000) % 100000000; // 取秒级时间戳的后8位作为基础
   150→
   151→/**
   152→ * 获取并递增消息序号
   153→ * 返回的 seq 会基于时间戳，避免进程重启后重复
   154→ */
   155→export function getNextMsgSeq(msgId: string): number {
   156→  const current = msgSeqTracker.get(msgId) ?? 0;
   157→  const next = current + 1;
   158→  msgSeqTracker.set(msgId, next);
   159→
   160→  // 清理过期的序号
   161→  // 简单策略：保留最近 1000 条
   162→  if (msgSeqTracker.size > 1000) {
   163→    const keys = Array.from(msgSeqTracker.keys());
   164→    for (let i = 0; i < 500; i++) {
   165→      msgSeqTracker.delete(keys[i]);
   166→    }
   167→  }
   168→
   169→  // 结合时间戳基础值，确保唯一性
   170→  return seqBaseTime + next;
   171→}
   172→
   173→// API 请求超时配置（毫秒）
   174→const DEFAULT_API_TIMEOUT = 30000; // 默认 30 秒
   175→const FILE_UPLOAD_TIMEOUT = 120000; // 文件上传 120 秒（2 分钟）
   176→
   177→/**
   178→ * API 请求封装
   179→ * @param accessToken 访问令牌
   180→ * @param method 请求方法
   181→ * @param path 请求路径
   182→ * @param body 请求体
   183→ * @param timeoutMs 超时时间（毫秒），不传则根据请求类型自动选择
   184→ */
   185→export async function apiRequest<T = unknown>(
   186→  accessToken: string,
   187→  method: string,
   188→  path: string,
   189→  body?: unknown,
   190→  timeoutMs?: number
   191→): Promise<T> {
   192→  const url = `${API_BASE}${path}`;
   193→  const headers: Record<string, string> = {
   194→    Authorization: `QQBot ${accessToken}`,
   195→    "Content-Type": "application/json",
   196→  };
   197→
   198→  // 根据请求类型自动选择超时时间
   199→  // 文件上传接口 (/files) 使用更长的超时时间
   200→  const isFileUpload = path.includes("/files");
   201→  const timeout = timeoutMs ?? (isFileUpload ? FILE_UPLOAD_TIMEOUT : DEFAULT_API_TIMEOUT);
   202→
   203→  // 创建 AbortController 用于超时控制
   204→  const controller = new AbortController();
   205→  const timeoutId = setTimeout(() => {
   206→    controller.abort();
   207→  }, timeout);
   208→
   209→  const options: RequestInit = {
   210→    method,
   211→    headers,
   212→    signal: controller.signal,
   213→  };
   214→
   215→  if (body) {
   216→    options.body = JSON.stringify(body);
   217→  }
   218→
   219→  // 打印请求信息
   220→  console.log(`[qqbot-api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
   221→  console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(headers, null, 2));
   222→  if (body) {
   223→    console.log(`[qqbot-api] >>> Body:`, JSON.stringify(body, null, 2));
   224→  }
   225→
   226→  let res: Response;
   227→  try {
   228→    res = await fetch(url, options);
   229→  } catch (err) {
   230→    clearTimeout(timeoutId);
   231→    if (err instanceof Error && err.name === "AbortError") {
   232→      console.error(`[qqbot-api] <<< Request timeout after ${timeout}ms`);
   233→      throw new Error(`Request timeout [${path}]: exceeded ${timeout}ms`);
   234→    }
   235→    console.error(`[qqbot-api] <<< Network error:`, err);
   236→    throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
   237→  } finally {
   238→    clearTimeout(timeoutId);
   239→  }
   240→
   241→  // 打印响应头
   242→  const responseHeaders: Record<string, string> = {};
   243→  res.headers.forEach((value, key) => {
   244→    responseHeaders[key] = value;
   245→  });
   246→  console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}`);
   247→  console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
   248→
   249→  let data: T;
   250→  let rawBody: string;
   251→  try {
   252→    rawBody = await res.text();
   253→    console.log(`[qqbot-api] <<< Body:`, rawBody);
   254→    data = JSON.parse(rawBody) as T;
   255→  } catch (err) {
   256→    console.error(`[qqbot-api] <<< Parse error:`, err);
   257→    throw new Error(`Failed to parse response [${path}]: ${err instanceof Error ? err.message : String(err)}`);
   258→  }
   259→
   260→  if (!res.ok) {
   261→    const error = data as { message?: string; code?: number };
   262→    throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`);
   263→  }
   264→
   265→  return data;
   266→}
   267→
   268→/**
   269→ * 获取 WebSocket Gateway URL
   270→ */
   271→export async function getGatewayUrl(accessToken: string): Promise<string> {
   272→  const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
   273→  return data.url;
   274→}
   275→
   276→// ============ 消息发送接口 ============
   277→
   278→/**
   279→ * 消息响应
   280→ */
   281→export interface MessageResponse {
   282→  id: string;
   283→  timestamp: number | string;
   284→}
   285→
   286→/**
   287→ * 构建消息体
   288→ * 根据 markdownSupport 配置决定消息格式：
   289→ * - markdown 模式: { markdown: { content }, msg_type: 2 }
   290→ * - 纯文本模式: { content, msg_type: 0 }
   291→ */
   292→function buildMessageBody(
   293→  content: string,
   294→  msgId: string | undefined,
   295→  msgSeq: number
   296→): Record<string, unknown> {
   297→  const body: Record<string, unknown> = currentMarkdownSupport
   298→    ? {
   299→        markdown: { content },
   300→        msg_type: 2,
   301→        msg_seq: msgSeq,
   302→      }
   303→    : {
   304→        content,
   305→        msg_type: 0,
   306→        msg_seq: msgSeq,
   307→      };
   308→
   309→  if (msgId) {
   310→    body.msg_id = msgId;
   311→  }
   312→
   313→  return body;
   314→}
   315→
   316→/**
   317→ * 发送 C2C 单聊消息
   318→ */
   319→export async function sendC2CMessage(
   320→  accessToken: string,
   321→  openid: string,
   322→  content: string,
   323→  msgId?: string
   324→): Promise<MessageResponse> {
   325→  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
   326→  const body = buildMessageBody(content, msgId, msgSeq);
   327→
   328→  return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
   329→}
   330→
   331→/**
   332→ * 发送 C2C 输入状态提示（告知用户机器人正在输入）
   333→ */
   334→export async function sendC2CInputNotify(
   335→  accessToken: string,
   336→  openid: string,
   337→  msgId?: string,
   338→  inputSecond: number = 60
   339→): Promise<void> {
   340→  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
   341→  const body = {
   342→    msg_type: 6,
   343→    input_notify: {
   344→      input_type: 1,
   345→      input_second: inputSecond,
   346→    },
   347→    msg_seq: msgSeq,
   348→    ...(msgId ? { msg_id: msgId } : {}),
   349→  };
   350→
   351→  await apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
   352→}
   353→
   354→/**
   355→ * 发送频道消息（不支持流式）
   356→ */
   357→export async function sendChannelMessage(
   358→  accessToken: string,
   359→  channelId: string,
   360→  content: string,
   361→  msgId?: string
   362→): Promise<{ id: string; timestamp: string }> {
   363→  return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, {
   364→    content,
   365→    ...(msgId ? { msg_id: msgId } : {}),
   366→  });
   367→}
   368→
   369→/**
   370→ * 发送群聊消息
   371→ */
   372→export async function sendGroupMessage(
   373→  accessToken: string,
   374→  groupOpenid: string,
   375→  content: string,
   376→  msgId?: string
   377→): Promise<MessageResponse> {
   378→  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
   379→  const body = buildMessageBody(content, msgId, msgSeq);
   380→
   381→  return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
   382→}
   383→
   384→/**
   385→ * 构建主动消息请求体
   386→ * 根据 markdownSupport 配置决定消息格式：
   387→ * - markdown 模式: { markdown: { content }, msg_type: 2 }
   388→ * - 纯文本模式: { content, msg_type: 0 }
   389→ *
   390→ * 注意：主动消息不支持流式发送
   391→ */
   392→function buildProactiveMessageBody(content: string): Record<string, unknown> {
   393→  // 主动消息内容校验（参考 Telegram 机制）
   394→  if (!content || content.trim().length === 0) {
   395→    throw new Error("主动消息内容不能为空 (markdown.content is empty)");
   396→  }
   397→
   398→  if (currentMarkdownSupport) {
   399→    return {
   400→      markdown: { content },
   401→      msg_type: 2,
   402→    };
   403→  } else {
   404→    return {
   405→      content,
   406→      msg_type: 0,
   407→    };
   408→  }
   409→}
   410→
   411→/**
   412→ * 主动发送 C2C 单聊消息（不需要 msg_id，每月限 4 条/用户）
   413→ *
   414→ * 注意：
   415→ * 1. 内容不能为空（对应 markdown.content 字段）
   416→ * 2. 不支持流式发送
   417→ */
   418→export async function sendProactiveC2CMessage(
   419→  accessToken: string,
   420→  openid: string,
   421→  content: string
   422→): Promise<{ id: string; timestamp: number }> {
   423→  const body = buildProactiveMessageBody(content);
   424→  console.log(`[qqbot-api] sendProactiveC2CMessage: openid=${openid}, msg_type=${body.msg_type}, content_len=${content.length}`);
   425→  return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
   426→}
   427→
   428→/**
   429→ * 主动发送群聊消息（不需要 msg_id，每月限 4 条/群）
   430→ *
   431→ * 注意：
   432→ * 1. 内容不能为空（对应 markdown.content 字段）
   433→ * 2. 不支持流式发送
   434→ */
   435→export async function sendProactiveGroupMessage(
   436→  accessToken: string,
   437→  groupOpenid: string,
   438→  content: string
   439→): Promise<{ id: string; timestamp: string }> {
   440→  const body = buildProactiveMessageBody(content);
   441→  console.log(`[qqbot-api] sendProactiveGroupMessage: group=${groupOpenid}, msg_type=${body.msg_type}, content_len=${content.length}`);
   442→  return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
   443→}
   444→
   445→// ============ 富媒体消息支持 ============
   446→
   447→/**
   448→ * 媒体文件类型
   449→ */
   450→export enum MediaFileType {
   451→  IMAGE = 1,
   452→  VIDEO = 2,
   453→  VOICE = 3,
   454→  FILE = 4, // 暂未开放
   455→}
   456→
   457→/**
   458→ * 上传富媒体文件的响应
   459→ */
   460→export interface UploadMediaResponse {
   461→  file_uuid: string;
   462→  file_info: string;
   463→  ttl: number;
   464→  id?: string; // 仅当 srv_send_msg=true 时返回
   465→}
   466→
   467→/**
   468→ * 上传富媒体文件到 C2C 单聊
   469→ * @param url - 公网可访问的图片 URL（与 fileData 二选一）
   470→ * @param fileData - Base64 编码的文件内容（与 url 二选一）
   471→ */
   472→export async function uploadC2CMedia(
   473→  accessToken: string,
   474→  openid: string,
   475→  fileType: MediaFileType,
   476→  url?: string,
   477→  fileData?: string,
   478→  srvSendMsg = false
   479→): Promise<UploadMediaResponse> {
   480→  if (!url && !fileData) {
   481→    throw new Error("uploadC2CMedia: url or fileData is required");
   482→  }
   483→
   484→  const body: Record<string, unknown> = {
   485→    file_type: fileType,
   486→    srv_send_msg: srvSendMsg,
   487→  };
   488→
   489→  if (url) {
   490→    body.url = url;
   491→  } else if (fileData) {
   492→    body.file_data = fileData;
   493→  }
   494→
   495→  return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, body);
   496→}
   497→
   498→/**
   499→ * 上传富媒体文件到群聊
   500→ * @param url - 公网可访问的图片 URL（与 fileData 二选一）
   501→ * @param fileData - Base64 编码的文件内容（与 url 二选一）
   502→ */
   503→export async function uploadGroupMedia(
   504→  accessToken: string,
   505→  groupOpenid: string,
   506→  fileType: MediaFileType,
   507→  url?: string,
   508→  fileData?: string,
   509→  srvSendMsg = false
   510→): Promise<UploadMediaResponse> {
   511→  if (!url && !fileData) {
   512→    throw new Error("uploadGroupMedia: url or fileData is required");
   513→  }
   514→
   515→  const body: Record<string, unknown> = {
   516→    file_type: fileType,
   517→    srv_send_msg: srvSendMsg,
   518→  };
   519→
   520→  if (url) {
   521→    body.url = url;
   522→  } else if (fileData) {
   523→    body.file_data = fileData;
   524→  }
   525→
   526→  return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, body);
   527→}
   528→
   529→/**
   530→ * 发送 C2C 单聊富媒体消息
   531→ */
   532→export async function sendC2CMediaMessage(
   533→  accessToken: string,
   534→  openid: string,
   535→  fileInfo: string,
   536→  msgId?: string,
   537→  content?: string
   538→): Promise<{ id: string; timestamp: number }> {
   539→  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
   540→  return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
   541→    msg_type: 7, // 富媒体消息类型
   542→    media: { file_info: fileInfo },
   543→    msg_seq: msgSeq,
   544→    ...(content ? { content } : {}),
   545→    ...(msgId ? { msg_id: msgId } : {}),
   546→  });
   547→}
   548→
   549→/**
   550→ * 发送群聊富媒体消息
   551→ */
   552→export async function sendGroupMediaMessage(
   553→  accessToken: string,
   554→  groupOpenid: string,
   555→  fileInfo: string,
   556→  msgId?: string,
   557→  content?: string
   558→): Promise<{ id: string; timestamp: string }> {
   559→  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
   560→  return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
   561→    msg_type: 7, // 富媒体消息类型
   562→    media: { file_info: fileInfo },
   563→    msg_seq: msgSeq,
   564→    ...(content ? { content } : {}),
   565→    ...(msgId ? { msg_id: msgId } : {}),
   566→  });
   567→}
   568→
   569→/**
   570→ * 发送带图片的 C2C 单聊消息（封装上传+发送）
   571→ * @param imageUrl - 图片来源，支持：
   572→ *   - 公网 URL: https://example.com/image.png
   573→ *   - Base64 Data URL: data:image/png;base64,xxxxx
   574→ */
   575→export async function sendC2CImageMessage(
   576→  accessToken: string,
   577→  openid: string,
   578→  imageUrl: string,
   579→  msgId?: string,
   580→  content?: string
   581→): Promise<{ id: string; timestamp: number }> {
   582→  let uploadResult: UploadMediaResponse;
   583→
   584→  // 检查是否是 Base64 Data URL
   585→  if (imageUrl.startsWith("data:")) {
   586→    // 解析 Base64 Data URL: data:image/png;base64,xxxxx
   587→    const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
   588→    if (!matches) {
   589→      throw new Error("Invalid Base64 Data URL format");
   590→    }
   591→    const base64Data = matches[2];
   592→    // 使用 file_data 上传
   593→    uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, base64Data, false);
   594→  } else {
   595→    // 公网 URL，使用 url 参数上传
   596→    uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
   597→  }
   598→
   599→  // 发送富媒体消息
   600→  return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
   601→}
   602→
   603→/**
   604→ * 发送带图片的群聊消息（封装上传+发送）
   605→ * @param imageUrl - 图片来源，支持：
   606→ *   - 公网 URL: https://example.com/image.png
   607→ *   - Base64 Data URL: data:image/png;base64,xxxxx
   608→ */
   609→export async function sendGroupImageMessage(
   610→  accessToken: string,
   611→  groupOpenid: string,
   612→  imageUrl: string,
   613→  msgId?: string,
   614→  content?: string
   615→): Promise<{ id: string; timestamp: string }> {
   616→  let uploadResult: UploadMediaResponse;
   617→
   618→  // 检查是否是 Base64 Data URL
   619→  if (imageUrl.startsWith("data:")) {
   620→    // 解析 Base64 Data URL: data:image/png;base64,xxxxx
   621→    const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
   622→    if (!matches) {
   623→      throw new Error("Invalid Base64 Data URL format");
   624→    }
   625→    const base64Data = matches[2];
   626→    // 使用 file_data 上传
   627→    uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, base64Data, false);
   628→  } else {
   629→    // 公网 URL，使用 url 参数上传
   630→    uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, undefined, false);
   631→  }
   632→
   633→  // 发送富媒体消息
   634→  return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
   635→}
   636→
   637→// ============ 后台 Token 刷新 (P1-1) ============
   638→
   639→/**
   640→ * 后台 Token 刷新配置
   641→ */
   642→interface BackgroundTokenRefreshOptions {
   643→  /** 提前刷新时间（毫秒，默认 5 分钟） */
   644→  refreshAheadMs?: number;
   645→  /** 随机偏移范围（毫秒，默认 0-30 秒） */
   646→  randomOffsetMs?: number;
   647→  /** 最小刷新间隔（毫秒，默认 1 分钟） */
   648→  minRefreshIntervalMs?: number;
   649→  /** 失败后重试间隔（毫秒，默认 5 秒） */
   650→  retryDelayMs?: number;
   651→  /** 日志函数 */
   652→  log?: {
   653→    info: (msg: string) => void;
   654→    error: (msg: string) => void;
   655→    debug?: (msg: string) => void;
   656→  };
   657→}
   658→
   659→// 后台刷新状态
   660→let backgroundRefreshRunning = false;
   661→let backgroundRefreshAbortController: AbortController | null = null;
   662→
   663→/**
   664→ * 启动后台 Token 刷新
   665→ * 在后台定时刷新 Token，避免请求时才发现过期
   666→ *
   667→ * @param appId 应用 ID
   668→ * @param clientSecret 应用密钥
   669→ * @param options 配置选项
   670→ */
   671→export function startBackgroundTokenRefresh(
   672→  appId: string,
   673→  clientSecret: string,
   674→  options?: BackgroundTokenRefreshOptions
   675→): void {
   676→  if (backgroundRefreshRunning) {
   677→    console.log("[qqbot-api] Background token refresh already running");
   678→    return;
   679→  }
   680→
   681→  const {
   682→    refreshAheadMs = 5 * 60 * 1000, // 提前 5 分钟刷新
   683→    randomOffsetMs = 30 * 1000, // 0-30 秒随机偏移
   684→    minRefreshIntervalMs = 60 * 1000, // 最少 1 分钟后刷新
   685→    retryDelayMs = 5 * 1000, // 失败后 5 秒重试
   686→    log,
   687→  } = options ?? {};
   688→
   689→  backgroundRefreshRunning = true;
   690→  backgroundRefreshAbortController = new AbortController();
   691→  const signal = backgroundRefreshAbortController.signal;
   692→
   693→  const refreshLoop = async () => {
   694→    log?.info?.("[qqbot-api] Background token refresh started");
   695→
   696→    while (!signal.aborted) {
   697→      try {
   698→        // 先确保有一个有效 Token
   699→        await getAccessToken(appId, clientSecret);
   700→
   701→        // 计算下次刷新时间
   702→        if (cachedToken) {
   703→          const expiresIn = cachedToken.expiresAt - Date.now();
   704→          // 提前刷新时间 + 随机偏移（避免集群同时刷新）
   705→          const randomOffset = Math.random() * randomOffsetMs;
   706→          const refreshIn = Math.max(
   707→            expiresIn - refreshAheadMs - randomOffset,
   708→            minRefreshIntervalMs
   709→          );
   710→
   711→          log?.debug?.(
   712→            `[qqbot-api] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`
   713→          );
   714→
   715→          // 等待到刷新时间
   716→          await sleep(refreshIn, signal);
   717→        } else {
   718→          // 没有缓存的 Token，等待一段时间后重试
   719→          log?.debug?.("[qqbot-api] No cached token, retrying soon");
   720→          await sleep(minRefreshIntervalMs, signal);
   721→        }
   722→      } catch (err) {
   723→        if (signal.aborted) break;
   724→
   725→        // 刷新失败，等待后重试
   726→        log?.error?.(`[qqbot-api] Background token refresh failed: ${err}`);
   727→        await sleep(retryDelayMs, signal);
   728→      }
   729→    }
   730→
   731→    backgroundRefreshRunning = false;
   732→    log?.info?.("[qqbot-api] Background token refresh stopped");
   733→  };
   734→
   735→  // 异步启动，不阻塞调用者
   736→  refreshLoop().catch((err) => {
   737→    backgroundRefreshRunning = false;
   738→    log?.error?.(`[qqbot-api] Background token refresh crashed: ${err}`);
   739→  });
   740→}
   741→
   742→/**
   743→ * 停止后台 Token 刷新
   744→ */
   745→export function stopBackgroundTokenRefresh(): void {
   746→  if (backgroundRefreshAbortController) {
   747→    backgroundRefreshAbortController.abort();
   748→    backgroundRefreshAbortController = null;
   749→  }
   750→  backgroundRefreshRunning = false;
   751→}
   752→
   753→/**
   754→ * 检查后台 Token 刷新是否正在运行
   755→ */
   756→export function isBackgroundTokenRefreshRunning(): boolean {
   757→  return backgroundRefreshRunning;
   758→}
   759→
   760→/**
   761→ * 可中断的 sleep 函数
   762→ */
   763→async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
   764→  return new Promise((resolve, reject) => {
   765→    const timer = setTimeout(resolve, ms);
   766→
   767→    if (signal) {
   768→      if (signal.aborted) {
   769→        clearTimeout(timer);
   770→        reject(new Error("Aborted"));
   771→        return;
   772→      }
   773→
   774→      const onAbort = () => {
   775→        clearTimeout(timer);
   776→        reject(new Error("Aborted"));
   777→      };
   778→
   779→      signal.addEventListener("abort", onAbort, { once: true });
   780→    }
   781→  });
   782→}

<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 API 鉴权和请求封装
 */

const API_BASE = "https://api.sgroup.qq.com";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";

// 运行时配置
let currentMarkdownSupport = false;

/**
 * 初始化 API 配置
 * @param options.markdownSupport - 是否支持 markdown 消息（默认 false，需要机器人具备该权限才能启用）
 */
export function initApiConfig(options: { markdownSupport?: boolean }): void {
  currentMarkdownSupport = options.markdownSupport === true; // 默认为 false，需要机器人具备 markdown 消息权限才能启用
}

/**
 * 获取当前是否支持 markdown
 */
export function isMarkdownSupport(): boolean {
  return currentMarkdownSupport;
}

let cachedToken: { token: string; expiresAt: number } | null = null;
// Singleflight: 防止并发获取 Token 的 Promise 缓存
let tokenFetchPromise: Promise<string> | null = null;

/**
 * 获取 AccessToken（带缓存 + singleflight 并发安全）
 *
 * 使用 singleflight 模式：当多个请求同时发现 Token 过期时，
 * 只有第一个请求会真正去获取新 Token，其他请求复用同一个 Promise。
 */
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
  // 检查缓存，提前 5 分钟刷新
  if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
    return cachedToken.token;
  }

  // Singleflight: 如果已有进行中的 Token 获取请求，复用它
  if (tokenFetchPromise) {
    console.log(`[qqbot-api] Token fetch in progress, waiting for existing request...`);
    return tokenFetchPromise;
  }

  // 创建新的 Token 获取 Promise（singleflight 入口）
  tokenFetchPromise = (async () => {
    try {
      return await doFetchToken(appId, clientSecret);
    } finally {
      // 无论成功失败，都清除 Promise 缓存
      tokenFetchPromise = null;
    }
  })();

  return tokenFetchPromise;
}

/**
 * 实际执行 Token 获取的内部函数
 */
async function doFetchToken(appId: string, clientSecret: string): Promise<string> {

  const requestBody = { appId, clientSecret };
  const requestHeaders = { "Content-Type": "application/json" };

  // 打印请求信息（隐藏敏感信息）
  console.log(`[qqbot-api] >>> POST ${TOKEN_URL}`);
  console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(requestHeaders, null, 2));
  console.log(`[qqbot-api] >>> Body:`, JSON.stringify({ appId, clientSecret: "***" }, null, 2));

  let response: Response;
  try {
    response = await fetch(TOKEN_URL, {
      method: "POST",
      headers: requestHeaders,
      body: JSON.stringify(requestBody),
    });
  } catch (err) {
    console.error(`[qqbot-api] <<< Network error:`, err);
    throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
  }

  // 打印响应头
  const responseHeaders: Record<string, string> = {};
  response.headers.forEach((value, key) => {
    responseHeaders[key] = value;
  });
  console.log(`[qqbot-api] <<< Status: ${response.status} ${response.statusText}`);
  console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));

  let data: { access_token?: string; expires_in?: number };
  let rawBody: string;
  try {
    rawBody = await response.text();
    // 隐藏 token 值
    const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
    console.log(`[qqbot-api] <<< Body:`, logBody);
    data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number };
  } catch (err) {
    console.error(`[qqbot-api] <<< Parse error:`, err);
    throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
  }

  if (!data.access_token) {
    throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
  }

  cachedToken = {
    token: data.access_token,
    expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
  };

  console.log(`[qqbot-api] Token cached, expires at: ${new Date(cachedToken.expiresAt).toISOString()}`);
  return cachedToken.token;
}

/**
 * 清除 Token 缓存
 */
export function clearTokenCache(): void {
  cachedToken = null;
  // 注意：不清除 tokenFetchPromise，让进行中的请求完成
  // 下次调用 getAccessToken 时会自动获取新 Token
}

/**
 * 获取 Token 缓存状态（用于监控）
 */
export function getTokenStatus(): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } {
  if (tokenFetchPromise) {
    return { status: "refreshing", expiresAt: cachedToken?.expiresAt ?? null };
  }
  if (!cachedToken) {
    return { status: "none", expiresAt: null };
  }
  const isValid = Date.now() < cachedToken.expiresAt - 5 * 60 * 1000;
  return { status: isValid ? "valid" : "expired", expiresAt: cachedToken.expiresAt };
}

/**
 * msg_seq 追踪器 - 用于对同一条消息的多次回复
 * key: msg_id, value: 当前 seq 值
 * 使用时间戳作为基础值，确保进程重启后不会重复
 */
const msgSeqTracker = new Map<string, number>();
const seqBaseTime = Math.floor(Date.now() / 1000) % 100000000; // 取秒级时间戳的后8位作为基础

/**
 * 获取并递增消息序号
 * 返回的 seq 会基于时间戳，避免进程重启后重复
 */
export function getNextMsgSeq(msgId: string): number {
  const current = msgSeqTracker.get(msgId) ?? 0;
  const next = current + 1;
  msgSeqTracker.set(msgId, next);

  // 清理过期的序号
  // 简单策略：保留最近 1000 条
  if (msgSeqTracker.size > 1000) {
    const keys = Array.from(msgSeqTracker.keys());
    for (let i = 0; i < 500; i++) {
      msgSeqTracker.delete(keys[i]);
    }
  }

  // 结合时间戳基础值，确保唯一性
  return seqBaseTime + next;
}

// API 请求超时配置（毫秒）
const DEFAULT_API_TIMEOUT = 30000; // 默认 30 秒
const FILE_UPLOAD_TIMEOUT = 120000; // 文件上传 120 秒（2 分钟）

/**
 * API 请求封装
 * @param accessToken 访问令牌
 * @param method 请求方法
 * @param path 请求路径
 * @param body 请求体
 * @param timeoutMs 超时时间（毫秒），不传则根据请求类型自动选择
 */
export async function apiRequest<T = unknown>(
  accessToken: string,
  method: string,
  path: string,
  body?: unknown,
  timeoutMs?: number
): Promise<T> {
  const url = `${API_BASE}${path}`;
  const headers: Record<string, string> = {
    Authorization: `QQBot ${accessToken}`,
    "Content-Type": "application/json",
  };

  // 根据请求类型自动选择超时时间
  // 文件上传接口 (/files) 使用更长的超时时间
  const isFileUpload = path.includes("/files");
  const timeout = timeoutMs ?? (isFileUpload ? FILE_UPLOAD_TIMEOUT : DEFAULT_API_TIMEOUT);

  // 创建 AbortController 用于超时控制
  const controller = new AbortController();
  const timeoutId = setTimeout(() => {
    controller.abort();
  }, timeout);

  const options: RequestInit = {
    method,
    headers,
    signal: controller.signal,
  };

  if (body) {
    options.body = JSON.stringify(body);
  }

  // 打印请求信息
  console.log(`[qqbot-api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
  console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(headers, null, 2));
  if (body) {
    console.log(`[qqbot-api] >>> Body:`, JSON.stringify(body, null, 2));
  }

  let res: Response;
  try {
    res = await fetch(url, options);
  } catch (err) {
    clearTimeout(timeoutId);
    if (err instanceof Error && err.name === "AbortError") {
      console.error(`[qqbot-api] <<< Request timeout after ${timeout}ms`);
      throw new Error(`Request timeout [${path}]: exceeded ${timeout}ms`);
    }
    console.error(`[qqbot-api] <<< Network error:`, err);
    throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
  } finally {
    clearTimeout(timeoutId);
  }

  // 打印响应头
  const responseHeaders: Record<string, string> = {};
  res.headers.forEach((value, key) => {
    responseHeaders[key] = value;
  });
  console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}`);
  console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));

  let data: T;
  let rawBody: string;
  try {
    rawBody = await res.text();
    console.log(`[qqbot-api] <<< Body:`, rawBody);
    data = JSON.parse(rawBody) as T;
  } catch (err) {
    console.error(`[qqbot-api] <<< Parse error:`, err);
    throw new Error(`Failed to parse response [${path}]: ${err instanceof Error ? err.message : String(err)}`);
  }

  if (!res.ok) {
    const error = data as { message?: string; code?: number };
    throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`);
  }

  return data;
}

/**
 * 获取 WebSocket Gateway URL
 */
export async function getGatewayUrl(accessToken: string): Promise<string> {
  const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
  return data.url;
}

// ============ 消息发送接口 ============

/**
 * 消息响应
 */
export interface MessageResponse {
  id: string;
  timestamp: number | string;
}

/**
 * 构建消息体
 * 根据 markdownSupport 配置决定消息格式：
 * - markdown 模式: { markdown: { content }, msg_type: 2 }
 * - 纯文本模式: { content, msg_type: 0 }
 */
function buildMessageBody(
  content: string,
  msgId: string | undefined,
  msgSeq: number
): Record<string, unknown> {
  const body: Record<string, unknown> = currentMarkdownSupport
    ? {
        markdown: { content },
        msg_type: 2,
        msg_seq: msgSeq,
      }
    : {
        content,
        msg_type: 0,
        msg_seq: msgSeq,
      };

  if (msgId) {
    body.msg_id = msgId;
  }

  return body;
}

/**
 * 发送 C2C 单聊消息
 */
export async function sendC2CMessage(
  accessToken: string,
  openid: string,
  content: string,
  msgId?: string
): Promise<MessageResponse> {
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
  const body = buildMessageBody(content, msgId, msgSeq);

  return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
}

/**
 * 发送 C2C 输入状态提示（告知用户机器人正在输入）
 */
export async function sendC2CInputNotify(
  accessToken: string,
  openid: string,
  msgId?: string,
  inputSecond: number = 60
): Promise<void> {
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
  const body = {
    msg_type: 6,
    input_notify: {
      input_type: 1,
      input_second: inputSecond,
    },
    msg_seq: msgSeq,
    ...(msgId ? { msg_id: msgId } : {}),
  };

  await apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
}

/**
 * 发送频道消息（不支持流式）
 */
export async function sendChannelMessage(
  accessToken: string,
  channelId: string,
  content: string,
  msgId?: string
): Promise<{ id: string; timestamp: string }> {
  return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, {
    content,
    ...(msgId ? { msg_id: msgId } : {}),
  });
}

/**
 * 发送群聊消息
 */
export async function sendGroupMessage(
  accessToken: string,
  groupOpenid: string,
  content: string,
  msgId?: string
): Promise<MessageResponse> {
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
  const body = buildMessageBody(content, msgId, msgSeq);

  return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
}

/**
 * 构建主动消息请求体
 * 根据 markdownSupport 配置决定消息格式：
 * - markdown 模式: { markdown: { content }, msg_type: 2 }
 * - 纯文本模式: { content, msg_type: 0 }
 *
 * 注意：主动消息不支持流式发送
 */
function buildProactiveMessageBody(content: string): Record<string, unknown> {
  // 主动消息内容校验（参考 Telegram 机制）
  if (!content || content.trim().length === 0) {
    throw new Error("主动消息内容不能为空 (markdown.content is empty)");
  }

  if (currentMarkdownSupport) {
    return {
      markdown: { content },
      msg_type: 2,
    };
  } else {
    return {
      content,
      msg_type: 0,
    };
  }
}

/**
 * 主动发送 C2C 单聊消息（不需要 msg_id，每月限 4 条/用户）
 *
 * 注意：
 * 1. 内容不能为空（对应 markdown.content 字段）
 * 2. 不支持流式发送
 */
export async function sendProactiveC2CMessage(
  accessToken: string,
  openid: string,
  content: string
): Promise<{ id: string; timestamp: number }> {
  const body = buildProactiveMessageBody(content);
  console.log(`[qqbot-api] sendProactiveC2CMessage: openid=${openid}, msg_type=${body.msg_type}, content_len=${content.length}`);
  return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
}

/**
 * 主动发送群聊消息（不需要 msg_id，每月限 4 条/群）
 *
 * 注意：
 * 1. 内容不能为空（对应 markdown.content 字段）
 * 2. 不支持流式发送
 */
export async function sendProactiveGroupMessage(
  accessToken: string,
  groupOpenid: string,
  content: string
): Promise<{ id: string; timestamp: string }> {
  const body = buildProactiveMessageBody(content);
  console.log(`[qqbot-api] sendProactiveGroupMessage: group=${groupOpenid}, msg_type=${body.msg_type}, content_len=${content.length}`);
  return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
}

// ============ 富媒体消息支持 ============

/**
 * 媒体文件类型
 */
export enum MediaFileType {
  IMAGE = 1,
  VIDEO = 2,
  VOICE = 3,
  FILE = 4, // 暂未开放
}

/**
 * 上传富媒体文件的响应
 */
export interface UploadMediaResponse {
  file_uuid: string;
  file_info: string;
  ttl: number;
  id?: string; // 仅当 srv_send_msg=true 时返回
}

/**
 * 上传富媒体文件到 C2C 单聊
 * @param url - 公网可访问的图片 URL（与 fileData 二选一）
 * @param fileData - Base64 编码的文件内容（与 url 二选一）
 */
export async function uploadC2CMedia(
  accessToken: string,
  openid: string,
  fileType: MediaFileType,
  url?: string,
  fileData?: string,
  srvSendMsg = false
): Promise<UploadMediaResponse> {
  if (!url && !fileData) {
    throw new Error("uploadC2CMedia: url or fileData is required");
  }

  const body: Record<string, unknown> = {
    file_type: fileType,
    srv_send_msg: srvSendMsg,
  };

  if (url) {
    body.url = url;
  } else if (fileData) {
    body.file_data = fileData;
  }

  return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, body);
}

/**
 * 上传富媒体文件到群聊
 * @param url - 公网可访问的图片 URL（与 fileData 二选一）
 * @param fileData - Base64 编码的文件内容（与 url 二选一）
 */
export async function uploadGroupMedia(
  accessToken: string,
  groupOpenid: string,
  fileType: MediaFileType,
  url?: string,
  fileData?: string,
  srvSendMsg = false
): Promise<UploadMediaResponse> {
  if (!url && !fileData) {
    throw new Error("uploadGroupMedia: url or fileData is required");
  }

  const body: Record<string, unknown> = {
    file_type: fileType,
    srv_send_msg: srvSendMsg,
  };

  if (url) {
    body.url = url;
  } else if (fileData) {
    body.file_data = fileData;
  }

  return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, body);
}

/**
 * 发送 C2C 单聊富媒体消息
 */
export async function sendC2CMediaMessage(
  accessToken: string,
  openid: string,
  fileInfo: string,
  msgId?: string,
  content?: string
): Promise<{ id: string; timestamp: number }> {
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
  return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
    msg_type: 7, // 富媒体消息类型
    media: { file_info: fileInfo },
    msg_seq: msgSeq,
    ...(content ? { content } : {}),
    ...(msgId ? { msg_id: msgId } : {}),
  });
}

/**
 * 发送群聊富媒体消息
 */
export async function sendGroupMediaMessage(
  accessToken: string,
  groupOpenid: string,
  fileInfo: string,
  msgId?: string,
  content?: string
): Promise<{ id: string; timestamp: string }> {
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
  return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
    msg_type: 7, // 富媒体消息类型
    media: { file_info: fileInfo },
    msg_seq: msgSeq,
    ...(content ? { content } : {}),
    ...(msgId ? { msg_id: msgId } : {}),
  });
}

/**
 * 发送带图片的 C2C 单聊消息（封装上传+发送）
 * @param imageUrl - 图片来源，支持：
 *   - 公网 URL: https://example.com/image.png
 *   - Base64 Data URL: data:image/png;base64,xxxxx
 */
export async function sendC2CImageMessage(
  accessToken: string,
  openid: string,
  imageUrl: string,
  msgId?: string,
  content?: string
): Promise<{ id: string; timestamp: number }> {
  let uploadResult: UploadMediaResponse;

  // 检查是否是 Base64 Data URL
  if (imageUrl.startsWith("data:")) {
    // 解析 Base64 Data URL: data:image/png;base64,xxxxx
    const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
    if (!matches) {
      throw new Error("Invalid Base64 Data URL format");
    }
    const base64Data = matches[2];
    // 使用 file_data 上传
    uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, base64Data, false);
  } else {
    // 公网 URL，使用 url 参数上传
    uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
  }

  // 发送富媒体消息
  return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
}

/**
 * 发送带图片的群聊消息（封装上传+发送）
 * @param imageUrl - 图片来源，支持：
 *   - 公网 URL: https://example.com/image.png
 *   - Base64 Data URL: data:image/png;base64,xxxxx
 */
export async function sendGroupImageMessage(
  accessToken: string,
  groupOpenid: string,
  imageUrl: string,
  msgId?: string,
  content?: string
): Promise<{ id: string; timestamp: string }> {
  let uploadResult: UploadMediaResponse;

  // 检查是否是 Base64 Data URL
  if (imageUrl.startsWith("data:")) {
    // 解析 Base64 Data URL: data:image/png;base64,xxxxx
    const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
    if (!matches) {
      throw new Error("Invalid Base64 Data URL format");
    }
    const base64Data = matches[2];
    // 使用 file_data 上传
    uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, base64Data, false);
  } else {
    // 公网 URL，使用 url 参数上传
    uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, undefined, false);
  }

  // 发送富媒体消息
  return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
}

// ============ 后台 Token 刷新 (P1-1) ============

/**
 * 后台 Token 刷新配置
 */
interface BackgroundTokenRefreshOptions {
  /** 提前刷新时间（毫秒，默认 5 分钟） */
  refreshAheadMs?: number;
  /** 随机偏移范围（毫秒，默认 0-30 秒） */
  randomOffsetMs?: number;
  /** 最小刷新间隔（毫秒，默认 1 分钟） */
  minRefreshIntervalMs?: number;
  /** 失败后重试间隔（毫秒，默认 5 秒） */
  retryDelayMs?: number;
  /** 日志函数 */
  log?: {
    info: (msg: string) => void;
    error: (msg: string) => void;
    debug?: (msg: string) => void;
  };
}

// 后台刷新状态
let backgroundRefreshRunning = false;
let backgroundRefreshAbortController: AbortController | null = null;

/**
 * 启动后台 Token 刷新
 * 在后台定时刷新 Token，避免请求时才发现过期
 *
 * @param appId 应用 ID
 * @param clientSecret 应用密钥
 * @param options 配置选项
 */
export function startBackgroundTokenRefresh(
  appId: string,
  clientSecret: string,
  options?: BackgroundTokenRefreshOptions
): void {
  if (backgroundRefreshRunning) {
    console.log("[qqbot-api] Background token refresh already running");
    return;
  }

  const {
    refreshAheadMs = 5 * 60 * 1000, // 提前 5 分钟刷新
    randomOffsetMs = 30 * 1000, // 0-30 秒随机偏移
    minRefreshIntervalMs = 60 * 1000, // 最少 1 分钟后刷新
    retryDelayMs = 5 * 1000, // 失败后 5 秒重试
    log,
  } = options ?? {};

  backgroundRefreshRunning = true;
  backgroundRefreshAbortController = new AbortController();
  const signal = backgroundRefreshAbortController.signal;

  const refreshLoop = async () => {
    log?.info?.("[qqbot-api] Background token refresh started");

    while (!signal.aborted) {
      try {
        // 先确保有一个有效 Token
        await getAccessToken(appId, clientSecret);

        // 计算下次刷新时间
        if (cachedToken) {
          const expiresIn = cachedToken.expiresAt - Date.now();
          // 提前刷新时间 + 随机偏移（避免集群同时刷新）
          const randomOffset = Math.random() * randomOffsetMs;
          const refreshIn = Math.max(
            expiresIn - refreshAheadMs - randomOffset,
            minRefreshIntervalMs
          );

          log?.debug?.(
            `[qqbot-api] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`
          );

          // 等待到刷新时间
          await sleep(refreshIn, signal);
        } else {
          // 没有缓存的 Token，等待一段时间后重试
          log?.debug?.("[qqbot-api] No cached token, retrying soon");
          await sleep(minRefreshIntervalMs, signal);
        }
      } catch (err) {
        if (signal.aborted) break;

        // 刷新失败，等待后重试
        log?.error?.(`[qqbot-api] Background token refresh failed: ${err}`);
        await sleep(retryDelayMs, signal);
      }
    }

    backgroundRefreshRunning = false;
    log?.info?.("[qqbot-api] Background token refresh stopped");
  };

  // 异步启动，不阻塞调用者
  refreshLoop().catch((err) => {
    backgroundRefreshRunning = false;
    log?.error?.(`[qqbot-api] Background token refresh crashed: ${err}`);
  });
}

/**
 * 停止后台 Token 刷新
 */
export function stopBackgroundTokenRefresh(): void {
  if (backgroundRefreshAbortController) {
    backgroundRefreshAbortController.abort();
    backgroundRefreshAbortController = null;
  }
  backgroundRefreshRunning = false;
}

/**
 * 检查后台 Token 刷新是否正在运行
 */
export function isBackgroundTokenRefreshRunning(): boolean {
  return backgroundRefreshRunning;
}

/**
 * 可中断的 sleep 函数
 */
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms);

    if (signal) {
      if (signal.aborted) {
        clearTimeout(timer);
        reject(new Error("Aborted"));
        return;
      }

      const onAbort = () => {
        clearTimeout(timer);
        reject(new Error("Aborted"));
      };

      signal.addEventListener("abort", onAbort, { once: true });
    }
  });
}


好的，现在我看到了关键信息！在 `api.ts` 中，我发现了：

1. `MediaFileType` 枚举中确实有 `VOICE = 3`（第453行）
2. 有 `uploadC2CMedia` 和 `uploadGroupMedia` 函数可以上传不同类型的媒体文件，包括 VOICE
3. 有 `sendC2CMediaMessage` 和 `sendGroupMediaMessage` 函数可以发送富媒体消息
4. 但是，我没有看到类似 `sendC2CImageMessage` 那样的封装好的 `sendC2CVoiceMessage` 或 `sendGroupVoiceMessage` 函数！

让我检查一下 `src/channel.ts` 文件，看看消息处理部分是如何与 OpenClaw 的 TTS 功能集成的。


好的，我现在需要查看 `src/channel.ts` 文件，这应该是 QQ Bot 插件的主要入口文件，看看它是如何与 OpenClaw 的 TTS 功能集成的。


     1→import {
     2→  type ChannelPlugin,
     3→  type OpenClawConfig,
     4→  applyAccountNameToChannelSection,
     5→  deleteAccountFromConfigSection,
     6→  setAccountEnabledInConfigSection,
     7→} from "openclaw/plugin-sdk";
     8→
     9→import type { ResolvedQQBotAccount } from "./types.js";
    10→import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
    11→import { sendText, sendMedia } from "./outbound.js";
    12→import { startGateway } from "./gateway.js";
    13→import { qqbotOnboardingAdapter } from "./onboarding.js";
    14→import { getQQBotRuntime } from "./runtime.js";
    15→
    16→/**
    17→ * 简单的文本分块函数
    18→ * 用于预先分块长文本
    19→ */
    20→function chunkText(text: string, limit: number): string[] {
    21→  if (text.length <= limit) return [text];
    22→
    23→  const chunks: string[] = [];
    24→  let remaining = text;
    25→
    26→  while (remaining.length > 0) {
    27→    if (remaining.length <= limit) {
    28→      chunks.push(remaining);
    29→      break;
    30→    }
    31→
    32→    // 尝试在换行处分割
    33→    let splitAt = remaining.lastIndexOf("\n", limit);
    34→    if (splitAt <= 0 || splitAt < limit * 0.5) {
    35→      // 没找到合适的换行，尝试在空格处分割
    36→      splitAt = remaining.lastIndexOf(" ", limit);
    37→    }
    38→    if (splitAt <= 0 || splitAt < limit * 0.5) {
    39→      // 还是没找到，强制在 limit 处分割
    40→      splitAt = limit;
    41→    }
    42→
    43→    chunks.push(remaining.slice(0, splitAt));
    44→    remaining = remaining.slice(splitAt).trimStart();
    45→  }
    46→
    47→  return chunks;
    48→}
    49→
    50→export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
    51→  id: "qqbot",
    52→  meta: {
    53→    id: "qqbot",
    54→    label: "QQ Bot",
    55→    selectionLabel: "QQ Bot",
    56→    docsPath: "/docs/channels/qqbot",
    57→    blurb: "Connect to QQ via official QQ Bot API",
    58→    order: 50,
    59→  },
    60→  capabilities: {
    61→    chatTypes: ["direct", "group"],
    62→    media: true,
    63→    reactions: false,
    64→    threads: false,
    65→    /**
    66→     * blockStreaming: true 表示该 Channel 支持块流式
    67→     * 框架会收集流式响应，然后通过 deliver 回调发送
    68→     */
    69→    blockStreaming: false,
    70→  },
    71→  reload: { configPrefixes: ["channels.qqbot"] },
    72→  // CLI onboarding wizard
    73→  onboarding: qqbotOnboardingAdapter,
    74→  // 消息目标解析
    75→  messaging: {
    76→    normalizeTarget: (target) => {
    77→      // 支持格式: qqbot:c2c:xxx, qqbot:group:xxx, c2c:xxx, group:xxx, openid
    78→      const normalized = target.replace(/^qqbot:/i, "");
    79→      return { ok: true, to: normalized };
    80→    },
    81→    targetResolver: {
    82→      looksLikeId: (id) => {
    83→        // 先去掉 qqbot: 前缀
    84→        const normalized = id.replace(/^qqbot:/i, "");
    85→        // 支持 c2c:xxx, group:xxx, channel:xxx 格式
    86→        if (normalized.startsWith("c2c:") || normalized.startsWith("group:") || normalized.startsWith("channel:")) return true;
    87→        // 支持纯 openid（32位十六进制）
    88→        if (/^[A-F0-9]{32}$/i.test(normalized)) return true;
    89→        return false;
    90→      },
    91→      hint: "c2c:<openid> or group:<groupOpenid>",
    92→    },
    93→  },
    94→  config: {
    95→    listAccountIds: (cfg) => listQQBotAccountIds(cfg),
    96→    resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
    97→    defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg),
    98→    // 新增：设置账户启用状态
    99→    setAccountEnabled: ({ cfg, accountId, enabled }) =>
   100→      setAccountEnabledInConfigSection({
   101→        cfg,
   102→        sectionKey: "qqbot",
   103→        accountId,
   104→        enabled,
   105→        allowTopLevel: true,
   106→      }),
   107→    // 新增：删除账户
   108→    deleteAccount: ({ cfg, accountId }) =>
   109→      deleteAccountFromConfigSection({
   110→        cfg,
   111→        sectionKey: "qqbot",
   112→        accountId,
   113→        clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
   114→      }),
   115→    isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
   116→    describeAccount: (account) => ({
   117→      accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
   118→      name: account?.name,
   119→      enabled: account?.enabled ?? false,
   120→      configured: Boolean(account?.appId && account?.clientSecret),
   121→      tokenSource: account?.secretSource,
   122→    }),
   123→    // 关键：解析 allowFrom 配置，用于命令授权
   124→    resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
   125→      const account = resolveQQBotAccount(cfg, accountId);
   126→      const allowFrom = account.config?.allowFrom ?? [];
   127→      console.log(`[qqbot] resolveAllowFrom: accountId=${accountId}, allowFrom=${JSON.stringify(allowFrom)}`);
   128→      return allowFrom.map((entry: string | number) => String(entry));
   129→    },
   130→    // 格式化 allowFrom 条目（移除 qqbot: 前缀，统一大写）
   131→    formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
   132→      allowFrom
   133→        .map((entry: string | number) => String(entry).trim())
   134→        .filter(Boolean)
   135→        .map((entry: string) => entry.replace(/^qqbot:/i, ""))
   136→        .map((entry: string) => entry.toUpperCase()), // QQ openid 是大写的
   137→  },
   138→  setup: {
   139→    // 新增：规范化账户 ID
   140→    resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
   141→    // 新增：应用账户名称
   142→    applyAccountName: ({ cfg, accountId, name }) =>
   143→      applyAccountNameToChannelSection({
   144→        cfg,
   145→        channelKey: "qqbot",
   146→        accountId,
   147→        name,
   148→      }),
   149→    validateInput: ({ input }) => {
   150→      if (!input.token && !input.tokenFile && !input.useEnv) {
   151→        return "QQBot requires --token (format: appId:clientSecret) or --use-env";
   152→      }
   153→      return null;
   154→    },
   155→    applyAccountConfig: ({ cfg, accountId, input }) => {
   156→      let appId = "";
   157→      let clientSecret = "";
   158→
   159→      if (input.token) {
   160→        const parts = input.token.split(":");
   161→        if (parts.length === 2) {
   162→          appId = parts[0];
   163→          clientSecret = parts[1];
   164→        }
   165→      }
   166→
   167→      return applyQQBotAccountConfig(cfg, accountId, {
   168→        appId,
   169→        clientSecret,
   170→        clientSecretFile: input.tokenFile,
   171→        name: input.name,
   172→        imageServerBaseUrl: input.imageServerBaseUrl,
   173→      });
   174→    },
   175→  },
   176→  // Messaging 配置：用于解析目标地址
   177→  messaging: {
   178→    /**
   179→     * 规范化目标地址
   180→     * 支持以下格式：
   181→     * - qqbot:c2c:openid -> 私聊
   182→     * - qqbot:group:groupid -> 群聊
   183→     * - qqbot:channel:channelid -> 频道
   184→     * - c2c:openid -> 私聊
   185→     * - group:groupid -> 群聊
   186→     * - channel:channelid -> 频道
   187→     * - 纯 openid（32位十六进制）-> 私聊
   188→     */
   189→    normalizeTarget: (target: string) => {
   190→      // 去掉 qqbot: 前缀（如果有）
   191→      let id = target.replace(/^qqbot:/i, "");
   192→
   193→      // 检查是否是已知格式
   194→      if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) {
   195→        return { ok: true, to: `qqbot:${id}` };
   196→      }
   197→
   198→      // 检查是否是纯 openid（32位十六进制，带连字符）
   199→      // QQ Bot OpenID 格式类似: 207A5B8339D01F6582911C014668B77B
   200→      const openIdPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
   201→      if (openIdPattern.test(id)) {
   202→        return { ok: true, to: `qqbot:c2c:${id}` };
   203→      }
   204→
   205→      // 不认识的格式
   206→      return {
   207→        ok: false,
   208→        error: `Invalid QQ Bot target format: "${target}". Expected: qqbot:c2c:openid, qqbot:group:groupid, or openid (UUID format)`
   209→      };
   210→    },
   211→    /**
   212→     * 目标解析器配置
   213→     * 用于判断一个目标 ID 是否看起来像 QQ Bot 的格式
   214→     */
   215→    targetResolver: {
   216→      /**
   217→       * 判断目标 ID 是否可能是 QQ Bot 格式
   218→       * 支持以下格式：
   219→       * - qqbot:c2c:xxx
   220→       * - qqbot:group:xxx
   221→       * - qqbot:channel:xxx
   222→       * - c2c:xxx
   223→       * - group:xxx
   224→       * - channel:xxx
   225→       * - UUID 格式的 openid
   226→       */
   227→      looksLikeId: (id: string): boolean => {
   228→        // 带 qqbot: 前缀的格式
   229→        if (/^qqbot:(c2c|group|channel):/i.test(id)) {
   230→          return true;
   231→        }
   232→        // 不带前缀但有类型标识
   233→        if (/^(c2c|group|channel):/i.test(id)) {
   234→          return true;
   235→        }
   236→        // UUID 格式的 openid（QQ Bot 的用户/群 ID 格式）
   237→        const openIdPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
   238→        return openIdPattern.test(id);
   239→      },
   240→      hint: "QQ Bot 目标格式: qqbot:c2c:openid (私聊) 或 qqbot:group:groupid (群聊)",
   241→    },
   242→  },
   243→  outbound: {
   244→    deliveryMode: "direct",
   245→    chunker: chunkText,
   246→    chunkerMode: "markdown",
   247→    textChunkLimit: 2000,
   248→    sendText: async ({ to, text, accountId, replyToId, cfg }) => {
   249→      const account = resolveQQBotAccount(cfg, accountId);
   250→      const result = await sendText({ to, text, accountId, replyToId, account });
   251→      return {
   252→        channel: "qqbot",
   253→        messageId: result.messageId,
   254→        error: result.error ? new Error(result.error) : undefined,
   255→      };
   256→    },
   257→    sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
   258→      const account = resolveQQBotAccount(cfg, accountId);
   259→      const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
   260→      return {
   261→        channel: "qqbot",
   262→        messageId: result.messageId,
   263→        error: result.error ? new Error(result.error) : undefined,
   264→      };
   265→    },
   266→  },
   267→  gateway: {
   268→    startAccount: async (ctx) => {
   269→      const { account, abortSignal, log, cfg } = ctx;
   270→
   271→      log?.info(`[qqbot:${account.accountId}] Starting gateway`);
   272→
   273→      await startGateway({
   274→        account,
   275→        abortSignal,
   276→        cfg,
   277→        log,
   278→        onReady: () => {
   279→          log?.info(`[qqbot:${account.accountId}] Gateway ready`);
   280→          ctx.setStatus({
   281→            ...ctx.getStatus(),
   282→            running: true,
   283→            connected: true,
   284→            lastConnectedAt: Date.now(),
   285→          });
   286→        },
   287→        onError: (error) => {
   288→          log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
   289→          ctx.setStatus({
   290→            ...ctx.getStatus(),
   291→            lastError: error.message,
   292→          });
   293→        },
   294→      });
   295→    },
   296→    // 新增：登出账户（清除配置中的凭证）
   297→    logoutAccount: async ({ accountId, cfg }) => {
   298→      const nextCfg = { ...cfg } as OpenClawConfig;
   299→      const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
   300→      let cleared = false;
   301→      let changed = false;
   302→
   303→      if (nextQQBot) {
   304→        const qqbot = nextQQBot as Record<string, unknown>;
   305→        if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
   306→          delete qqbot.clientSecret;
   307→          cleared = true;
   308→          changed = true;
   309→        }
   310→        const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
   311→        if (accounts && accountId in accounts) {
   312→          const entry = accounts[accountId] as Record<string, unknown> | undefined;
   313→          if (entry && "clientSecret" in entry) {
   314→            delete entry.clientSecret;
   315→            cleared = true;
   316→            changed = true;
   317→          }
   318→          if (entry && Object.keys(entry).length === 0) {
   319→            delete accounts[accountId];
   320→            changed = true;
   321→          }
   322→        }
   323→      }
   324→
   325→      if (changed && nextQQBot) {
   326→        nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
   327→        const runtime = getQQBotRuntime();
   328→        const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
   329→        await configApi.writeConfigFile(nextCfg);
   330→      }
   331→
   332→      const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
   333→      const loggedOut = resolved.secretSource === "none";
   334→      const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
   335→
   336→      return { ok: true, cleared, envToken, loggedOut };
   337→    },
   338→  },
   339→  status: {
   340→    defaultRuntime: {
   341→      accountId: DEFAULT_ACCOUNT_ID,
   342→      running: false,
   343→      connected: false,
   344→      lastConnectedAt: null,
   345→      lastError: null,
   346→      lastInboundAt: null,
   347→      lastOutboundAt: null,
   348→    },
   349→    // 新增：构建通道摘要
   350→    buildChannelSummary: ({ snapshot }: { snapshot: Record<string, unknown> }) => ({
   351→      configured: snapshot.configured ?? false,
   352→      tokenSource: snapshot.tokenSource ?? "none",
   353→      running: snapshot.running ?? false,
   354→      connected: snapshot.connected ?? false,
   355→      lastConnectedAt: snapshot.lastConnectedAt ?? null,
   356→      lastError: snapshot.lastError ?? null,
   357→    }),
   358→    buildAccountSnapshot: ({ account, runtime }: { account?: ResolvedQQBotAccount; runtime?: Record<string, unknown> }) => ({
   359→      accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
   360→      name: account?.name,
   361→      enabled: account?.enabled ?? false,
   362→      configured: Boolean(account?.appId && account?.clientSecret),
   363→      tokenSource: account?.secretSource,
   364→      running: runtime?.running ?? false,
   365→      connected: runtime?.connected ?? false,
   366→      lastConnectedAt: runtime?.lastConnectedAt ?? null,
   367→      lastError: runtime?.lastError ?? null,
   368→      lastInboundAt: runtime?.lastInboundAt ?? null,
   369→      lastOutboundAt: runtime?.lastOutboundAt ?? null,
   370→    }),
   371→  },
   372→};
   373→

<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>

import {
  type ChannelPlugin,
  type OpenClawConfig,
  applyAccountNameToChannelSection,
  deleteAccountFromConfigSection,
  setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk";

import type { ResolvedQQBotAccount } from "./types.js";
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
import { sendText, sendMedia } from "./outbound.js";
import { startGateway } from "./gateway.js";
import { qqbotOnboardingAdapter } from "./onboarding.js";
import { getQQBotRuntime } from "./runtime.js";

/**
 * 简单的文本分块函数
 * 用于预先分块长文本
 */
function chunkText(text: string, limit: number): string[] {
  if (text.length <= limit) return [text];

  const chunks: string[] = [];
  let remaining = text;

  while (remaining.length > 0) {
    if (remaining.length <= limit) {
      chunks.push(remaining);
      break;
    }

    // 尝试在换行处分割
    let splitAt = remaining.lastIndexOf("\n", limit);
    if (splitAt <= 0 || splitAt < limit * 0.5) {
      // 没找到合适的换行，尝试在空格处分割
      splitAt = remaining.lastIndexOf(" ", limit);
    }
    if (splitAt <= 0 || splitAt < limit * 0.5) {
      // 还是没找到，强制在 limit 处分割
      splitAt = limit;
    }

    chunks.push(remaining.slice(0, splitAt));
    remaining = remaining.slice(splitAt).trimStart();
  }

  return chunks;
}

export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
  id: "qqbot",
  meta: {
    id: "qqbot",
    label: "QQ Bot",
    selectionLabel: "QQ Bot",
    docsPath: "/docs/channels/qqbot",
    blurb: "Connect to QQ via official QQ Bot API",
    order: 50,
  },
  capabilities: {
    chatTypes: ["direct", "group"],
    media: true,
    reactions: false,
    threads: false,
    /**
     * blockStreaming: true 表示该 Channel 支持块流式
     * 框架会收集流式响应，然后通过 deliver 回调发送
     */
    blockStreaming: false,
  },
  reload: { configPrefixes: ["channels.qqbot"] },
  // CLI onboarding wizard
  onboarding: qqbotOnboardingAdapter,
  // 消息目标解析
  messaging: {
    normalizeTarget: (target) => {
      // 支持格式: qqbot:c2c:xxx, qqbot:group:xxx, c2c:xxx, group:xxx, openid
      const normalized = target.replace(/^qqbot:/i, "");
      return { ok: true, to: normalized };
    },
    targetResolver: {
      looksLikeId: (id) => {
        // 先去掉 qqbot: 前缀
        const normalized = id.replace(/^qqbot:/i, "");
        // 支持 c2c:xxx, group:xxx, channel:xxx 格式
        if (normalized.startsWith("c2c:") || normalized.startsWith("group:") || normalized.startsWith("channel:")) return true;
        // 支持纯 openid（32位十六进制）
        if (/^[A-F0-9]{32}$/i.test(normalized)) return true;
        return false;
      },
      hint: "c2c:<openid> or group:<groupOpenid>",
    },
  },
  config: {
    listAccountIds: (cfg) => listQQBotAccountIds(cfg),
    resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
    defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg),
    // 新增：设置账户启用状态
    setAccountEnabled: ({ cfg, accountId, enabled }) =>
      setAccountEnabledInConfigSection({
        cfg,
        sectionKey: "qqbot",
        accountId,
        enabled,
        allowTopLevel: true,
      }),
    // 新增：删除账户
    deleteAccount: ({ cfg, accountId }) =>
      deleteAccountFromConfigSection({
        cfg,
        sectionKey: "qqbot",
        accountId,
        clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
      }),
    isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
    describeAccount: (account) => ({
      accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
      name: account?.name,
      enabled: account?.enabled ?? false,
      configured: Boolean(account?.appId && account?.clientSecret),
      tokenSource: account?.secretSource,
    }),
    // 关键：解析 allowFrom 配置，用于命令授权
    resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
      const account = resolveQQBotAccount(cfg, accountId);
      const allowFrom = account.config?.allowFrom ?? [];
      console.log(`[qqbot] resolveAllowFrom: accountId=${accountId}, allowFrom=${JSON.stringify(allowFrom)}`);
      return allowFrom.map((entry: string | number) => String(entry));
    },
    // 格式化 allowFrom 条目（移除 qqbot: 前缀，统一大写）
    formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
      allowFrom
        .map((entry: string | number) => String(entry).trim())
        .filter(Boolean)
        .map((entry: string) => entry.replace(/^qqbot:/i, ""))
        .map((entry: string) => entry.toUpperCase()), // QQ openid 是大写的
  },
  setup: {
    // 新增：规范化账户 ID
    resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
    // 新增：应用账户名称
    applyAccountName: ({ cfg, accountId, name }) =>
      applyAccountNameToChannelSection({
        cfg,
        channelKey: "qqbot",
        accountId,
        name,
      }),
    validateInput: ({ input }) => {
      if (!input.token && !input.tokenFile && !input.useEnv) {
        return "QQBot requires --token (format: appId:clientSecret) or --use-env";
      }
      return null;
    },
    applyAccountConfig: ({ cfg, accountId, input }) => {
      let appId = "";
      let clientSecret = "";

      if (input.token) {
        const parts = input.token.split(":");
        if (parts.length === 2) {
          appId = parts[0];
          clientSecret = parts[1];
        }
      }

      return applyQQBotAccountConfig(cfg, accountId, {
        appId,
        clientSecret,
        clientSecretFile: input.tokenFile,
        name: input.name,
        imageServerBaseUrl: input.imageServerBaseUrl,
      });
    },
  },
  // Messaging 配置：用于解析目标地址
  messaging: {
    /**
     * 规范化目标地址
     * 支持以下格式：
     * - qqbot:c2c:openid -> 私聊
     * - qqbot:group:groupid -> 群聊
     * - qqbot:channel:channelid -> 频道
     * - c2c:openid -> 私聊
     * - group:groupid -> 群聊
     * - channel:channelid -> 频道
     * - 纯 openid（32位十六进制）-> 私聊
     */
    normalizeTarget: (target: string) => {
      // 去掉 qqbot: 前缀（如果有）
      let id = target.replace(/^qqbot:/i, "");

      // 检查是否是已知格式
      if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) {
        return { ok: true, to: `qqbot:${id}` };
      }

      // 检查是否是纯 openid（32位十六进制，带连字符）
      // QQ Bot OpenID 格式类似: 207A5B8339D01F6582911C014668B77B
      const openIdPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
      if (openIdPattern.test(id)) {
        return { ok: true, to: `qqbot:c2c:${id}` };
      }

      // 不认识的格式
      return {
        ok: false,
        error: `Invalid QQ Bot target format: "${target}". Expected: qqbot:c2c:openid, qqbot:group:groupid, or openid (UUID format)`
      };
    },
    /**
     * 目标解析器配置
     * 用于判断一个目标 ID 是否看起来像 QQ Bot 的格式
     */
    targetResolver: {
      /**
       * 判断目标 ID 是否可能是 QQ Bot 格式
       * 支持以下格式：
       * - qqbot:c2c:xxx
       * - qqbot:group:xxx
       * - qqbot:channel:xxx
       * - c2c:xxx
       * - group:xxx
       * - channel:xxx
       * - UUID 格式的 openid
       */
      looksLikeId: (id: string): boolean => {
        // 带 qqbot: 前缀的格式
        if (/^qqbot:(c2c|group|channel):/i.test(id)) {
          return true;
        }
        // 不带前缀但有类型标识
        if (/^(c2c|group|channel):/i.test(id)) {
          return true;
        }
        // UUID 格式的 openid（QQ Bot 的用户/群 ID 格式）
        const openIdPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
        return openIdPattern.test(id);
      },
      hint: "QQ Bot 目标格式: qqbot:c2c:openid (私聊) 或 qqbot:group:groupid (群聊)",
    },
  },
  outbound: {
    deliveryMode: "direct",
    chunker: chunkText,
    chunkerMode: "markdown",
    textChunkLimit: 2000,
    sendText: async ({ to, text, accountId, replyToId, cfg }) => {
      const account = resolveQQBotAccount(cfg, accountId);
      const result = await sendText({ to, text, accountId, replyToId, account });
      return {
        channel: "qqbot",
        messageId: result.messageId,
        error: result.error ? new Error(result.error) : undefined,
      };
    },
    sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
      const account = resolveQQBotAccount(cfg, accountId);
      const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
      return {
        channel: "qqbot",
        messageId: result.messageId,
        error: result.error ? new Error(result.error) : undefined,
      };
    },
  },
  gateway: {
    startAccount: async (ctx) => {
      const { account, abortSignal, log, cfg } = ctx;

      log?.info(`[qqbot:${account.accountId}] Starting gateway`);

      await startGateway({
        account,
        abortSignal,
        cfg,
        log,
        onReady: () => {
          log?.info(`[qqbot:${account.accountId}] Gateway ready`);
          ctx.setStatus({
            ...ctx.getStatus(),
            running: true,
            connected: true,
            lastConnectedAt: Date.now(),
          });
        },
        onError: (error) => {
          log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
          ctx.setStatus({
            ...ctx.getStatus(),
            lastError: error.message,
          });
        },
      });
    },
    // 新增：登出账户（清除配置中的凭证）
    logoutAccount: async ({ accountId, cfg }) => {
      const nextCfg = { ...cfg } as OpenClawConfig;
      const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
      let cleared = false;
      let changed = false;

      if (nextQQBot) {
        const qqbot = nextQQBot as Record<string, unknown>;
        if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
          delete qqbot.clientSecret;
          cleared = true;
          changed = true;
        }
        const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
        if (accounts && accountId in accounts) {
          const entry = accounts[accountId] as Record<string, unknown> | undefined;
          if (entry && "clientSecret" in entry) {
            delete entry.clientSecret;
            cleared = true;
            changed = true;
          }
          if (entry && Object.keys(entry).length === 0) {
            delete accounts[accountId];
            changed = true;
          }
        }
      }

      if (changed && nextQQBot) {
        nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
        const runtime = getQQBotRuntime();
        const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
        await configApi.writeConfigFile(nextCfg);
      }

      const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
      const loggedOut = resolved.secretSource === "none";
      const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);

      return { ok: true, cleared, envToken, loggedOut };
    },
  },
  status: {
    defaultRuntime: {
      accountId: DEFAULT_ACCOUNT_ID,
      running: false,
      connected: false,
      lastConnectedAt: null,
      lastError: null,
      lastInboundAt: null,
      lastOutboundAt: null,
    },
    // 新增：构建通道摘要
    buildChannelSummary: ({ snapshot }: { snapshot: Record<string, unknown> }) => ({
      configured: snapshot.configured ?? false,
      tokenSource: snapshot.tokenSource ?? "none",
      running: snapshot.running ?? false,
      connected: snapshot.connected ?? false,
      lastConnectedAt: snapshot.lastConnectedAt ?? null,
      lastError: snapshot.lastError ?? null,
    }),
    buildAccountSnapshot: ({ account, runtime }: { account?: ResolvedQQBotAccount; runtime?: Record<string, unknown> }) => ({
      accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
      name: account?.name,
      enabled: account?.enabled ?? false,
      configured: Boolean(account?.appId && account?.clientSecret),
      tokenSource: account?.secretSource,
      running: runtime?.running ?? false,
      connected: runtime?.connected ?? false,
      lastConnectedAt: runtime?.lastConnectedAt ?? null,
      lastError: runtime?.lastError ?? null,
      lastInboundAt: runtime?.lastInboundAt ?? null,
      lastOutboundAt: runtime?.lastOutboundAt ?? null,
    }),
  },
};


好的，现在我需要找到 OpenClaw 框架中关于 TTS 的实现，看看它是如何处理语音消息的。让我搜索一下 OpenClaw 中关于 TTS 的相关代码。


好的，现在让我搜索 OpenClaw 中关于 TTS 的相关代码，看看它是如何生成和发送语音消息的。


<persisted-output>
Output too large (39.8KB). Full output saved to: /root/.claude/projects/-root--openclaw/6781816a-a4b0-4438-aa0f-d4563efbdcb6/tool-results/call_xpqrjij1gmll9alilcym4b00.txt

Preview (first 2KB):
Found 528 files
agents/main/sessions/fb487186-0e5f-4529-b006-d42da174bb6f.jsonl
agents/main/sessions/f24f104f-5edd-420a-a9b0-8cb4a5623b4e.jsonl
agents/main/sessions/sessions.json
agents/main/sessions/7d61c8f4-d8ac-4c58-a8b9-c4867bdf79f5.jsonl
openclaw.json.bak
openclaw.json
openclaw.json.bak.1
openclaw.json.bak.2
openclaw.json.bak.3
openclaw.json.bak.4
agents/main/sessions/e90517e5-cbc2-45c2-a15b-c70d3548107e.jsonl.reset.2026-02-27T13-00-09.862Z
workspace/TOOLS.md
workspace/AGENTS.md
extensions/wecom/node_modules/.package-lock.json
extensions/wecom/package-lock.json
extensions/wecom/node_modules/node-edge-tts/bin.js
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/config/zod-schema.session.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/config/zod-schema.providers.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/config/zod-schema.providers-core.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/config/zod-schema.d.ts
extensions/wecom/node_modules/openclaw/extensions/voice-call/src/webhook.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/config/types.tts.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/config/types.messages.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/config/types.discord.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/plugins/runtime/types.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/plugins/types.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/config/types.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/config/sessions/types.d.ts
extensions/wecom/node_modules/openclaw/extensions/voice-call/src/providers/twilio.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/tts/tts.d.ts
extensions/wecom/node_modules/openclaw/dist/plugin-sdk/tts/tts-core.d.ts
extensions/wecom/node_modules/openclaw/extensions/voice-call/src/providers/tts-openai.ts
extensions/wecom/node_modules/openclaw/extensions/voice-call/src/providers/telnyx.ts
...
</persisted-output>


好的，我看到了一些与 TTS 相关的文件。我需要查看 OpenClaw 框架核心的 TTS 实现，特别是 `/root/.openclaw/extensions/wecom/node_modules/openclaw/dist/plugin-sdk/tts/tts-core.d.ts` 这个文件，以及看看 OpenClaw 如何在通道间处理语音消息的发送。


我需要查看 OpenClaw 核心的 TTS 实现，特别是 tts-core.d.ts 和其他相关文件，以了解它如何处理语音消息的发送。


Bash

find /root/.openclaw -name "*tts*" | grep -v node_modules | head -20

查找OpenClaw核心的TTS相关文件


列出extensions目录


total 8
drwxr-xr-x  7 root root   85 Mar 10 19:04 .
drwx------ 19 root root 4096 Mar 10 19:04 ..
drwxr-xr-x  4 root root  126 Feb 24 18:20 adp-openclaw
drwxr-xr-x  4 root root  143 Feb 24 18:19 ddingtalk
drwxr-xr-x  6 root root 4096 Feb 24 18:19 qqbot
drwxr-xr-x  2 root root   50 Mar 10 19:04 skillhub
drwxr-xr-x  4 root root  186 Feb 24 18:20 wecom


好的，看起来 OpenClaw 的核心 TTS 实现在 node_modules 里面。让我查看一下 `/root/.openclaw/extensions/wecom/node_modules/openclaw/dist/plugin-sdk/tts/` 目录下的文件，以及 `/root/.openclaw/extensions/wecom/node_modules/openclaw/dist/plugin-sdk/channels/` 目录，看看 OpenClaw 是如何处理媒体/语音消息的。


我要查看 OpenClaw 核心的 TTS 实现文件，以及通道处理相关的代码。


列出plugin-sdk目录


Error: File does not exist. Note: your current working directory is /root/.openclaw.


     1→import type { OpenClawConfig } from "../config/config.js";
     2→import type { ResolvedTtsConfig, ResolvedTtsModelOverrides, TtsDirectiveParseResult } from "./tts.js";
     3→export declare function isValidVoiceId(voiceId: string): boolean;
     4→export declare function parseTtsDirectives(text: string, policy: ResolvedTtsModelOverrides): TtsDirectiveParseResult;
     5→export declare const OPENAI_TTS_MODELS: readonly ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"];
     6→export declare const OPENAI_TTS_VOICES: readonly ["alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "juniper", "marin", "onyx", "nova", "sage", "shimmer", "verse"];
     7→type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
     8→export declare function isValidOpenAIModel(model: string): boolean;
     9→export declare function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice;
    10→type SummarizeResult = {
    11→    summary: string;
    12→    latencyMs: number;
    13→    inputLength: number;
    14→    outputLength: number;
    15→};
    16→export declare function summarizeText(params: {
    17→    text: string;
    18→    targetLength: number;
    19→    cfg: OpenClawConfig;
    20→    config: ResolvedTtsConfig;
    21→    timeoutMs: number;
    22→}): Promise<SummarizeResult>;
    23→export declare function scheduleCleanup(tempDir: string, delayMs?: number): void;
    24→export declare function elevenLabsTTS(params: {
    25→    text: string;
    26→    apiKey: string;
    27→    baseUrl: string;
    28→    voiceId: string;
    29→    modelId: string;
    30→    outputFormat: string;
    31→    seed?: number;
    32→    applyTextNormalization?: "auto" | "on" | "off";
    33→    languageCode?: string;
    34→    voiceSettings: ResolvedTtsConfig["elevenlabs"]["voiceSettings"];
    35→    timeoutMs: number;
    36→}): Promise<Buffer>;
    37→export declare function openaiTTS(params: {
    38→    text: string;
    39→    apiKey: string;
    40→    model: string;
    41→    voice: string;
    42→    responseFormat: "mp3" | "opus" | "pcm";
    43→    timeoutMs: number;
    44→}): Promise<Buffer>;
    45→export declare function inferEdgeExtension(outputFormat: string): string;
    46→export declare function edgeTTS(params: {
    47→    text: string;
    48→    outputPath: string;
    49→    config: ResolvedTtsConfig["edge"];
    50→    timeoutMs: number;
    51→}): Promise<void>;
    52→export {};
    53→

<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>

import type { OpenClawConfig } from "../config/config.js";
import type { ResolvedTtsConfig, ResolvedTtsModelOverrides, TtsDirectiveParseResult } from "./tts.js";
export declare function isValidVoiceId(voiceId: string): boolean;
export declare function parseTtsDirectives(text: string, policy: ResolvedTtsModelOverrides): TtsDirectiveParseResult;
export declare const OPENAI_TTS_MODELS: readonly ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"];
export declare const OPENAI_TTS_VOICES: readonly ["alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "juniper", "marin", "onyx", "nova", "sage", "shimmer", "verse"];
type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
export declare function isValidOpenAIModel(model: string): boolean;
export declare function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice;
type SummarizeResult = {
    summary: string;
    latencyMs: number;
    inputLength: number;
    outputLength: number;
};
export declare function summarizeText(params: {
    text: string;
    targetLength: number;
    cfg: OpenClawConfig;
    config: ResolvedTtsConfig;
    timeoutMs: number;
}): Promise<SummarizeResult>;
export declare function scheduleCleanup(tempDir: string, delayMs?: number): void;
export declare function elevenLabsTTS(params: {
    text: string;
    apiKey: string;
    baseUrl: string;
    voiceId: string;
    modelId: string;
    outputFormat: string;
    seed?: number;
    applyTextNormalization?: "auto" | "on" | "off";
    languageCode?: string;
    voiceSettings: ResolvedTtsConfig["elevenlabs"]["voiceSettings"];
    timeoutMs: number;
}): Promise<Buffer>;
export declare function openaiTTS(params: {
    text: string;
    apiKey: string;
    model: string;
    voice: string;
    responseFormat: "mp3" | "opus" | "pcm";
    timeoutMs: number;
}): Promise<Buffer>;
export declare function inferEdgeExtension(outputFormat: string): string;
export declare function edgeTTS(params: {
    text: string;
    outputPath: string;
    config: ResolvedTtsConfig["edge"];
    timeoutMs: number;
}): Promise<void>;
export {};


total 5396
drwxr-xr-x 43 root root    8192 Feb 24 18:20 .
drwxr-xr-x  8 root root   32768 Feb 24 18:20 ..
-rw-r--r--  1 root root      44 Feb 24 18:20 account-id.d.ts
-rw-r--r--  1 root root    1100 Feb 24 18:20 account-id.js
-rw-r--r--  1 root root     451 Feb 24 18:20 account-lookup-B-58l5Qb.js
-rw-r--r--  1 root root    1956 Feb 24 18:20 accounts-2gm1zJtg.js
-rw-r--r--  1 root root    8988 Feb 24 18:20 accounts-BKKD4hJZ.js
-rw-r--r--  1 root root    1940 Feb 24 18:20 accounts-sa6LdGmg.js
-rw-r--r--  1 root root    1882 Feb 24 18:20 active-listener-BQLpdhSf.js
drwxr-xr-x 11 root root    8192 Feb 24 18:20 agents
-rw-r--r--  1 root root   29909 Feb 24 18:20 agent-scope-S0PcYLol.js
-rw-r--r--  1 root root    5800 Feb 24 18:20 api-key-rotation-BsGZZy-M.js
-rw-r--r--  1 root root    3265 Feb 24 18:20 audio-preflight-CVSuDZJo.js
drwxr-xr-x  3 root root    4096 Feb 24 18:20 auto-reply
-rw-r--r--  1 root root    2143 Feb 24 18:20 bindings-CYy12UhO.js
drwxr-xr-x  3 root root    4096 Feb 24 18:20 browser
-rw-r--r--  1 root root    3743 Feb 24 18:20 channel-activity-lGclttrw.js
drwxr-xr-x  6 root root    4096 Feb 24 18:20 channels
-rw-r--r--  1 root root   83002 Feb 24 18:20 channel-web-DGK20iUj.js
-rw-r--r--  1 root root     699 Feb 24 18:20 channel-web.d.ts
-rw-r--r--  1 root root     327 Feb 24 18:20 chat-type-nF1vanR8.js
-rw-r--r--  1 root root   59217 Feb 24 18:20 chrome-CANCfwVi.js
-rw-r--r--  1 root root   12798 Feb 24 18:20 chunk-BNhTPGBO.js
drwxr-xr-x  2 root root    4096 Feb 24 18:20 cli
-rw-r--r--  1 root root    3037 Feb 24 18:20 command-format-CSkgHwwD.js
-rw-r--r--  1 root root    2269 Feb 24 18:20 command-poll-backoff-DYedxQ1l.js
drwxr-xr-x  3 root root     187 Feb 24 18:20 commands
-rw-r--r--  1 root root   33565 Feb 24 18:20 commands-registry-4c-BAnJu.js
drwxr-xr-x  2 root root      31 Feb 24 18:20 compat
drwxr-xr-x  3 root root    4096 Feb 24 18:20 config
-rw-r--r--  1 root root  286718 Feb 24 18:20 config-0er5lK-Y.js
-rw-r--r--  1 root root    1507 Feb 24 18:20 conversation-label-Dp2gunan.js
drwxr-xr-x  3 root root    4096 Feb 24 18:20 cron
drwxr-xr-x  2 root root      28 Feb 24 18:20 daemon
-rw-r--r--  1 root root   45270 Feb 24 18:20 deliver-Bo5iHu6D.js
-rw-r--r--  1 root root   10331 Feb 24 18:20 diagnostic-DF-QTn_B.js
-rw-r--r--  1 root root    2564 Feb 24 18:20 diagnostic-session-state-N_p75k1K.js
drwxr-xr-x  4 root root    4096 Feb 24 18:20 discord
-rw-r--r--  1 root root   36272 Feb 24 18:20 dock-xRpcIf4o.js
-rw-r--r--  1 root root    1275 Feb 24 18:20 errors-CYhifFbz.js
-rw-r--r--  1 root root    2437 Feb 24 18:20 fetch-DdmiNDLY.js
-rw-r--r--  1 root root    4396 Feb 24 18:20 fetch-guard-C8dbOA2p.js
-rw-r--r--  1 root root    1374 Feb 24 18:20 fetch-timeout-D14QGlq9.js
drwxr-xr-x  5 root root    4096 Feb 24 18:20 gateway
-rw-r--r--  1 root root    4149 Feb 24 18:20 github-copilot-token-Cg0YPPSu.js
-rw-r--r--  1 root root     607 Feb 24 18:20 globals.d.ts
drwxr-xr-x  2 root root      51 Feb 24 18:20 hooks
-rw-r--r--  1 root root   60014 Feb 24 18:20 image-Cq1KSeA8.js
-rw-r--r--  1 root root   17352 Feb 24 18:20 image-ops-DukJmgIX.js
drwxr-xr-x  3 root root    4096 Feb 24 18:20 imessage
-rw-r--r--  1 root root    3948 Feb 24 18:20 inbound-context-DAG51Tji.js
-rw-r--r--  1 root root      39 Feb 24 18:20 index.d.ts
-rw-r--r--  1 root root  144636 Feb 24 18:20 index.js
drwxr-xr-x  6 root root    4096 Feb 24 18:20 infra
-rw-r--r--  1 root root   27354 Feb 24 18:20 ir-DQ4F9r9S.js
drwxr-xr-x  3 root root    4096 Feb 24 18:20 line
drwxr-xr-x  2 root root     102 Feb 24 18:20 link-understanding
-rw-r--r--  1 root root    6143 Feb 24 18:20 local-roots-3Xef4JBF.js
-rw-r--r--  1 root root     421 Feb 24 18:20 logger.d.ts
drwxr-xr-x  2 root root    4096 Feb 24 18:20 logging
-rw-r--r--  1 root root    1657 Feb 24 18:20 logging.d.ts
-rw-r--r--  1 root root    2505 Feb 24 18:20 login-MoaLnpAe.js
-rw-r--r--  1 root root   10377 Feb 24 18:20 login-qr-BuDpnoR8.js
-rw-r--r--  1 root root  133770 Feb 24 18:20 manager-Bx37Xovu.js
-rw-r--r--  1 root root   27594 Feb 24 18:20 manifest-registry-J3QGS9aQ.js
drwxr-xr-x  2 root root     146 Feb 24 18:20 markdown
-rw-r--r--  1 root root    1563 Feb 24 18:20 markdown-tables-G3uY5lDR.js
drwxr-xr-x  2 root root    4096 Feb 24 18:20 media
drwxr-xr-x  3 root root    4096 Feb 24 18:20 media-understanding
drwxr-xr-x  2 root root    4096 Feb 24 18:20 memory
-rw-r--r--  1 root root    3236 Feb 24 18:20 message-channel-CkSgoFcf.js
-rw-r--r--  1 root root  143104 Feb 24 18:20 model-selection-CfBXvztJ.js
drwxr-xr-x  2 root root      31 Feb 24 18:20 node-host
-rw-r--r--  1 root root     569 Feb 24 18:20 outbound-attachment-ChuHktds.js
-rw-r--r--  1 root root    7956 Feb 24 18:20 outbound-B_5zbsvl.js
drwxr-xr-x  2 root root      88 Feb 24 18:20 pairing
-rw-r--r--  1 root root    1175 Feb 24 18:20 path-guards-B41Bkx5V.js
-rw-r--r--  1 root root    7821 Feb 24 18:20 paths-B-NY-HdV.js
-rw-r--r--  1 root root    8321 Feb 24 18:20 paths-DVWx7USN.js
-rw-r--r--  1 root root    3817 Feb 24 18:20 pi-auth-json-USgP67DB.js
-rw-r--r--  1 root root  235053 Feb 24 18:20 pi-embedded-helpers-CCie0w1M.js
-rw-r--r--  1 root root    1064 Feb 24 18:20 pi-model-discovery-CNP1dAqt.js
drwxr-xr-x  3 root root    4096 Feb 24 18:20 plugins
-rw-r--r--  1 root root   25934 Feb 24 18:20 plugins-BcZyqZ2i.js
drwxr-xr-x  2 root root    4096 Feb 24 18:20 plugin-sdk
-rw-r--r--  1 root root    1963 Feb 24 18:20 polls-BVXGH_xL.js
-rw-r--r--  1 root root     903 Feb 24 18:20 polls.d.ts
drwxr-xr-x  3 root root     131 Feb 24 18:20 process
drwxr-xr-x  2 root root      97 Feb 24 18:20 providers
-rw-r--r--  1 root root     483 Feb 24 18:20 proxy-BaSHbVlA.js
-rw-r--r--  1 root root   63536 Feb 24 18:20 pw-ai-WAP29gR1.js
-rw-r--r--  1 root root   51319 Feb 24 18:20 qmd-manager-6jltrqA1.js
-rw-r--r--  1 root root   22240 Feb 24 18:20 query-expansion-CSgsaVpI.js
-rw-r--r--  1 root root    6257 Feb 24 18:20 redact-CuKhE6Uz.js
-rw-r--r--  1 root root    3292 Feb 24 18:20 render-3_VJ30aR.js
-rw-r--r--  1 root root    4931 Feb 24 18:20 replies-BczW3tOT.js
-rw-r--r--  1 root root 2818968 Feb 24 18:20 reply-Bs9SaomU.js
-rw-r--r--  1 root root    6504 Feb 24 18:20 reply-prefix-CbCIh1l9.js
-rw-r--r--  1 root root    1300 Feb 24 18:20 resolve-outbound-target-BRl0FB7o.js
-rw-r--r--  1 root root    9163 Feb 24 18:20 resolve-route-grCI_wee.js
-rw-r--r--  1 root root    2968 Feb 24 18:20 retry-CJ-3febF.js
-rw-r--r--  1 root root     423 Feb 24 18:20 rolldown-runtime-Cbj13DAv.js
drwxr-xr-x  2 root root     127 Feb 24 18:20 routing
-rw-r--r--  1 root root   70718 Feb 24 18:20 runner-CgiBc1IJ.js
-rw-r--r--  1 root root     256 Feb 24 18:20 runtime.d.ts
-rw-r--r--  1 root root     319 Feb 24 18:20 secure-random-CV-D0cj2.js
drwxr-xr-x  2 root root     164 Feb 24 18:20 security
-rw-r--r--  1 root root   17757 Feb 24 18:20 send-B9x6wfw1.js
-rw-r--r--  1 root root   17545 Feb 24 18:20 send-BxdcwbDT.js
-rw-r--r--  1 root root  107556 Feb 24 18:20 send-C3UOFJ3z.js
-rw-r--r--  1 root root   83867 Feb 24 18:20 send-Cd0zOGtF.js
-rw-r--r--  1 root root   13124 Feb 24 18:20 send-TXnCZ4QC.js
-rw-r--r--  1 root root    6521 Feb 24 18:20 session-CmMtIhFE.js
-rw-r--r--  1 root root    8722 Feb 24 18:20 session-key-C7qWg3SH.js
drwxr-xr-x  2 root root    4096 Feb 24 18:20 sessions
-rw-r--r--  1 root root   82098 Feb 24 18:20 sessions-CUeRK6_V.js
drwxr-xr-x  4 root root    4096 Feb 24 18:20 shared
drwxr-xr-x  3 root root    4096 Feb 24 18:20 signal
-rw-r--r--  1 root root   12732 Feb 24 18:20 skill-commands-AjZnAIx0.js
-rw-r--r--  1 root root   47116 Feb 24 18:20 skills-5iUBhaef.js
drwxr-xr-x  4 root root    4096 Feb 24 18:20 slack
-rw-r--r--  1 root root   13140 Feb 24 18:20 ssrf-DPlHrEn9.js
-rw-r--r--  1 root root    2877 Feb 24 18:20 store-CC2fFkbE.js
-rw-r--r--  1 root root   53923 Feb 24 18:20 subsystem-DDlKm7JR.js
-rw-r--r--  1 root root    1145 Feb 24 18:20 tables-BOJ0WJO4.js
-rw-r--r--  1 root root    6647 Feb 24 18:20 target-errors-CTguFpPy.js
drwxr-xr-x  3 root root    4096 Feb 24 18:20 telegram
drwxr-xr-x  2 root root     175 Feb 24 18:20 terminal
-rw-r--r--  1 root root    4746 Feb 24 18:20 thinking-BpFZfHN9.js
-rw-r--r--  1 root root     881 Feb 24 18:20 tokens-CkN6dVPA.js
-rw-r--r--  1 root root    9752 Feb 24 18:20 tool-images-DTG_6Hc_.js
-rw-r--r--  1 root root   14199 Feb 24 18:20 tool-loop-detection-DunmEFLj.js
-rw-r--r--  1 root root     589 Feb 24 18:20 transcript-events-BVtbBh8t.js
drwxr-xr-x  2 root root      43 Feb 24 18:20 tts
drwxr-xr-x  2 root root    4096 Feb 24 18:20 utils
-rw-r--r--  1 root root    3249 Feb 24 18:20 utils.d.ts
-rw-r--r--  1 root root     501 Feb 24 18:20 version.d.ts
drwxr-xr-x  4 root root    4096 Feb 24 18:20 web
-rw-r--r--  1 root root    2658 Feb 24 18:20 web-gvMwnGUU.js
drwxr-xr-x  2 root root      64 Feb 24 18:20 whatsapp
-rw-r--r--  1 root root    3233 Feb 24 18:20 whatsapp-actions-CHbQZBh7.js
drwxr-xr-x  2 root root      46 Feb 24 18:20 wizard


好的，我看到了 `tts` 目录！让我查看一下 `tts.js` 文件，以及发送消息相关的文件，比如 `send-*.js`。


我需要查看 tts.js 文件以及与消息发送相关的文件，例如 send-*.js，来了解 OpenClaw 如何生成和发送语音消息。