@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqbot",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
28
src/api.ts
28
src/api.ts
@@ -160,3 +160,31 @@ export async function sendGroupMessage(
|
||||
...(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,
|
||||
});
|
||||
}
|
||||
|
||||
114
src/gateway.ts
114
src/gateway.ts
@@ -13,6 +13,8 @@ const INTENTS = {
|
||||
// 重连配置
|
||||
const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
|
||||
const MAX_RECONNECT_ATTEMPTS = 100;
|
||||
const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值
|
||||
const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开
|
||||
|
||||
export interface GatewayContext {
|
||||
account: ResolvedQQBotAccount;
|
||||
@@ -43,6 +45,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let lastSeq: number | null = null;
|
||||
let lastConnectTime: number = 0; // 上次连接成功的时间
|
||||
let quickDisconnectCount = 0; // 连续快速断开次数
|
||||
|
||||
abortSignal.addEventListener("abort", () => {
|
||||
isAborted = true;
|
||||
@@ -109,8 +113,12 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
groupOpenid?: string;
|
||||
attachments?: Array<{ content_type: string; url: string; filename?: string }>;
|
||||
}) => {
|
||||
log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`);
|
||||
if (event.attachments?.length) {
|
||||
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
||||
}
|
||||
|
||||
pluginRuntime.channel.activity.record({
|
||||
channel: "qqbot",
|
||||
@@ -141,7 +149,23 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
if (account.systemPrompt) {
|
||||
systemPrompts.push(account.systemPrompt);
|
||||
}
|
||||
const messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${event.content}`;
|
||||
|
||||
// 处理附件(图片等)
|
||||
let attachmentInfo = "";
|
||||
const imageUrls: string[] = [];
|
||||
if (event.attachments?.length) {
|
||||
for (const att of event.attachments) {
|
||||
if (att.content_type?.startsWith("image/")) {
|
||||
imageUrls.push(att.url);
|
||||
attachmentInfo += `\n[图片: ${att.url}]`;
|
||||
} else {
|
||||
attachmentInfo += `\n[附件: ${att.filename ?? att.content_type}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userContent = event.content + attachmentInfo;
|
||||
const messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`;
|
||||
|
||||
const body = pluginRuntime.channel.reply.formatInboundEnvelope({
|
||||
channel: "QQBot",
|
||||
@@ -154,6 +178,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
name: event.senderName,
|
||||
},
|
||||
envelope: envelopeOptions,
|
||||
// 传递图片 URL 列表
|
||||
...(imageUrls.length > 0 ? { imageUrls } : {}),
|
||||
});
|
||||
|
||||
const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
|
||||
@@ -183,17 +209,37 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
QQGroupOpenid: event.groupOpenid,
|
||||
});
|
||||
|
||||
// 发送消息的辅助函数,带 token 过期重试
|
||||
const sendWithTokenRetry = async (sendFn: (token: string) => Promise<unknown>) => {
|
||||
try {
|
||||
const token = await getAccessToken(account.appId, account.clientSecret);
|
||||
await sendFn(token);
|
||||
} catch (err) {
|
||||
const errMsg = String(err);
|
||||
// 如果是 token 相关错误,清除缓存重试一次
|
||||
if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
|
||||
log?.info(`[qqbot:${account.accountId}] Token may be expired, refreshing...`);
|
||||
clearTokenCache();
|
||||
const newToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
await sendFn(newToken);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 发送错误提示的辅助函数
|
||||
const sendErrorMessage = async (errorText: string) => {
|
||||
try {
|
||||
const token = await getAccessToken(account.appId, account.clientSecret);
|
||||
if (event.type === "c2c") {
|
||||
await sendC2CMessage(token, event.senderId, errorText, event.messageId);
|
||||
} else if (event.type === "group" && event.groupOpenid) {
|
||||
await sendGroupMessage(token, event.groupOpenid, errorText, event.messageId);
|
||||
} else if (event.channelId) {
|
||||
await sendChannelMessage(token, event.channelId, errorText, event.messageId);
|
||||
}
|
||||
await sendWithTokenRetry(async (token) => {
|
||||
if (event.type === "c2c") {
|
||||
await sendC2CMessage(token, event.senderId, errorText, event.messageId);
|
||||
} else if (event.type === "group" && event.groupOpenid) {
|
||||
await sendGroupMessage(token, event.groupOpenid, errorText, event.messageId);
|
||||
} else if (event.channelId) {
|
||||
await sendChannelMessage(token, event.channelId, errorText, event.messageId);
|
||||
}
|
||||
});
|
||||
} catch (sendErr) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to send error message: ${sendErr}`);
|
||||
}
|
||||
@@ -202,9 +248,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
try {
|
||||
const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
||||
|
||||
// 每次发消息前刷新 token
|
||||
const freshToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
|
||||
// 追踪是否有响应
|
||||
let hasResponse = false;
|
||||
const responseTimeout = 30000; // 30秒超时
|
||||
@@ -234,22 +277,27 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
if (!replyText.trim()) return;
|
||||
|
||||
// 处理回复内容,避免被 QQ 识别为 URL
|
||||
// 把文件扩展名中的点替换为下划线,如 README.md -> README_md
|
||||
const originalText = replyText;
|
||||
replyText = replyText.replace(/(\w+)\.(\w{2,4})\b/g, "$1_$2");
|
||||
|
||||
// 把所有可能被识别为 URL 的点替换为下划线
|
||||
// 匹配:字母/数字.字母/数字 的模式
|
||||
replyText = replyText.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
||||
|
||||
const hasReplacement = replyText !== originalText;
|
||||
if (hasReplacement) {
|
||||
replyText += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
||||
}
|
||||
|
||||
try {
|
||||
if (event.type === "c2c") {
|
||||
await sendC2CMessage(freshToken, event.senderId, replyText, event.messageId);
|
||||
} else if (event.type === "group" && event.groupOpenid) {
|
||||
await sendGroupMessage(freshToken, event.groupOpenid, replyText, event.messageId);
|
||||
} else if (event.channelId) {
|
||||
await sendChannelMessage(freshToken, event.channelId, replyText, event.messageId);
|
||||
}
|
||||
await sendWithTokenRetry(async (token) => {
|
||||
if (event.type === "c2c") {
|
||||
await sendC2CMessage(token, event.senderId, replyText, event.messageId);
|
||||
} else if (event.type === "group" && event.groupOpenid) {
|
||||
await sendGroupMessage(token, event.groupOpenid, replyText, event.messageId);
|
||||
} else if (event.channelId) {
|
||||
await sendChannelMessage(token, event.channelId, replyText, event.messageId);
|
||||
}
|
||||
});
|
||||
log?.info(`[qqbot:${account.accountId}] Sent reply`);
|
||||
|
||||
pluginRuntime.channel.activity.record({
|
||||
@@ -301,6 +349,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
ws.on("open", () => {
|
||||
log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
|
||||
reconnectAttempts = 0; // 连接成功,重置重试计数
|
||||
lastConnectTime = Date.now(); // 记录连接时间
|
||||
});
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
@@ -366,6 +415,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
content: event.content,
|
||||
messageId: event.id,
|
||||
timestamp: event.timestamp,
|
||||
attachments: event.attachments,
|
||||
});
|
||||
} else if (t === "AT_MESSAGE_CREATE") {
|
||||
const event = d as GuildMessageEvent;
|
||||
@@ -378,6 +428,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
timestamp: event.timestamp,
|
||||
channelId: event.channel_id,
|
||||
guildId: event.guild_id,
|
||||
attachments: event.attachments,
|
||||
});
|
||||
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
||||
const event = d as GuildMessageEvent;
|
||||
@@ -389,6 +440,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
messageId: event.id,
|
||||
timestamp: event.timestamp,
|
||||
guildId: event.guild_id,
|
||||
attachments: event.attachments,
|
||||
});
|
||||
} else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
||||
const event = d as GroupMessageEvent;
|
||||
@@ -399,6 +451,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
messageId: event.id,
|
||||
timestamp: event.timestamp,
|
||||
groupOpenid: event.group_openid,
|
||||
attachments: event.attachments,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -431,6 +484,25 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
|
||||
ws.on("close", (code, reason) => {
|
||||
log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
|
||||
|
||||
// 检测是否是快速断开(连接后很快就断了)
|
||||
const connectionDuration = Date.now() - lastConnectTime;
|
||||
if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && lastConnectTime > 0) {
|
||||
quickDisconnectCount++;
|
||||
log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`);
|
||||
|
||||
// 如果连续快速断开超过阈值,清除 session 重新 identify
|
||||
if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
|
||||
log?.info(`[qqbot:${account.accountId}] Too many quick disconnects, clearing session to re-identify`);
|
||||
sessionId = null;
|
||||
lastSeq = null;
|
||||
quickDisconnectCount = 0;
|
||||
}
|
||||
} else {
|
||||
// 连接持续时间够长,重置计数
|
||||
quickDisconnectCount = 0;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
// 非正常关闭则重连
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import { getAccessToken, sendC2CMessage, sendChannelMessage } from "./api.js";
|
||||
import {
|
||||
getAccessToken,
|
||||
sendC2CMessage,
|
||||
sendChannelMessage,
|
||||
sendGroupMessage,
|
||||
sendProactiveC2CMessage,
|
||||
sendProactiveGroupMessage,
|
||||
} from "./api.js";
|
||||
|
||||
export interface OutboundContext {
|
||||
to: string;
|
||||
@@ -17,7 +24,30 @@ export interface OutboundResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息
|
||||
* 解析目标地址
|
||||
* 格式:
|
||||
* - openid (32位十六进制) -> C2C 单聊
|
||||
* - group:xxx -> 群聊
|
||||
* - channel:xxx -> 频道
|
||||
* - 纯数字 -> 频道
|
||||
*/
|
||||
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
|
||||
if (to.startsWith("group:")) {
|
||||
return { type: "group", id: to.slice(6) };
|
||||
}
|
||||
if (to.startsWith("channel:")) {
|
||||
return { type: "channel", id: to.slice(8) };
|
||||
}
|
||||
// openid 通常是 32 位十六进制
|
||||
if (/^[A-F0-9]{32}$/i.test(to)) {
|
||||
return { type: "c2c", id: to };
|
||||
}
|
||||
// 默认当作频道 ID
|
||||
return { type: "channel", id: to };
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息(被动回复,需要 replyToId)
|
||||
*/
|
||||
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
const { to, text, replyToId, account } = ctx;
|
||||
@@ -28,16 +58,53 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
const target = parseTarget(to);
|
||||
|
||||
// 判断目标类型:openid (C2C) 或 channel_id (频道)
|
||||
// openid 通常是 32 位十六进制,channel_id 通常是数字
|
||||
const isC2C = /^[A-F0-9]{32}$/i.test(to);
|
||||
|
||||
if (isC2C) {
|
||||
const result = await sendC2CMessage(accessToken, to, text, replyToId ?? undefined);
|
||||
if (target.type === "c2c") {
|
||||
const result = await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
} else if (target.type === "group") {
|
||||
const result = await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
} else {
|
||||
const result = await sendChannelMessage(accessToken, to, text, replyToId ?? undefined);
|
||||
const result = await sendChannelMessage(accessToken, target.id, text, replyToId ?? undefined);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { channel: "qqbot", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送消息(不需要 replyToId,有配额限制:每月 4 条/用户/群)
|
||||
*
|
||||
* @param account - 账户配置
|
||||
* @param to - 目标地址,格式:openid(单聊)或 group:xxx(群聊)
|
||||
* @param text - 消息内容
|
||||
*/
|
||||
export async function sendProactiveMessage(
|
||||
account: ResolvedQQBotAccount,
|
||||
to: string,
|
||||
text: string
|
||||
): Promise<OutboundResult> {
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
const target = parseTarget(to);
|
||||
|
||||
if (target.type === "c2c") {
|
||||
const result = await sendProactiveC2CMessage(accessToken, target.id, text);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
} else if (target.type === "group") {
|
||||
const result = await sendProactiveGroupMessage(accessToken, target.id, text);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
} else {
|
||||
// 频道暂不支持主动消息,使用普通发送
|
||||
const result = await sendChannelMessage(accessToken, target.id, text);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
15
src/types.ts
15
src/types.ts
@@ -37,6 +37,18 @@ export interface QQBotAccountConfig {
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 富媒体附件
|
||||
*/
|
||||
export interface MessageAttachment {
|
||||
content_type: string; // 如 "image/png"
|
||||
filename?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
size?: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* C2C 消息事件
|
||||
*/
|
||||
@@ -52,6 +64,7 @@ export interface C2CMessageEvent {
|
||||
message_scene?: {
|
||||
source: string;
|
||||
};
|
||||
attachments?: MessageAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +85,7 @@ export interface GuildMessageEvent {
|
||||
nick?: string;
|
||||
joined_at?: string;
|
||||
};
|
||||
attachments?: MessageAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +101,7 @@ export interface GroupMessageEvent {
|
||||
timestamp: string;
|
||||
group_id: string;
|
||||
group_openid: string;
|
||||
attachments?: MessageAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user