diff --git a/index.ts b/index.ts index 9bfaa73..8b602fc 100644 --- a/index.ts +++ b/index.ts @@ -16,6 +16,7 @@ export default plugin; export { qqbotPlugin } from "./src/channel.js"; export { setQQBotRuntime, getQQBotRuntime } from "./src/runtime.js"; +export { qqbotOnboardingAdapter } from "./src/onboarding.js"; export * from "./src/types.js"; export * from "./src/api.js"; export * from "./src/config.js"; diff --git a/src/api.ts b/src/api.ts index ba6ef22..70ccf43 100644 --- a/src/api.ts +++ b/src/api.ts @@ -43,6 +43,32 @@ export function clearTokenCache(): void { cachedToken = null; } +/** + * msg_seq 追踪器 - 用于对同一条消息的多次回复 + * key: msg_id, value: 当前 seq 值 + */ +const msgSeqTracker = new Map(); + +/** + * 获取并递增消息序号 + */ +export function getNextMsgSeq(msgId: string): number { + const current = msgSeqTracker.get(msgId) ?? 0; + const next = current + 1; + msgSeqTracker.set(msgId, next); + + // 清理过期的序号(超过 5 次或 60 分钟后无意义) + // 简单策略:保留最近 1000 条 + if (msgSeqTracker.size > 1000) { + const keys = Array.from(msgSeqTracker.keys()); + for (let i = 0; i < 500; i++) { + msgSeqTracker.delete(keys[i]); + } + } + + return next; +} + /** * API 请求封装 */ @@ -93,9 +119,11 @@ export async function sendC2CMessage( 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 } : {}), }); } @@ -124,9 +152,11 @@ export async function sendGroupMessage( 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 } : {}), }); } diff --git a/src/channel.ts b/src/channel.ts index ba4ab70..c0ef685 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -3,6 +3,7 @@ import type { ResolvedQQBotAccount } from "./types.js"; import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js"; import { sendText } from "./outbound.js"; import { startGateway } from "./gateway.js"; +import { qqbotOnboardingAdapter } from "./onboarding.js"; const DEFAULT_ACCOUNT_ID = "default"; @@ -23,6 +24,8 @@ export const qqbotPlugin: ChannelPlugin = { threads: false, }, reload: { configPrefixes: ["channels.qqbot"] }, + // CLI onboarding wizard + onboarding: qqbotOnboardingAdapter, config: { listAccountIds: (cfg) => listQQBotAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId), diff --git a/src/config.ts b/src/config.ts index 5ac8aee..0f1a82f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -62,6 +62,7 @@ export function resolveQQBotAccount( clientSecretFile: qqbot?.clientSecretFile, dmPolicy: qqbot?.dmPolicy, allowFrom: qqbot?.allowFrom, + systemPrompt: qqbot?.systemPrompt, }; appId = qqbot?.appId ?? ""; } else { @@ -95,6 +96,7 @@ export function resolveQQBotAccount( appId, clientSecret, secretSource, + systemPrompt: accountConfig.systemPrompt, config: accountConfig, }; } diff --git a/src/gateway.ts b/src/gateway.ts index 7f332fd..1bf5f47 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -1,6 +1,6 @@ import WebSocket from "ws"; import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; -import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage } from "./api.js"; +import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache } from "./api.js"; import { getQQBotRuntime } from "./runtime.js"; // QQ Bot intents @@ -10,6 +10,10 @@ const INTENTS = { GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊 }; +// 重连配置 +const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟 +const MAX_RECONNECT_ATTEMPTS = 100; + export interface GatewayContext { account: ResolvedQQBotAccount; abortSignal: AbortSignal; @@ -24,7 +28,7 @@ export interface GatewayContext { } /** - * 启动 Gateway WebSocket 连接 + * 启动 Gateway WebSocket 连接(带自动重连) */ export async function startGateway(ctx: GatewayContext): Promise { const { account, abortSignal, cfg, onReady, onError, log } = ctx; @@ -33,256 +37,412 @@ export async function startGateway(ctx: GatewayContext): Promise { throw new Error("QQBot not configured (missing appId or clientSecret)"); } - const pluginRuntime = getQQBotRuntime(); - const accessToken = await getAccessToken(account.appId, account.clientSecret); - const gatewayUrl = await getGatewayUrl(accessToken); - - log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`); - - const ws = new WebSocket(gatewayUrl); + let reconnectAttempts = 0; + let isAborted = false; + let currentWs: WebSocket | null = null; let heartbeatInterval: ReturnType | null = null; + let sessionId: string | null = null; let lastSeq: number | null = null; + abortSignal.addEventListener("abort", () => { + isAborted = true; + cleanup(); + }); + const cleanup = () => { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); + if (currentWs && (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING)) { + currentWs.close(); } + currentWs = null; }; - abortSignal.addEventListener("abort", cleanup); - - // 处理收到的消息 - const handleMessage = async (event: { - type: "c2c" | "guild" | "dm" | "group"; - senderId: string; - senderName?: string; - content: string; - messageId: string; - timestamp: string; - channelId?: string; - guildId?: string; - groupOpenid?: string; - }) => { - log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`); - - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "inbound", - }); - - const isGroup = event.type === "guild" || event.type === "group"; - const peerId = event.type === "guild" ? `channel:${event.channelId}` - : event.type === "group" ? `group:${event.groupOpenid}` - : event.senderId; - - const route = pluginRuntime.channel.routing.resolveAgentRoute({ - cfg, - channel: "qqbot", - accountId: account.accountId, - peer: { - kind: isGroup ? "group" : "dm", - id: peerId, - }, - }); - - const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg); - - const body = pluginRuntime.channel.reply.formatInboundEnvelope({ - channel: "QQBot", - from: event.senderName ?? event.senderId, - timestamp: new Date(event.timestamp).getTime(), - body: event.content, - chatType: isGroup ? "group" : "direct", - sender: { - id: event.senderId, - name: event.senderName, - }, - envelope: envelopeOptions, - }); - - const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}` - : event.type === "group" ? `qqbot:group:${event.groupOpenid}` - : `qqbot:${event.senderId}`; - const toAddress = fromAddress; - - const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({ - Body: body, - RawBody: event.content, - CommandBody: event.content, - From: fromAddress, - To: toAddress, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: isGroup ? "group" : "direct", - SenderId: event.senderId, - SenderName: event.senderName, - Provider: "qqbot", - Surface: "qqbot", - MessageSid: event.messageId, - Timestamp: new Date(event.timestamp).getTime(), - OriginatingChannel: "qqbot", - OriginatingTo: toAddress, - // QQBot 特有字段 - QQChannelId: event.channelId, - QQGuildId: event.guildId, - QQGroupOpenid: event.groupOpenid, - }); - - // 分发到 AI 系统 - try { - const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId); - - await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - responsePrefix: messagesConfig.responsePrefix, - deliver: async (payload: { text?: string }) => { - const replyText = payload.text ?? ""; - if (!replyText.trim()) return; - - try { - if (event.type === "c2c") { - await sendC2CMessage(accessToken, event.senderId, replyText, event.messageId); - } else if (event.type === "group" && event.groupOpenid) { - await sendGroupMessage(accessToken, event.groupOpenid, replyText, event.messageId); - } else if (event.channelId) { - await sendChannelMessage(accessToken, event.channelId, replyText, event.messageId); - } - log?.info(`[qqbot:${account.accountId}] Sent reply`); - - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "outbound", - }); - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`); - } - }, - onError: (err: unknown) => { - log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`); - }, - }, - replyOptions: {}, - }); - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`); - } + const getReconnectDelay = () => { + const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1); + return RECONNECT_DELAYS[idx]; }; - ws.on("open", () => { - log?.info(`[qqbot:${account.accountId}] WebSocket connected`); - }); + const scheduleReconnect = () => { + if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`); + return; + } - ws.on("message", async (data) => { - try { - const payload = JSON.parse(data.toString()) as WSPayload; - const { op, d, s, t } = payload; + const delay = getReconnectDelay(); + reconnectAttempts++; + log?.info(`[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`); - if (s) lastSeq = s; - - log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`); - - switch (op) { - case 10: // Hello - log?.info(`[qqbot:${account.accountId}] Hello received, starting heartbeat`); - // Identify - ws.send( - JSON.stringify({ - op: 2, - d: { - token: `QQBot ${accessToken}`, - intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C, - shard: [0, 1], - }, - }) - ); - // Heartbeat - const interval = (d as { heartbeat_interval: number }).heartbeat_interval; - heartbeatInterval = setInterval(() => { - ws.send(JSON.stringify({ op: 1, d: lastSeq })); - }, interval); - break; - - case 0: // Dispatch - if (t === "READY") { - log?.info(`[qqbot:${account.accountId}] Ready`); - onReady?.(d); - } else if (t === "C2C_MESSAGE_CREATE") { - const event = d as C2CMessageEvent; - await handleMessage({ - type: "c2c", - senderId: event.author.user_openid, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - }); - } else if (t === "AT_MESSAGE_CREATE") { - const event = d as GuildMessageEvent; - await handleMessage({ - type: "guild", - senderId: event.author.id, - senderName: event.author.username, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - channelId: event.channel_id, - guildId: event.guild_id, - }); - } else if (t === "DIRECT_MESSAGE_CREATE") { - const event = d as GuildMessageEvent; - await handleMessage({ - type: "dm", - senderId: event.author.id, - senderName: event.author.username, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - guildId: event.guild_id, - }); - } else if (t === "GROUP_AT_MESSAGE_CREATE") { - const event = d as GroupMessageEvent; - await handleMessage({ - type: "group", - senderId: event.author.member_openid, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - groupOpenid: event.group_openid, - }); - } - break; - - case 11: // Heartbeat ACK - log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`); - break; - - case 9: // Invalid Session - log?.error(`[qqbot:${account.accountId}] Invalid session`); - onError?.(new Error("Invalid session")); - cleanup(); - break; + setTimeout(() => { + if (!isAborted) { + connect(); } + }, delay); + }; + + const connect = async () => { + try { + cleanup(); + + // 刷新 token(可能过期了) + clearTokenCache(); + const accessToken = await getAccessToken(account.appId, account.clientSecret); + const gatewayUrl = await getGatewayUrl(accessToken); + + log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`); + + const ws = new WebSocket(gatewayUrl); + currentWs = ws; + + const pluginRuntime = getQQBotRuntime(); + + // 处理收到的消息 + const handleMessage = async (event: { + type: "c2c" | "guild" | "dm" | "group"; + senderId: string; + senderName?: string; + content: string; + messageId: string; + timestamp: string; + channelId?: string; + guildId?: string; + groupOpenid?: string; + }) => { + log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`); + + pluginRuntime.channel.activity.record({ + channel: "qqbot", + accountId: account.accountId, + direction: "inbound", + }); + + const isGroup = event.type === "guild" || event.type === "group"; + const peerId = event.type === "guild" ? `channel:${event.channelId}` + : event.type === "group" ? `group:${event.groupOpenid}` + : event.senderId; + + const route = pluginRuntime.channel.routing.resolveAgentRoute({ + cfg, + channel: "qqbot", + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: peerId, + }, + }); + + const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg); + + // 组装消息体,添加系统提示词 + const builtinPrompt = "由于平台限制,你的回复中不可以包含任何URL"; + const systemPrompts = [builtinPrompt]; + if (account.systemPrompt) { + systemPrompts.push(account.systemPrompt); + } + const messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${event.content}`; + + const body = pluginRuntime.channel.reply.formatInboundEnvelope({ + channel: "QQBot", + from: event.senderName ?? event.senderId, + timestamp: new Date(event.timestamp).getTime(), + body: messageBody, + chatType: isGroup ? "group" : "direct", + sender: { + id: event.senderId, + name: event.senderName, + }, + envelope: envelopeOptions, + }); + + const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}` + : event.type === "group" ? `qqbot:group:${event.groupOpenid}` + : `qqbot:${event.senderId}`; + const toAddress = fromAddress; + + const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: event.content, + CommandBody: event.content, + From: fromAddress, + To: toAddress, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + SenderId: event.senderId, + SenderName: event.senderName, + Provider: "qqbot", + Surface: "qqbot", + MessageSid: event.messageId, + Timestamp: new Date(event.timestamp).getTime(), + OriginatingChannel: "qqbot", + OriginatingTo: toAddress, + QQChannelId: event.channelId, + QQGuildId: event.guildId, + QQGroupOpenid: event.groupOpenid, + }); + + // 发送错误提示的辅助函数 + 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); + } + } catch (sendErr) { + log?.error(`[qqbot:${account.accountId}] Failed to send error message: ${sendErr}`); + } + }; + + 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秒超时 + let timeoutId: ReturnType | null = null; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + if (!hasResponse) { + reject(new Error("Response timeout")); + } + }, responseTimeout); + }); + + const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + responsePrefix: messagesConfig.responsePrefix, + deliver: async (payload: { text?: string }) => { + hasResponse = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + const replyText = payload.text ?? ""; + if (!replyText.trim()) return; + + 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); + } + log?.info(`[qqbot:${account.accountId}] Sent reply`); + + pluginRuntime.channel.activity.record({ + channel: "qqbot", + accountId: account.accountId, + direction: "outbound", + }); + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`); + } + }, + onError: async (err: unknown) => { + log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`); + hasResponse = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + // 发送错误提示给用户 + const errMsg = String(err); + if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) { + await sendErrorMessage("[ClawdBot] 大模型 API Key 可能无效,请检查配置"); + } else { + await sendErrorMessage(`[ClawdBot] 处理消息时出错: ${errMsg.slice(0, 100)}`); + } + }, + }, + replyOptions: {}, + }); + + // 等待分发完成或超时 + try { + await Promise.race([dispatchPromise, timeoutPromise]); + } catch (err) { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (!hasResponse) { + log?.error(`[qqbot:${account.accountId}] No response within timeout`); + await sendErrorMessage("[ClawdBot] 未收到响应,请检查大模型 API Key 是否正确配置"); + } + } + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`); + await sendErrorMessage(`[ClawdBot] 处理消息失败: ${String(err).slice(0, 100)}`); + } + }; + + ws.on("open", () => { + log?.info(`[qqbot:${account.accountId}] WebSocket connected`); + reconnectAttempts = 0; // 连接成功,重置重试计数 + }); + + ws.on("message", async (data) => { + try { + const payload = JSON.parse(data.toString()) as WSPayload; + const { op, d, s, t } = payload; + + if (s) lastSeq = s; + + log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`); + + switch (op) { + case 10: // Hello + log?.info(`[qqbot:${account.accountId}] Hello received`); + + // 如果有 session_id,尝试 Resume + if (sessionId && lastSeq !== null) { + log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`); + ws.send(JSON.stringify({ + op: 6, // Resume + d: { + token: `QQBot ${accessToken}`, + session_id: sessionId, + seq: lastSeq, + }, + })); + } else { + // 新连接,发送 Identify + ws.send(JSON.stringify({ + op: 2, + d: { + token: `QQBot ${accessToken}`, + intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C, + shard: [0, 1], + }, + })); + } + + // 启动心跳 + const interval = (d as { heartbeat_interval: number }).heartbeat_interval; + if (heartbeatInterval) clearInterval(heartbeatInterval); + heartbeatInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ op: 1, d: lastSeq })); + log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`); + } + }, interval); + break; + + case 0: // Dispatch + if (t === "READY") { + const readyData = d as { session_id: string }; + sessionId = readyData.session_id; + log?.info(`[qqbot:${account.accountId}] Ready, session: ${sessionId}`); + onReady?.(d); + } else if (t === "RESUMED") { + log?.info(`[qqbot:${account.accountId}] Session resumed`); + } else if (t === "C2C_MESSAGE_CREATE") { + const event = d as C2CMessageEvent; + await handleMessage({ + type: "c2c", + senderId: event.author.user_openid, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + }); + } else if (t === "AT_MESSAGE_CREATE") { + const event = d as GuildMessageEvent; + await handleMessage({ + type: "guild", + senderId: event.author.id, + senderName: event.author.username, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + channelId: event.channel_id, + guildId: event.guild_id, + }); + } else if (t === "DIRECT_MESSAGE_CREATE") { + const event = d as GuildMessageEvent; + await handleMessage({ + type: "dm", + senderId: event.author.id, + senderName: event.author.username, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + guildId: event.guild_id, + }); + } else if (t === "GROUP_AT_MESSAGE_CREATE") { + const event = d as GroupMessageEvent; + await handleMessage({ + type: "group", + senderId: event.author.member_openid, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + groupOpenid: event.group_openid, + }); + } + break; + + case 11: // Heartbeat ACK + log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`); + break; + + case 7: // Reconnect + log?.info(`[qqbot:${account.accountId}] Server requested reconnect`); + cleanup(); + scheduleReconnect(); + break; + + case 9: // Invalid Session + const canResume = d as boolean; + log?.error(`[qqbot:${account.accountId}] Invalid session, can resume: ${canResume}`); + if (!canResume) { + sessionId = null; + lastSeq = null; + } + cleanup(); + scheduleReconnect(); + break; + } + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`); + } + }); + + ws.on("close", (code, reason) => { + log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`); + cleanup(); + + // 非正常关闭则重连 + if (!isAborted && code !== 1000) { + scheduleReconnect(); + } + }); + + ws.on("error", (err) => { + log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`); + onError?.(err); + }); + } catch (err) { - log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`); + log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`); + scheduleReconnect(); } - }); + }; - ws.on("close", (code, reason) => { - log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason}`); - cleanup(); - }); - - ws.on("error", (err) => { - log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`); - onError?.(err); - }); + // 开始连接 + await connect(); // 等待 abort 信号 return new Promise((resolve) => { diff --git a/src/onboarding.ts b/src/onboarding.ts new file mode 100644 index 0000000..189792e --- /dev/null +++ b/src/onboarding.ts @@ -0,0 +1,246 @@ +/** + * QQBot CLI Onboarding Adapter + * + * 提供 moltbot onboard 命令的交互式配置支持 + */ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingStatus, + ChannelOnboardingStatusContext, + ChannelOnboardingConfigureContext, + ChannelOnboardingResult, +} from "clawdbot/plugin-sdk"; +import { listQQBotAccountIds, resolveQQBotAccount } from "./config.js"; + +const DEFAULT_ACCOUNT_ID = "default"; + +// 内部类型(避免循环依赖) +interface MoltbotConfig { + channels?: { + qqbot?: QQBotChannelConfig; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface QQBotChannelConfig { + enabled?: boolean; + appId?: string; + clientSecret?: string; + clientSecretFile?: string; + name?: string; + accounts?: Record; +} + +/** + * 解析默认账户 ID + */ +function resolveDefaultQQBotAccountId(cfg: MoltbotConfig): string { + const ids = listQQBotAccountIds(cfg); + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +/** + * QQBot Onboarding Adapter + */ +export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = { + channel: "qqbot" as any, + + getStatus: async (ctx: ChannelOnboardingStatusContext): Promise => { + const { cfg } = ctx; + const configured = listQQBotAccountIds(cfg as MoltbotConfig).some((accountId) => { + const account = resolveQQBotAccount(cfg as MoltbotConfig, accountId); + return Boolean(account.appId && account.clientSecret); + }); + + return { + channel: "qqbot" as any, + configured, + statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`], + selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊", + quickstartScore: configured ? 1 : 20, + }; + }, + + configure: async (ctx: ChannelOnboardingConfigureContext): Promise => { + const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx; + const moltbotCfg = cfg as MoltbotConfig; + + const qqbotOverride = (accountOverrides as Record).qqbot?.trim(); + const defaultAccountId = resolveDefaultQQBotAccountId(moltbotCfg); + let accountId = qqbotOverride ?? defaultAccountId; + + // 是否需要提示选择账户 + if (shouldPromptAccountIds && !qqbotOverride) { + const existingIds = listQQBotAccountIds(moltbotCfg); + if (existingIds.length > 1) { + accountId = await prompter.select({ + message: "选择 QQBot 账户", + options: existingIds.map((id) => ({ + value: id, + label: id === DEFAULT_ACCOUNT_ID ? "默认账户" : id, + })), + initialValue: accountId, + }); + } + } + + let next = moltbotCfg; + const resolvedAccount = resolveQQBotAccount(next, accountId); + const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret); + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envAppId = typeof process !== "undefined" ? process.env?.QQBOT_APP_ID?.trim() : undefined; + const envSecret = typeof process !== "undefined" ? process.env?.QQBOT_CLIENT_SECRET?.trim() : undefined; + const canUseEnv = allowEnv && Boolean(envAppId && envSecret); + const hasConfigCredentials = Boolean(resolvedAccount.config.appId && resolvedAccount.config.clientSecret); + + let appId: string | null = null; + let clientSecret: string | null = null; + + // 显示帮助 + if (!accountConfigured) { + await prompter.note( + [ + "1) 打开 QQ 开放平台: https://q.qq.com/", + "2) 创建机器人应用,获取 AppID 和 ClientSecret", + "3) 在「开发设置」中添加沙箱成员(测试阶段)", + "4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET", + "", + "文档: https://bot.q.qq.com/wiki/", + ].join("\n"), + "QQ Bot 配置", + ); + } + + // 检测环境变量 + if (canUseEnv && !hasConfigCredentials) { + const keepEnv = await prompter.confirm({ + message: "检测到环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET,是否使用?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + qqbot: { + ...next.channels?.qqbot, + enabled: true, + }, + }, + }; + } else { + // 手动输入 + appId = String( + await prompter.text({ + message: "请输入 QQ Bot AppID", + placeholder: "例如: 102146862", + initialValue: resolvedAccount.appId || undefined, + validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"), + }), + ).trim(); + clientSecret = String( + await prompter.text({ + message: "请输入 QQ Bot ClientSecret", + placeholder: "你的 ClientSecret", + validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"), + }), + ).trim(); + } + } else if (hasConfigCredentials) { + // 已有配置 + const keep = await prompter.confirm({ + message: "QQ Bot 已配置,是否保留当前配置?", + initialValue: true, + }); + if (!keep) { + appId = String( + await prompter.text({ + message: "请输入 QQ Bot AppID", + placeholder: "例如: 102146862", + initialValue: resolvedAccount.appId || undefined, + validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"), + }), + ).trim(); + clientSecret = String( + await prompter.text({ + message: "请输入 QQ Bot ClientSecret", + placeholder: "你的 ClientSecret", + validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"), + }), + ).trim(); + } + } else { + // 没有配置,需要输入 + appId = String( + await prompter.text({ + message: "请输入 QQ Bot AppID", + placeholder: "例如: 102146862", + initialValue: resolvedAccount.appId || undefined, + validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"), + }), + ).trim(); + clientSecret = String( + await prompter.text({ + message: "请输入 QQ Bot ClientSecret", + placeholder: "你的 ClientSecret", + validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"), + }), + ).trim(); + } + + // 应用配置 + if (appId && clientSecret) { + if (accountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + channels: { + ...next.channels, + qqbot: { + ...next.channels?.qqbot, + enabled: true, + appId, + clientSecret, + }, + }, + }; + } else { + next = { + ...next, + channels: { + ...next.channels, + qqbot: { + ...next.channels?.qqbot, + enabled: true, + accounts: { + ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts, + [accountId]: { + ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId], + enabled: true, + appId, + clientSecret, + }, + }, + }, + }, + }; + } + } + + return { cfg: next as any, accountId }; + }, + + disable: (cfg) => ({ + ...cfg, + channels: { + ...(cfg as MoltbotConfig).channels, + qqbot: { ...(cfg as MoltbotConfig).channels?.qqbot, enabled: false }, + }, + }) as any, +}; diff --git a/src/types.ts b/src/types.ts index 92f4a02..4ef6ce3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,8 @@ export interface ResolvedQQBotAccount { appId: string; clientSecret: string; secretSource: "config" | "file" | "env" | "none"; + /** 系统提示词 */ + systemPrompt?: string; config: QQBotAccountConfig; } @@ -31,6 +33,8 @@ export interface QQBotAccountConfig { clientSecretFile?: string; dmPolicy?: "open" | "pairing" | "allowlist"; allowFrom?: string[]; + /** 系统提示词,会添加在用户消息前面 */ + systemPrompt?: string; } /**