openclaw/extensions/qqbot/src/api.ts

992 lines
27 KiB
TypeScript

import { createRequire } from "node:module";
import os from "node:os";
import { debugLog, debugError } from "./utils/debug-log.js";
import { sanitizeFileName } from "./utils/platform.js";
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
const API_BASE = "https://api.sgroup.qq.com";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
// Plugin User-Agent format: QQBotPlugin/{version} (Node/{nodeVersion}; {os})
const _require = createRequire(import.meta.url);
let _pluginVersion = "unknown";
try {
_pluginVersion = _require("../package.json").version ?? "unknown";
} catch {
/* fallback */
}
export const PLUGIN_USER_AGENT = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()})`;
// =========================================================================
// Per-appId runtime config (avoids multi-account global state conflicts)
// =========================================================================
const markdownSupportMap = new Map<string, boolean>();
/** Structured metadata recorded for outbound messages. */
export interface OutboundMeta {
text?: string;
mediaType?: "image" | "voice" | "video" | "file";
mediaUrl?: string;
mediaLocalPath?: string;
ttsText?: string;
}
type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
const onMessageSentHookMap = new Map<string, OnMessageSentCallback>();
/** Register an outbound-message hook scoped to one appId. */
export function onMessageSent(appId: string, callback: OnMessageSentCallback): void {
onMessageSentHookMap.set(String(appId).trim(), callback);
}
/** Initialize per-app API behavior such as markdown support. */
export function initApiConfig(appId: string, options: { markdownSupport?: boolean }): void {
markdownSupportMap.set(String(appId).trim(), options.markdownSupport === true);
}
/** Return whether markdown is enabled for the given appId. */
export function isMarkdownSupport(appId: string): boolean {
return markdownSupportMap.get(String(appId).trim()) ?? false;
}
// Keep token state per appId to avoid multi-account cross-talk.
const tokenCacheMap = new Map<string, { token: string; expiresAt: number; appId: string }>();
const tokenFetchPromises = new Map<string, Promise<string>>();
/**
* Resolve an access token with caching and singleflight semantics.
*/
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
const normalizedAppId = String(appId).trim();
const cachedToken = tokenCacheMap.get(normalizedAppId);
// Refresh slightly ahead of expiry without making short-lived tokens unusable.
const REFRESH_AHEAD_MS = cachedToken
? Math.min(5 * 60 * 1000, (cachedToken.expiresAt - Date.now()) / 3)
: 0;
if (cachedToken && Date.now() < cachedToken.expiresAt - REFRESH_AHEAD_MS) {
return cachedToken.token;
}
let fetchPromise = tokenFetchPromises.get(normalizedAppId);
if (fetchPromise) {
debugLog(
`[qqbot-api:${normalizedAppId}] Token fetch in progress, waiting for existing request...`,
);
return fetchPromise;
}
fetchPromise = (async () => {
try {
return await doFetchToken(normalizedAppId, clientSecret);
} finally {
tokenFetchPromises.delete(normalizedAppId);
}
})();
tokenFetchPromises.set(normalizedAppId, fetchPromise);
return fetchPromise;
}
/** Perform the token fetch request. */
async function doFetchToken(appId: string, clientSecret: string): Promise<string> {
const requestBody = { appId, clientSecret };
const requestHeaders = { "Content-Type": "application/json", "User-Agent": PLUGIN_USER_AGENT };
debugLog(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL}`);
let response: Response;
try {
response = await fetch(TOKEN_URL, {
method: "POST",
headers: requestHeaders,
body: JSON.stringify(requestBody),
});
} catch (err) {
debugError(`[qqbot-api:${appId}] <<< 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;
});
const tokenTraceId = response.headers.get("x-tps-trace-id") ?? "";
debugLog(
`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}${tokenTraceId ? ` | TraceId: ${tokenTraceId}` : ""}`,
);
let data: { access_token?: string; expires_in?: number };
let rawBody: string;
try {
rawBody = await response.text();
// Redact the token before logging the raw response body.
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
debugLog(`[qqbot-api:${appId}] <<< Body:`, logBody);
data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number };
} catch (err) {
debugError(`[qqbot-api:${appId}] <<< 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)}`);
}
const expiresAt = Date.now() + (data.expires_in ?? 7200) * 1000;
tokenCacheMap.set(appId, {
token: data.access_token,
expiresAt,
appId,
});
debugLog(`[qqbot-api:${appId}] Token cached, expires at: ${new Date(expiresAt).toISOString()}`);
return data.access_token;
}
/** Clear one token cache or all token caches. */
export function clearTokenCache(appId?: string): void {
if (appId) {
const normalizedAppId = String(appId).trim();
tokenCacheMap.delete(normalizedAppId);
debugLog(`[qqbot-api:${normalizedAppId}] Token cache cleared manually.`);
} else {
tokenCacheMap.clear();
debugLog(`[qqbot-api] All token caches cleared.`);
}
}
/** Return token-cache status for diagnostics. */
export function getTokenStatus(appId: string): {
status: "valid" | "expired" | "refreshing" | "none";
expiresAt: number | null;
} {
if (tokenFetchPromises.has(appId)) {
return { status: "refreshing", expiresAt: tokenCacheMap.get(appId)?.expiresAt ?? null };
}
const cached = tokenCacheMap.get(appId);
if (!cached) {
return { status: "none", expiresAt: null };
}
const remaining = cached.expiresAt - Date.now();
const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3);
return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt };
}
/** Generate a message sequence in the 0..65535 range. */
export function getNextMsgSeq(_msgId: string): number {
const timePart = Date.now() % 100000000;
const random = Math.floor(Math.random() * 65536);
return (timePart ^ random) % 65536;
}
const DEFAULT_API_TIMEOUT = 30000;
const FILE_UPLOAD_TIMEOUT = 120000;
/** Shared API request wrapper. */
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",
"User-Agent": PLUGIN_USER_AGENT,
};
const isFileUpload = path.includes("/files");
const timeout = timeoutMs ?? (isFileUpload ? FILE_UPLOAD_TIMEOUT : DEFAULT_API_TIMEOUT);
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);
}
debugLog(`[qqbot-api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
if (body) {
const logBody = { ...body } as Record<string, unknown>;
if (typeof logBody.file_data === "string") {
logBody.file_data = `<base64 ${(logBody.file_data as string).length} chars>`;
}
debugLog(`[qqbot-api] >>> Body:`, JSON.stringify(logBody));
}
let res: Response;
try {
res = await fetch(url, options);
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === "AbortError") {
debugError(`[qqbot-api] <<< Request timeout after ${timeout}ms`);
throw new Error(`Request timeout[${path}]: exceeded ${timeout}ms`);
}
debugError(`[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;
});
const traceId = res.headers.get("x-tps-trace-id") ?? "";
debugLog(
`[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`,
);
let data: T;
let rawBody: string;
try {
rawBody = await res.text();
debugLog(`[qqbot-api] <<< Body:`, rawBody);
data = JSON.parse(rawBody) as T;
} catch (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;
}
// Upload retry with exponential backoff.
const UPLOAD_MAX_RETRIES = 2;
const UPLOAD_BASE_DELAY_MS = 1000;
async function apiRequestWithRetry<T = unknown>(
accessToken: string,
method: string,
path: string,
body?: unknown,
maxRetries = UPLOAD_MAX_RETRIES,
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await apiRequest<T>(accessToken, method, path, body);
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
const errMsg = lastError.message;
if (
errMsg.includes("400") ||
errMsg.includes("401") ||
errMsg.includes("Invalid") ||
errMsg.includes("upload timeout") ||
errMsg.includes("timeout") ||
errMsg.includes("Timeout")
) {
throw lastError;
}
if (attempt < maxRetries) {
const delay = UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
debugLog(
`[qqbot-api] Upload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${errMsg.slice(0, 100)}`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError!;
}
export async function getGatewayUrl(accessToken: string): Promise<string> {
const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
return data.url;
}
// Message sending.
export interface MessageResponse {
id: string;
timestamp: number | string;
ext_info?: {
ref_idx?: string;
};
}
/**
* Send a message and invoke the refIdx hook when QQ returns one.
*/
async function sendAndNotify(
appId: string,
accessToken: string,
method: string,
path: string,
body: unknown,
meta: OutboundMeta,
): Promise<MessageResponse> {
const result = await apiRequest<MessageResponse>(accessToken, method, path, body);
const hook = onMessageSentHookMap.get(String(appId).trim());
if (result.ext_info?.ref_idx && hook) {
try {
hook(result.ext_info.ref_idx, meta);
} catch (err) {
debugError(`[qqbot-api:${appId}] onMessageSent hook error: ${err}`);
}
}
return result;
}
function buildMessageBody(
appId: string,
content: string,
msgId: string | undefined,
msgSeq: number,
messageReference?: string,
): Record<string, unknown> {
const md = isMarkdownSupport(appId);
const body: Record<string, unknown> = md
? {
markdown: { content },
msg_type: 2,
msg_seq: msgSeq,
}
: {
content,
msg_type: 0,
msg_seq: msgSeq,
};
if (msgId) {
body.msg_id = msgId;
}
if (messageReference && !md) {
body.message_reference = { message_id: messageReference };
}
return body;
}
export async function sendC2CMessage(
appId: string,
accessToken: string,
openid: string,
content: string,
msgId?: string,
messageReference?: string,
): Promise<MessageResponse> {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
const body = buildMessageBody(appId, content, msgId, msgSeq, messageReference);
return sendAndNotify(appId, accessToken, "POST", `/v2/users/${openid}/messages`, body, {
text: content,
});
}
export async function sendC2CInputNotify(
accessToken: string,
openid: string,
msgId?: string,
inputSecond: number = 60,
): Promise<{ refIdx?: string }> {
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 } : {}),
};
const response = await apiRequest<{ ext_info?: { ref_idx?: string } }>(
accessToken,
"POST",
`/v2/users/${openid}/messages`,
body,
);
return { refIdx: response.ext_info?.ref_idx };
}
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 } : {}),
});
}
/** Send a direct-message payload inside a guild DM session. */
export async function sendDmMessage(
accessToken: string,
guildId: string,
content: string,
msgId?: string,
): Promise<{ id: string; timestamp: string }> {
return apiRequest(accessToken, "POST", `/dms/${guildId}/messages`, {
content,
...(msgId ? { msg_id: msgId } : {}),
});
}
export async function sendGroupMessage(
appId: string,
accessToken: string,
groupOpenid: string,
content: string,
msgId?: string,
): Promise<MessageResponse> {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
const body = buildMessageBody(appId, content, msgId, msgSeq);
return sendAndNotify(appId, accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, {
text: content,
});
}
function buildProactiveMessageBody(appId: string, content: string): Record<string, unknown> {
if (!content || content.trim().length === 0) {
throw new Error("Proactive message content must not be empty (markdown.content is empty)");
}
if (isMarkdownSupport(appId)) {
return { markdown: { content }, msg_type: 2 };
} else {
return { content, msg_type: 0 };
}
}
export async function sendProactiveC2CMessage(
appId: string,
accessToken: string,
openid: string,
content: string,
): Promise<MessageResponse> {
const body = buildProactiveMessageBody(appId, content);
return sendAndNotify(appId, accessToken, "POST", `/v2/users/${openid}/messages`, body, {
text: content,
});
}
export async function sendProactiveGroupMessage(
appId: string,
accessToken: string,
groupOpenid: string,
content: string,
): Promise<{ id: string; timestamp: string }> {
const body = buildProactiveMessageBody(appId, content);
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
}
// Rich media message support.
export enum MediaFileType {
IMAGE = 1,
VIDEO = 2,
VOICE = 3,
FILE = 4,
}
export interface UploadMediaResponse {
file_uuid: string;
file_info: string;
ttl: number;
id?: string;
}
export async function uploadC2CMedia(
accessToken: string,
openid: string,
fileType: MediaFileType,
url?: string,
fileData?: string,
srvSendMsg = false,
fileName?: string,
): Promise<UploadMediaResponse> {
if (!url && !fileData) throw new Error("uploadC2CMedia: url or fileData is required");
if (fileData) {
const contentHash = computeFileHash(fileData);
const cachedInfo = getCachedFileInfo(contentHash, "c2c", openid, fileType);
if (cachedInfo) {
return { file_uuid: "", file_info: cachedInfo, ttl: 0 };
}
}
const body: Record<string, unknown> = { file_type: fileType, srv_send_msg: srvSendMsg };
if (url) body.url = url;
else if (fileData) body.file_data = fileData;
if (fileType === MediaFileType.FILE && fileName) body.file_name = sanitizeFileName(fileName);
const result = await apiRequestWithRetry<UploadMediaResponse>(
accessToken,
"POST",
`/v2/users/${openid}/files`,
body,
);
if (fileData && result.file_info && result.ttl > 0) {
const contentHash = computeFileHash(fileData);
setCachedFileInfo(
contentHash,
"c2c",
openid,
fileType,
result.file_info,
result.file_uuid,
result.ttl,
);
}
return result;
}
export async function uploadGroupMedia(
accessToken: string,
groupOpenid: string,
fileType: MediaFileType,
url?: string,
fileData?: string,
srvSendMsg = false,
fileName?: string,
): Promise<UploadMediaResponse> {
if (!url && !fileData) throw new Error("uploadGroupMedia: url or fileData is required");
if (fileData) {
const contentHash = computeFileHash(fileData);
const cachedInfo = getCachedFileInfo(contentHash, "group", groupOpenid, fileType);
if (cachedInfo) {
return { file_uuid: "", file_info: cachedInfo, ttl: 0 };
}
}
const body: Record<string, unknown> = { file_type: fileType, srv_send_msg: srvSendMsg };
if (url) body.url = url;
else if (fileData) body.file_data = fileData;
if (fileType === MediaFileType.FILE && fileName) body.file_name = sanitizeFileName(fileName);
const result = await apiRequestWithRetry<UploadMediaResponse>(
accessToken,
"POST",
`/v2/groups/${groupOpenid}/files`,
body,
);
if (fileData && result.file_info && result.ttl > 0) {
const contentHash = computeFileHash(fileData);
setCachedFileInfo(
contentHash,
"group",
groupOpenid,
fileType,
result.file_info,
result.file_uuid,
result.ttl,
);
}
return result;
}
export async function sendC2CMediaMessage(
appId: string,
accessToken: string,
openid: string,
fileInfo: string,
msgId?: string,
content?: string,
meta?: OutboundMeta,
): Promise<MessageResponse> {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
return sendAndNotify(
appId,
accessToken,
"POST",
`/v2/users/${openid}/messages`,
{
msg_type: 7,
media: { file_info: fileInfo },
msg_seq: msgSeq,
...(content ? { content } : {}),
...(msgId ? { msg_id: msgId } : {}),
},
meta ?? { text: content },
);
}
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 } : {}),
});
}
export async function sendC2CImageMessage(
appId: string,
accessToken: string,
openid: string,
imageUrl: string,
msgId?: string,
content?: string,
localPath?: string,
): Promise<MessageResponse> {
let uploadResult: UploadMediaResponse;
const isBase64 = imageUrl.startsWith("data:");
if (isBase64) {
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) throw new Error("Invalid Base64 Data URL format");
uploadResult = await uploadC2CMedia(
accessToken,
openid,
MediaFileType.IMAGE,
undefined,
matches[2],
false,
);
} else {
uploadResult = await uploadC2CMedia(
accessToken,
openid,
MediaFileType.IMAGE,
imageUrl,
undefined,
false,
);
}
const meta: OutboundMeta = {
text: content,
mediaType: "image",
...(!isBase64 ? { mediaUrl: imageUrl } : {}),
...(localPath ? { mediaLocalPath: localPath } : {}),
};
return sendC2CMediaMessage(
appId,
accessToken,
openid,
uploadResult.file_info,
msgId,
content,
meta,
);
}
export async function sendGroupImageMessage(
appId: string,
accessToken: string,
groupOpenid: string,
imageUrl: string,
msgId?: string,
content?: string,
): Promise<{ id: string; timestamp: string }> {
let uploadResult: UploadMediaResponse;
const isBase64 = imageUrl.startsWith("data:");
if (isBase64) {
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) throw new Error("Invalid Base64 Data URL format");
uploadResult = await uploadGroupMedia(
accessToken,
groupOpenid,
MediaFileType.IMAGE,
undefined,
matches[2],
false,
);
} else {
uploadResult = await uploadGroupMedia(
accessToken,
groupOpenid,
MediaFileType.IMAGE,
imageUrl,
undefined,
false,
);
}
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
}
export async function sendC2CVoiceMessage(
appId: string,
accessToken: string,
openid: string,
voiceBase64?: string,
voiceUrl?: string,
msgId?: string,
ttsText?: string,
filePath?: string,
): Promise<MessageResponse> {
const uploadResult = await uploadC2CMedia(
accessToken,
openid,
MediaFileType.VOICE,
voiceUrl,
voiceBase64,
false,
);
return sendC2CMediaMessage(appId, accessToken, openid, uploadResult.file_info, msgId, undefined, {
mediaType: "voice",
...(ttsText ? { ttsText } : {}),
...(filePath ? { mediaLocalPath: filePath } : {}),
});
}
export async function sendGroupVoiceMessage(
appId: string,
accessToken: string,
groupOpenid: string,
voiceBase64?: string,
voiceUrl?: string,
msgId?: string,
): Promise<{ id: string; timestamp: string }> {
const uploadResult = await uploadGroupMedia(
accessToken,
groupOpenid,
MediaFileType.VOICE,
voiceUrl,
voiceBase64,
false,
);
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId);
}
export async function sendC2CFileMessage(
appId: string,
accessToken: string,
openid: string,
fileBase64?: string,
fileUrl?: string,
msgId?: string,
fileName?: string,
localFilePath?: string,
): Promise<MessageResponse> {
const uploadResult = await uploadC2CMedia(
accessToken,
openid,
MediaFileType.FILE,
fileUrl,
fileBase64,
false,
fileName,
);
return sendC2CMediaMessage(appId, accessToken, openid, uploadResult.file_info, msgId, undefined, {
mediaType: "file",
mediaUrl: fileUrl,
mediaLocalPath: localFilePath ?? fileName,
});
}
export async function sendGroupFileMessage(
appId: string,
accessToken: string,
groupOpenid: string,
fileBase64?: string,
fileUrl?: string,
msgId?: string,
fileName?: string,
): Promise<{ id: string; timestamp: string }> {
const uploadResult = await uploadGroupMedia(
accessToken,
groupOpenid,
MediaFileType.FILE,
fileUrl,
fileBase64,
false,
fileName,
);
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId);
}
export async function sendC2CVideoMessage(
appId: string,
accessToken: string,
openid: string,
videoUrl?: string,
videoBase64?: string,
msgId?: string,
content?: string,
localPath?: string,
): Promise<MessageResponse> {
const uploadResult = await uploadC2CMedia(
accessToken,
openid,
MediaFileType.VIDEO,
videoUrl,
videoBase64,
false,
);
return sendC2CMediaMessage(appId, accessToken, openid, uploadResult.file_info, msgId, content, {
text: content,
mediaType: "video",
...(videoUrl ? { mediaUrl: videoUrl } : {}),
...(localPath ? { mediaLocalPath: localPath } : {}),
});
}
export async function sendGroupVideoMessage(
appId: string,
accessToken: string,
groupOpenid: string,
videoUrl?: string,
videoBase64?: string,
msgId?: string,
content?: string,
): Promise<{ id: string; timestamp: string }> {
const uploadResult = await uploadGroupMedia(
accessToken,
groupOpenid,
MediaFileType.VIDEO,
videoUrl,
videoBase64,
false,
);
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
}
// Background token refresh, isolated per appId.
interface BackgroundTokenRefreshOptions {
refreshAheadMs?: number;
randomOffsetMs?: number;
minRefreshIntervalMs?: number;
retryDelayMs?: number;
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
}
const backgroundRefreshControllers = new Map<string, AbortController>();
export function startBackgroundTokenRefresh(
appId: string,
clientSecret: string,
options?: BackgroundTokenRefreshOptions,
): void {
if (backgroundRefreshControllers.has(appId)) {
debugLog(`[qqbot-api:${appId}] Background token refresh already running`);
return;
}
const {
refreshAheadMs = 5 * 60 * 1000,
randomOffsetMs = 30 * 1000,
minRefreshIntervalMs = 60 * 1000,
retryDelayMs = 5 * 1000,
log,
} = options ?? {};
const controller = new AbortController();
backgroundRefreshControllers.set(appId, controller);
const signal = controller.signal;
const refreshLoop = async () => {
log?.info?.(`[qqbot-api:${appId}] Background token refresh started`);
while (!signal.aborted) {
try {
await getAccessToken(appId, clientSecret);
const cached = tokenCacheMap.get(appId);
if (cached) {
const expiresIn = cached.expiresAt - Date.now();
const randomOffset = Math.random() * randomOffsetMs;
const refreshIn = Math.max(
expiresIn - refreshAheadMs - randomOffset,
minRefreshIntervalMs,
);
log?.debug?.(
`[qqbot-api:${appId}] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`,
);
await sleep(refreshIn, signal);
} else {
log?.debug?.(`[qqbot-api:${appId}] No cached token, retrying soon`);
await sleep(minRefreshIntervalMs, signal);
}
} catch (err) {
if (signal.aborted) break;
log?.error?.(`[qqbot-api:${appId}] Background token refresh failed: ${err}`);
await sleep(retryDelayMs, signal);
}
}
backgroundRefreshControllers.delete(appId);
log?.info?.(`[qqbot-api:${appId}] Background token refresh stopped`);
};
refreshLoop().catch((err) => {
backgroundRefreshControllers.delete(appId);
log?.error?.(`[qqbot-api:${appId}] Background token refresh crashed: ${err}`);
});
}
/**
* Stop background token refresh.
* @param appId Optional appId to stop a single account instead of all refresh loops.
*/
export function stopBackgroundTokenRefresh(appId?: string): void {
if (appId) {
const controller = backgroundRefreshControllers.get(appId);
if (controller) {
controller.abort();
backgroundRefreshControllers.delete(appId);
}
} else {
for (const controller of backgroundRefreshControllers.values()) {
controller.abort();
}
backgroundRefreshControllers.clear();
}
}
export function isBackgroundTokenRefreshRunning(appId?: string): boolean {
if (appId) return backgroundRefreshControllers.has(appId);
return backgroundRefreshControllers.size > 0;
}
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 });
}
});
}