mirror of https://github.com/openclaw/openclaw.git
992 lines
27 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
}
|