diff --git a/package.json b/package.json index c04cb83..5ecc185 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api.ts b/src/api.ts index 70ccf43..2ce7767 100644 --- a/src/api.ts +++ b/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, + }); +} diff --git a/src/gateway.ts b/src/gateway.ts index 0dca038..e0611b5 100644 --- a/src/gateway.ts +++ b/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 { let heartbeatInterval: ReturnType | 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 { 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 { 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 { 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 { QQGroupOpenid: event.groupOpenid, }); + // 发送消息的辅助函数,带 token 过期重试 + const sendWithTokenRetry = async (sendFn: (token: string) => Promise) => { + 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(); // 非正常关闭则重连 diff --git a/src/outbound.ts b/src/outbound.ts index 3747792..a8f9809 100644 --- a/src/outbound.ts +++ b/src/outbound.ts @@ -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 { const { to, text, replyToId, account } = ctx; @@ -28,16 +58,53 @@ export async function sendText(ctx: OutboundContext): Promise { 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 { + 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) { diff --git a/src/types.ts b/src/types.ts index 4ef6ce3..69e1cc1 100644 --- a/src/types.ts +++ b/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[]; } /**