"use strict";Object.defineProperty(exports, "__esModule", { value: true });exports.MediaFileType = void 0;exports.apiRequest = apiRequest;exports.clearTokenCache = clearTokenCache;exports.getAccessToken = getAccessToken;exports.getGatewayUrl = getGatewayUrl;exports.getNextMsgSeq = getNextMsgSeq;exports.getTokenStatus = getTokenStatus;exports.initApiConfig = initApiConfig;exports.isBackgroundTokenRefreshRunning = isBackgroundTokenRefreshRunning;exports.isMarkdownSupport = isMarkdownSupport;exports.sendC2CImageMessage = sendC2CImageMessage;exports.sendC2CInputNotify = sendC2CInputNotify;exports.sendC2CMediaMessage = sendC2CMediaMessage;exports.sendC2CMessage = sendC2CMessage;exports.sendChannelMessage = sendChannelMessage;exports.sendGroupImageMessage = sendGroupImageMessage;exports.sendGroupMediaMessage = sendGroupMediaMessage;exports.sendGroupMessage = sendGroupMessage;exports.sendProactiveC2CMessage = sendProactiveC2CMessage;exports.sendProactiveGroupMessage = sendProactiveGroupMessage;exports.startBackgroundTokenRefresh = startBackgroundTokenRefresh;exports.stopBackgroundTokenRefresh = stopBackgroundTokenRefresh;exports.uploadC2CMedia = uploadC2CMedia;exports.uploadGroupMedia = uploadGroupMedia; /** * 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,需要机器人具备该权限才能启用) */ function initApiConfig(options) { currentMarkdownSupport = options.markdownSupport === true; // 默认为 false,需要机器人具备 markdown 消息权限才能启用 } /** * 获取当前是否支持 markdown */ function isMarkdownSupport() { return currentMarkdownSupport; } let cachedToken = null; // Singleflight: 防止并发获取 Token 的 Promise 缓存 let tokenFetchPromise = null; /** * 获取 AccessToken(带缓存 + singleflight 并发安全) * * 使用 singleflight 模式:当多个请求同时发现 Token 过期时, * 只有第一个请求会真正去获取新 Token,其他请求复用同一个 Promise。 */ async function getAccessToken(appId, clientSecret) { // 检查缓存,提前 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, clientSecret) { 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; 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 = {}; 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; let rawBody; 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); } 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 缓存 */ function clearTokenCache() { cachedToken = null; // 注意:不清除 tokenFetchPromise,让进行中的请求完成 // 下次调用 getAccessToken 时会自动获取新 Token } /** * 获取 Token 缓存状态(用于监控) */ function getTokenStatus() { 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(); const seqBaseTime = Math.floor(Date.now() / 1000) % 100000000; // 取秒级时间戳的后8位作为基础 /** * 获取并递增消息序号 * 返回的 seq 会基于时间戳,避免进程重启后重复 */ function getNextMsgSeq(msgId) { 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 超时时间(毫秒),不传则根据请求类型自动选择 */ async function apiRequest( accessToken, method, path, body, timeoutMs) { const url = `${API_BASE}${path}`; const headers = { 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 = { 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; 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 = {}; 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; let rawBody; try { rawBody = await res.text(); console.log(`[qqbot-api] <<< Body:`, rawBody); data = JSON.parse(rawBody); } 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; throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`); } return data; } /** * 获取 WebSocket Gateway URL */ async function getGatewayUrl(accessToken) { const data = await apiRequest(accessToken, "GET", "/gateway"); return data.url; } // ============ 消息发送接口 ============ /** * 消息响应 */ /** * 构建消息体 * 根据 markdownSupport 配置决定消息格式: * - markdown 模式: { markdown: { content }, msg_type: 2 } * - 纯文本模式: { content, msg_type: 0 } */ function buildMessageBody( content, msgId, msgSeq) { const body = 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 单聊消息 */ async function sendC2CMessage( accessToken, openid, content, msgId) { const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; const body = buildMessageBody(content, msgId, msgSeq); return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body); } /** * 发送 C2C 输入状态提示(告知用户机器人正在输入) */ async function sendC2CInputNotify( accessToken, openid, msgId, inputSecond = 60) { 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); } /** * 发送频道消息(不支持流式) */ async function sendChannelMessage( accessToken, channelId, content, msgId) { return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, { content, ...(msgId ? { msg_id: msgId } : {}) }); } /** * 发送群聊消息 */ async function sendGroupMessage( accessToken, groupOpenid, content, msgId) { 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) { // 主动消息内容校验(参考 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. 不支持流式发送 */ async function sendProactiveC2CMessage( accessToken, openid, content) { 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. 不支持流式发送 */ async function sendProactiveGroupMessage( accessToken, groupOpenid, content) { 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); } // ============ 富媒体消息支持 ============ /** * 媒体文件类型 */let MediaFileType = exports.MediaFileType = /*#__PURE__*/function (MediaFileType) {MediaFileType[MediaFileType["IMAGE"] = 1] = "IMAGE";MediaFileType[MediaFileType["VIDEO"] = 2] = "VIDEO";MediaFileType[MediaFileType["VOICE"] = 3] = "VOICE";MediaFileType[MediaFileType["FILE"] = 4] = "FILE"; // 暂未开放 return MediaFileType;}({}); /** * 上传富媒体文件的响应 */ /** * 上传富媒体文件到 C2C 单聊 * @param url - 公网可访问的图片 URL(与 fileData 二选一) * @param fileData - Base64 编码的文件内容(与 url 二选一) */ async function uploadC2CMedia( accessToken, openid, fileType, url, fileData, srvSendMsg = false) { if (!url && !fileData) { throw new Error("uploadC2CMedia: url or fileData is required"); } const body = { 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 二选一) */ async function uploadGroupMedia( accessToken, groupOpenid, fileType, url, fileData, srvSendMsg = false) { if (!url && !fileData) { throw new Error("uploadGroupMedia: url or fileData is required"); } const body = { 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 单聊富媒体消息 */ async function sendC2CMediaMessage( accessToken, openid, fileInfo, msgId, content) { 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 } : {}) }); } /** * 发送群聊富媒体消息 */ async function sendGroupMediaMessage( accessToken, groupOpenid, fileInfo, msgId, content) { 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 */ async function sendC2CImageMessage( accessToken, openid, imageUrl, msgId, content) { let uploadResult; // 检查是否是 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 */ async function sendGroupImageMessage( accessToken, groupOpenid, imageUrl, msgId, content) { let uploadResult; // 检查是否是 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 刷新配置 */ // 后台刷新状态 let backgroundRefreshRunning = false; let backgroundRefreshAbortController = null; /** * 启动后台 Token 刷新 * 在后台定时刷新 Token,避免请求时才发现过期 * * @param appId 应用 ID * @param clientSecret 应用密钥 * @param options 配置选项 */ function startBackgroundTokenRefresh( appId, clientSecret, options) { 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 刷新 */ function stopBackgroundTokenRefresh() { if (backgroundRefreshAbortController) { backgroundRefreshAbortController.abort(); backgroundRefreshAbortController = null; } backgroundRefreshRunning = false; } /** * 检查后台 Token 刷新是否正在运行 */ function isBackgroundTokenRefreshRunning() { return backgroundRefreshRunning; } /** * 可中断的 sleep 函数 */ async function sleep(ms, signal) { 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 }); } }); } /* v9-bd36f97751735b94 */