Files
qqbot/src/api.ts

399 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* QQ Bot API 鉴权和请求封装
*/
const API_BASE = "https://api.sgroup.qq.com";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
let cachedToken: { token: string; expiresAt: number } | null = null;
/**
* 获取 AccessToken带缓存
*/
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
// 检查缓存,提前 5 分钟刷新
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
return cachedToken.token;
}
let response: Response;
try {
response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ appId, clientSecret }),
});
} catch (err) {
throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
}
let data: { access_token?: string; expires_in?: number };
try {
data = (await response.json()) as { access_token?: string; expires_in?: number };
} catch (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,
};
return cachedToken.token;
}
/**
* 清除 Token 缓存
*/
export function clearTokenCache(): void {
cachedToken = null;
}
/**
* 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 请求封装
*/
export async function apiRequest<T = unknown>(
accessToken: string,
method: string,
path: string,
body?: unknown
): Promise<T> {
const url = `${API_BASE}${path}`;
const options: RequestInit = {
method,
headers: {
Authorization: `QQBot ${accessToken}`,
"Content-Type": "application/json",
},
};
if (body) {
options.body = JSON.stringify(body);
}
let res: Response;
try {
res = await fetch(url, options);
} catch (err) {
throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
}
let data: T;
try {
data = (await res.json()) 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;
}
/**
* 获取 WebSocket Gateway URL
*/
export async function getGatewayUrl(accessToken: string): Promise<string> {
const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
return data.url;
}
/**
* 发送 C2C 单聊消息
*/
export async function sendC2CMessage(
accessToken: string,
openid: string,
content: string,
msgId?: string
): Promise<{ id: string; timestamp: number }> {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
content,
msg_type: 0,
msg_seq: msgSeq,
...(msgId ? { msg_id: msgId } : {}),
});
}
/**
* 发送 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<{ id: string; timestamp: string }> {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
content,
msg_type: 0,
msg_seq: msgSeq,
...(msgId ? { msg_id: msgId } : {}),
});
}
/**
* 主动发送 C2C 单聊消息(不需要 msg_id每月限 4 条/用户)
*/
export async function sendProactiveC2CMessage(
accessToken: string,
openid: string,
content: string
): Promise<{ id: string; timestamp: number }> {
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
content,
msg_type: 0,
});
}
/**
* 主动发送群聊消息(不需要 msg_id每月限 4 条/群)
*/
export async function sendProactiveGroupMessage(
accessToken: string,
groupOpenid: string,
content: string
): Promise<{ id: string; timestamp: string }> {
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
content,
msg_type: 0,
});
}
// ============ 富媒体消息支持 ============
/**
* 媒体文件类型
*/
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 accessToken 访问令牌
* @param openid 用户 openid
* @param fileType 文件类型
* @param url 媒体资源 URL
* @param srvSendMsg 是否直接发送(推荐 false获取 file_info 后再发送)
*/
export async function uploadC2CMedia(
accessToken: string,
openid: string,
fileType: MediaFileType,
url: string,
srvSendMsg = false
): Promise<UploadMediaResponse> {
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, {
file_type: fileType,
url,
srv_send_msg: srvSendMsg,
});
}
/**
* 上传富媒体文件到群聊
* @param accessToken 访问令牌
* @param groupOpenid 群 openid
* @param fileType 文件类型
* @param url 媒体资源 URL
* @param srvSendMsg 是否直接发送(推荐 false获取 file_info 后再发送)
*/
export async function uploadGroupMedia(
accessToken: string,
groupOpenid: string,
fileType: MediaFileType,
url: string,
srvSendMsg = false
): Promise<UploadMediaResponse> {
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, {
file_type: fileType,
url,
srv_send_msg: srvSendMsg,
});
}
/**
* 发送 C2C 单聊富媒体消息
* @param accessToken 访问令牌
* @param openid 用户 openid
* @param fileInfo 从 uploadC2CMedia 获取的 file_info
* @param msgId 被动回复时需要的消息 ID
* @param content 可选的文字内容
*/
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 } : {}),
});
}
/**
* 发送群聊富媒体消息
* @param accessToken 访问令牌
* @param groupOpenid 群 openid
* @param fileInfo 从 uploadGroupMedia 获取的 file_info
* @param msgId 被动回复时需要的消息 ID
* @param 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 } : {}),
});
}
/**
* 发送带图片的 C2C 单聊消息(封装上传+发送)
* @param accessToken 访问令牌
* @param openid 用户 openid
* @param imageUrl 图片 URL
* @param msgId 被动回复时需要的消息 ID
* @param content 可选的文字内容
*/
export async function sendC2CImageMessage(
accessToken: string,
openid: string,
imageUrl: string,
msgId?: string,
content?: string
): Promise<{ id: string; timestamp: number }> {
// 先上传图片获取 file_info
const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, false);
// 再发送富媒体消息
return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
}
/**
* 发送带图片的群聊消息(封装上传+发送)
* @param accessToken 访问令牌
* @param groupOpenid 群 openid
* @param imageUrl 图片 URL
* @param msgId 被动回复时需要的消息 ID
* @param content 可选的文字内容
*/
export async function sendGroupImageMessage(
accessToken: string,
groupOpenid: string,
imageUrl: string,
msgId?: string,
content?: string
): Promise<{ id: string; timestamp: string }> {
// 先上传图片获取 file_info
console.log(`[qqbot-api] sendGroupImageMessage: uploading image from URL: ${imageUrl}`);
const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, false);
console.log(`[qqbot-api] sendGroupImageMessage: upload success, file_info: ${uploadResult.file_info?.slice(0, 50)}...`);
// 再发送富媒体消息
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
}