Merge pull request #7 from sliverp/feature/group

Feature/group
This commit is contained in:
Bijin
2026-01-29 16:46:25 +08:00
committed by GitHub
5 changed files with 213 additions and 31 deletions

View File

@@ -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",

View File

@@ -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,
});
}

View File

@@ -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();
// 非正常关闭则重连

View File

@@ -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) {

View File

@@ -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[];
}
/**