From 9a531cd6eb54a96b96d18855587b93ed8921ccbf Mon Sep 17 00:00:00 2001 From: sliverp Date: Wed, 28 Jan 2026 17:18:41 +0800 Subject: [PATCH] first commit --- clawdbot.plugin.json | 9 +++ index.ts | 15 ++++ package.json | 22 ++++++ src/api.ts | 116 +++++++++++++++++++++++++++++ src/channel.ts | 147 ++++++++++++++++++++++++++++++++++++ src/config.ts | 152 +++++++++++++++++++++++++++++++++++++ src/gateway.ts | 173 +++++++++++++++++++++++++++++++++++++++++++ src/outbound.ts | 47 ++++++++++++ src/types.ts | 81 ++++++++++++++++++++ tsconfig.json | 16 ++++ 10 files changed, 778 insertions(+) create mode 100644 clawdbot.plugin.json create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/api.ts create mode 100644 src/channel.ts create mode 100644 src/config.ts create mode 100644 src/gateway.ts create mode 100644 src/outbound.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/clawdbot.plugin.json b/clawdbot.plugin.json new file mode 100644 index 0000000..32017b4 --- /dev/null +++ b/clawdbot.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "qqbot", + "channels": ["qqbot"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..2fa41ed --- /dev/null +++ b/index.ts @@ -0,0 +1,15 @@ +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { qqbotPlugin } from "./src/channel.js"; + +export default { + register(api: MoltbotPluginApi) { + api.registerChannel({ plugin: qqbotPlugin }); + }, +}; + +export { qqbotPlugin } from "./src/channel.js"; +export * from "./src/types.js"; +export * from "./src/api.js"; +export * from "./src/config.js"; +export * from "./src/gateway.js"; +export * from "./src/outbound.js"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3f55e1a --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "qqbot", + "version": "1.0.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/ws": "^8.5.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "clawdbot": "*" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..69708cd --- /dev/null +++ b/src/api.ts @@ -0,0 +1,116 @@ +/** + * 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 { + // 检查缓存,提前 5 分钟刷新 + if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) { + return cachedToken.token; + } + + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ appId, clientSecret }), + }); + + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + + 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; +} + +/** + * API 请求封装 + */ +export async function apiRequest( + accessToken: string, + method: string, + path: string, + body?: unknown +): Promise { + const url = `${API_BASE}${path}`; + const options: RequestInit = { + method, + headers: { + Authorization: `QQBot ${accessToken}`, + "Content-Type": "application/json", + }, + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const res = await fetch(url, options); + const data = (await res.json()) as T; + + 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 { + 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 }> { + return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, { + content, + msg_type: 0, + ...(msgId ? { msg_id: msgId } : {}), + }); +} + +/** + * 发送频道消息 + */ +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 } : {}), + }); +} diff --git a/src/channel.ts b/src/channel.ts new file mode 100644 index 0000000..aab45af --- /dev/null +++ b/src/channel.ts @@ -0,0 +1,147 @@ +import type { ChannelPlugin, MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import type { ResolvedQQBotAccount } from "./types.js"; +import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js"; +import { sendText } from "./outbound.js"; +import { startGateway } from "./gateway.js"; + +const DEFAULT_ACCOUNT_ID = "default"; + +export const qqbotPlugin: ChannelPlugin = { + id: "qqbot", + meta: { + id: "qqbot", + label: "QQ Bot", + selectionLabel: "QQ Bot", + docsPath: "/docs/channels/qqbot", + blurb: "Connect to QQ via official QQ Bot API", + order: 50, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: false, + reactions: false, + threads: false, + }, + reload: { configPrefixes: ["channels.qqbot"] }, + config: { + listAccountIds: (cfg) => listQQBotAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + isConfigured: (account) => Boolean(account.appId && account.clientSecret), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.appId && account.clientSecret), + tokenSource: account.secretSource, + }), + }, + setup: { + validateInput: ({ input }) => { + if (!input.token && !input.tokenFile && !input.useEnv) { + // token 在这里是 appId:clientSecret 格式 + return "QQBot requires --token (format: appId:clientSecret) or --use-env"; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + let appId = ""; + let clientSecret = ""; + + if (input.token) { + // 支持 appId:clientSecret 格式 + const parts = input.token.split(":"); + if (parts.length === 2) { + appId = parts[0]; + clientSecret = parts[1]; + } + } + + return applyQQBotAccountConfig(cfg, accountId, { + appId, + clientSecret, + clientSecretFile: input.tokenFile, + name: input.name, + }); + }, + }, + outbound: { + deliveryMode: "direct", + textChunkLimit: 2000, + sendText: async ({ to, text, accountId, replyToId, cfg }) => { + const account = resolveQQBotAccount(cfg, accountId); + const result = await sendText({ to, text, accountId, replyToId, account }); + return { + channel: "qqbot", + messageId: result.messageId, + error: result.error ? new Error(result.error) : undefined, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const { account, abortSignal, log, runtime } = ctx; + + log?.info(`[qqbot:${account.accountId}] Starting gateway`); + + await startGateway({ + account, + abortSignal, + log, + onMessage: (event) => { + log?.info(`[qqbot:${account.accountId}] Message from ${event.senderId}: ${event.content}`); + // 消息处理会通过 runtime 发送到 moltbot 核心 + runtime.emit?.("message", { + channel: "qqbot", + accountId: account.accountId, + chatType: event.type === "c2c" ? "direct" : "group", + senderId: event.senderId, + senderName: event.senderName, + content: event.content, + messageId: event.messageId, + timestamp: event.timestamp, + channelId: event.channelId, + guildId: event.guildId, + raw: event.raw, + }); + }, + onReady: (data) => { + log?.info(`[qqbot:${account.accountId}] Gateway ready`); + ctx.setStatus({ + ...ctx.getStatus(), + running: true, + connected: true, + lastConnectedAt: Date.now(), + }); + }, + onError: (error) => { + log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`); + ctx.setStatus({ + ...ctx.getStatus(), + lastError: error.message, + }); + }, + }); + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + connected: false, + lastConnectedAt: null, + lastError: null, + }, + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.appId && account.clientSecret), + tokenSource: account.secretSource, + running: runtime?.running ?? false, + connected: runtime?.connected ?? false, + lastConnectedAt: runtime?.lastConnectedAt ?? null, + lastError: runtime?.lastError ?? null, + }), + }, +}; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..5ac8aee --- /dev/null +++ b/src/config.ts @@ -0,0 +1,152 @@ +import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js"; + +const DEFAULT_ACCOUNT_ID = "default"; + +interface MoltbotConfig { + channels?: { + qqbot?: QQBotChannelConfig; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface QQBotChannelConfig extends QQBotAccountConfig { + accounts?: Record; +} + +/** + * 列出所有 QQBot 账户 ID + */ +export function listQQBotAccountIds(cfg: MoltbotConfig): string[] { + const ids = new Set(); + const qqbot = cfg.channels?.qqbot; + + if (qqbot?.appId) { + ids.add(DEFAULT_ACCOUNT_ID); + } + + if (qqbot?.accounts) { + for (const accountId of Object.keys(qqbot.accounts)) { + if (qqbot.accounts[accountId]?.appId) { + ids.add(accountId); + } + } + } + + return Array.from(ids); +} + +/** + * 解析 QQBot 账户配置 + */ +export function resolveQQBotAccount( + cfg: MoltbotConfig, + accountId?: string | null +): ResolvedQQBotAccount { + const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID; + const qqbot = cfg.channels?.qqbot; + + // 基础配置 + let accountConfig: QQBotAccountConfig = {}; + let appId = ""; + let clientSecret = ""; + let secretSource: "config" | "file" | "env" | "none" = "none"; + + if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { + // 默认账户从顶层读取 + accountConfig = { + enabled: qqbot?.enabled, + name: qqbot?.name, + appId: qqbot?.appId, + clientSecret: qqbot?.clientSecret, + clientSecretFile: qqbot?.clientSecretFile, + dmPolicy: qqbot?.dmPolicy, + allowFrom: qqbot?.allowFrom, + }; + appId = qqbot?.appId ?? ""; + } else { + // 命名账户从 accounts 读取 + const account = qqbot?.accounts?.[resolvedAccountId]; + accountConfig = account ?? {}; + appId = account?.appId ?? ""; + } + + // 解析 clientSecret + if (accountConfig.clientSecret) { + clientSecret = accountConfig.clientSecret; + secretSource = "config"; + } else if (accountConfig.clientSecretFile) { + // 从文件读取(运行时处理) + secretSource = "file"; + } else if (process.env.QQBOT_CLIENT_SECRET && resolvedAccountId === DEFAULT_ACCOUNT_ID) { + clientSecret = process.env.QQBOT_CLIENT_SECRET; + secretSource = "env"; + } + + // AppId 也可以从环境变量读取 + if (!appId && process.env.QQBOT_APP_ID && resolvedAccountId === DEFAULT_ACCOUNT_ID) { + appId = process.env.QQBOT_APP_ID; + } + + return { + accountId: resolvedAccountId, + name: accountConfig.name, + enabled: accountConfig.enabled !== false, + appId, + clientSecret, + secretSource, + config: accountConfig, + }; +} + +/** + * 应用账户配置 + */ +export function applyQQBotAccountConfig( + cfg: MoltbotConfig, + accountId: string, + input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string } +): MoltbotConfig { + const next = { ...cfg }; + + if (accountId === DEFAULT_ACCOUNT_ID) { + next.channels = { + ...next.channels, + qqbot: { + ...next.channels?.qqbot, + enabled: true, + ...(input.appId ? { appId: input.appId } : {}), + ...(input.clientSecret + ? { clientSecret: input.clientSecret } + : input.clientSecretFile + ? { clientSecretFile: input.clientSecretFile } + : {}), + ...(input.name ? { name: input.name } : {}), + }, + }; + } else { + 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, + ...(input.appId ? { appId: input.appId } : {}), + ...(input.clientSecret + ? { clientSecret: input.clientSecret } + : input.clientSecretFile + ? { clientSecretFile: input.clientSecretFile } + : {}), + ...(input.name ? { name: input.name } : {}), + }, + }, + }, + }; + } + + return next; +} diff --git a/src/gateway.ts b/src/gateway.ts new file mode 100644 index 0000000..bd40dbb --- /dev/null +++ b/src/gateway.ts @@ -0,0 +1,173 @@ +import WebSocket from "ws"; +import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent } from "./types.js"; +import { getAccessToken, getGatewayUrl } from "./api.js"; + +// QQ Bot intents +const INTENTS = { + PUBLIC_GUILD_MESSAGES: 1 << 30, + DIRECT_MESSAGE: 1 << 25, + // C2C 私聊在 PUBLIC_GUILD_MESSAGES 里 +}; + +export interface GatewayContext { + account: ResolvedQQBotAccount; + abortSignal: AbortSignal; + onMessage: (event: GatewayMessageEvent) => void; + onReady?: (data: unknown) => void; + onError?: (error: Error) => void; + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }; +} + +export interface GatewayMessageEvent { + type: "c2c" | "guild" | "dm"; + senderId: string; + senderName?: string; + content: string; + messageId: string; + timestamp: string; + channelId?: string; + guildId?: string; + raw: unknown; +} + +/** + * 启动 Gateway WebSocket 连接 + */ +export async function startGateway(ctx: GatewayContext): Promise { + const { account, abortSignal, onMessage, onReady, onError, log } = ctx; + + if (!account.appId || !account.clientSecret) { + throw new Error("QQBot not configured (missing appId or clientSecret)"); + } + + 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 heartbeatInterval: ReturnType | null = null; + let lastSeq: number | null = null; + + const cleanup = () => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + }; + + abortSignal.addEventListener("abort", cleanup); + + ws.on("open", () => { + log?.info(`[qqbot:${account.accountId}] WebSocket connected`); + }); + + 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, starting heartbeat`); + // Identify + ws.send( + JSON.stringify({ + op: 2, + d: { + token: `QQBot ${accessToken}`, + intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE, + 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; + onMessage({ + type: "c2c", + senderId: event.author.user_openid, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + raw: event, + }); + } else if (t === "AT_MESSAGE_CREATE") { + const event = d as GuildMessageEvent; + onMessage({ + 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, + raw: event, + }); + } else if (t === "DIRECT_MESSAGE_CREATE") { + const event = d as GuildMessageEvent; + onMessage({ + type: "dm", + senderId: event.author.id, + senderName: event.author.username, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + guildId: event.guild_id, + raw: event, + }); + } + 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; + } + } 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}`); + cleanup(); + }); + + ws.on("error", (err) => { + log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`); + onError?.(err); + }); + + // 等待 abort 信号 + return new Promise((resolve) => { + abortSignal.addEventListener("abort", () => resolve()); + }); +} diff --git a/src/outbound.ts b/src/outbound.ts new file mode 100644 index 0000000..3747792 --- /dev/null +++ b/src/outbound.ts @@ -0,0 +1,47 @@ +import type { ResolvedQQBotAccount } from "./types.js"; +import { getAccessToken, sendC2CMessage, sendChannelMessage } from "./api.js"; + +export interface OutboundContext { + to: string; + text: string; + accountId?: string | null; + replyToId?: string | null; + account: ResolvedQQBotAccount; +} + +export interface OutboundResult { + channel: string; + messageId?: string; + timestamp?: string | number; + error?: string; +} + +/** + * 发送文本消息 + */ +export async function sendText(ctx: OutboundContext): Promise { + const { to, text, replyToId, account } = ctx; + + 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); + + // 判断目标类型: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); + return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; + } else { + const result = await sendChannelMessage(accessToken, to, 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 }; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d7269e7 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,81 @@ +/** + * QQ Bot 配置类型 + */ +export interface QQBotConfig { + appId: string; + clientSecret?: string; + clientSecretFile?: string; +} + +/** + * 解析后的 QQ Bot 账户 + */ +export interface ResolvedQQBotAccount { + accountId: string; + name?: string; + enabled: boolean; + appId: string; + clientSecret: string; + secretSource: "config" | "file" | "env" | "none"; + config: QQBotAccountConfig; +} + +/** + * QQ Bot 账户配置 + */ +export interface QQBotAccountConfig { + enabled?: boolean; + name?: string; + appId?: string; + clientSecret?: string; + clientSecretFile?: string; + dmPolicy?: "open" | "pairing" | "allowlist"; + allowFrom?: string[]; +} + +/** + * C2C 消息事件 + */ +export interface C2CMessageEvent { + author: { + id: string; + union_openid: string; + user_openid: string; + }; + content: string; + id: string; + timestamp: string; + message_scene?: { + source: string; + }; +} + +/** + * 频道 AT 消息事件 + */ +export interface GuildMessageEvent { + id: string; + channel_id: string; + guild_id: string; + content: string; + timestamp: string; + author: { + id: string; + username?: string; + bot?: boolean; + }; + member?: { + nick?: string; + joined_at?: string; + }; +} + +/** + * WebSocket 事件负载 + */ +export interface WSPayload { + op: number; + d?: unknown; + s?: number; + t?: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fd54667 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["index.ts", "src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}