From 2fb01a5173b072231b0d5cc8ab3d9640c90f2512 Mon Sep 17 00:00:00 2001 From: constansino Date: Thu, 29 Jan 2026 15:30:14 +0800 Subject: [PATCH] feat(qq): add QQ OneBot v11 extension support --- extensions/qq/clawdbot.plugin.json | 11 ++ extensions/qq/index.ts | 17 +++ extensions/qq/package.json | 14 ++ extensions/qq/src/channel.ts | 236 +++++++++++++++++++++++++++++ extensions/qq/src/client.ts | 81 ++++++++++ extensions/qq/src/config.ts | 9 ++ extensions/qq/src/runtime.ts | 14 ++ extensions/qq/src/types.ts | 27 ++++ 8 files changed, 409 insertions(+) create mode 100644 extensions/qq/clawdbot.plugin.json create mode 100644 extensions/qq/index.ts create mode 100644 extensions/qq/package.json create mode 100644 extensions/qq/src/channel.ts create mode 100644 extensions/qq/src/client.ts create mode 100644 extensions/qq/src/config.ts create mode 100644 extensions/qq/src/runtime.ts create mode 100644 extensions/qq/src/types.ts diff --git a/extensions/qq/clawdbot.plugin.json b/extensions/qq/clawdbot.plugin.json new file mode 100644 index 000000000..f1f6cd77a --- /dev/null +++ b/extensions/qq/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "qq", + "channels": [ + "qq" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/qq/index.ts b/extensions/qq/index.ts new file mode 100644 index 000000000..88b36f80f --- /dev/null +++ b/extensions/qq/index.ts @@ -0,0 +1,17 @@ +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; +import { qqChannel } from "./src/channel.js"; +import { setQQRuntime } from "./src/runtime.js"; + +const plugin = { + id: "qq", + name: "QQ (OneBot)", + description: "QQ channel plugin via OneBot v11", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + setQQRuntime(api.runtime); + api.registerChannel({ plugin: qqChannel }); + }, +}; + +export default plugin; diff --git a/extensions/qq/package.json b/extensions/qq/package.json new file mode 100644 index 000000000..0e2d5548c --- /dev/null +++ b/extensions/qq/package.json @@ -0,0 +1,14 @@ +{ + "name": "@clawdbot/qq", + "version": "1.0.0", + "type": "module", + "description": "Clawdbot QQ channel plugin via OneBot v11", + "clawdbot": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/extensions/qq/src/channel.ts b/extensions/qq/src/channel.ts new file mode 100644 index 000000000..136ab3171 --- /dev/null +++ b/extensions/qq/src/channel.ts @@ -0,0 +1,236 @@ +import { + type ChannelPlugin, + type ChannelAccountSnapshot, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + type ReplyPayload, +} from "clawdbot/plugin-sdk"; +import { OneBotClient } from "./client.js"; +import { QQConfigSchema, type QQConfig } from "./config.js"; +import { getQQRuntime } from "./runtime.js"; + +export type ResolvedQQAccount = ChannelAccountSnapshot & { + config: QQConfig; + client?: OneBotClient; +}; + +function normalizeTarget(raw: string): string { + return raw.replace(/^(qq:)/i, ""); +} + +const clients = new Map(); + +function getClientForAccount(accountId: string) { + return clients.get(accountId); +} + +export const qqChannel: ChannelPlugin = { + id: "qq", + meta: { + id: "qq", + label: "QQ (OneBot)", + selectionLabel: "QQ", + docsPath: "extensions/qq", + blurb: "Connect to QQ via OneBot v11", + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + configSchema: buildChannelConfigSchema(QQConfigSchema), + config: { + listAccountIds: (cfg) => { + // @ts-ignore + const qq = cfg.channels?.qq; + if (!qq) return []; + if (qq.accounts) return Object.keys(qq.accounts); + return [DEFAULT_ACCOUNT_ID]; + }, + resolveAccount: (cfg, accountId) => { + const id = accountId ?? DEFAULT_ACCOUNT_ID; + // @ts-ignore + const qq = cfg.channels?.qq; + const accountConfig = id === DEFAULT_ACCOUNT_ID ? qq : qq?.accounts?.[id]; + + return { + accountId: id, + name: accountConfig?.name ?? "QQ Default", + enabled: true, + configured: Boolean(accountConfig?.wsUrl), + tokenSource: accountConfig?.accessToken ? "config" : "none", + config: accountConfig || {}, + }; + }, + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + describeAccount: (acc) => ({ + accountId: acc.accountId, + configured: acc.configured, + }), + }, + gateway: { + startAccount: async (ctx) => { + const { account, cfg } = ctx; + const config = account.config; + + if (!config.wsUrl) { + throw new Error("QQ: wsUrl is required"); + } + + const client = new OneBotClient({ + wsUrl: config.wsUrl, + accessToken: config.accessToken, + }); + + clients.set(account.accountId, client); + + client.on("connect", () => { + console.log(`[QQ] Connected account ${account.accountId}`); + try { + getQQRuntime().channel.activity.record({ + channel: "qq", + accountId: account.accountId, + direction: "inbound", + }); + } catch (err) { + // ignore + } + }); + + client.on("message", async (event) => { + if (event.post_type !== "message") return; + + const isGroup = event.message_type === "group"; + const userId = event.user_id; + const groupId = event.group_id; + const text = event.raw_message || ""; + + if (config.admins && config.admins.length > 0 && userId) { + if (!config.admins.includes(userId)) { + // Ignore + } + } + + const fromId = isGroup ? `group:${groupId}` : String(userId); + const conversationLabel = isGroup ? `QQ Group ${groupId}` : `QQ User ${userId}`; + const senderName = event.sender?.nickname || "Unknown"; + + const runtime = getQQRuntime(); + + // Create Dispatcher + const deliver = async (payload: ReplyPayload) => { + const send = (msg: string) => { + if (isGroup) client.sendGroupMsg(groupId, msg); + else client.sendPrivateMsg(userId, msg); + }; + + if (payload.text) { + send(payload.text); + } + + if (payload.files) { + for (const file of payload.files) { + if (file.url) { + send(`[CQ:image,file=${file.url}]`); + } + } + } + }; + + const { dispatcher, replyOptions } = runtime.channel.reply.createReplyDispatcherWithTyping({ + deliver, + }); + + const ctxPayload = runtime.channel.reply.finalizeInboundContext({ + Provider: "qq", + Channel: "qq", + From: fromId, + To: "qq:bot", + Body: text, + RawBody: text, + SenderId: String(userId), + SenderName: senderName, + ConversationLabel: conversationLabel, + SessionKey: `qq:${fromId}`, + AccountId: account.accountId, + ChatType: isGroup ? "group" : "direct", + Timestamp: event.time * 1000, + OriginatingChannel: "qq", + OriginatingTo: fromId, + CommandAuthorized: true + }); + + await runtime.channel.session.recordInboundSession({ + storePath: runtime.channel.session.resolveStorePath(cfg.session?.store, { agentId: "default" }), + sessionKey: ctxPayload.SessionKey!, + ctx: ctxPayload, + updateLastRoute: { + sessionKey: ctxPayload.SessionKey!, + channel: "qq", + to: fromId, + accountId: account.accountId, + }, + onRecordError: (err) => console.error("QQ Session Error:", err) + }); + + await runtime.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, // Passed dispatcher + replyOptions, // Passed options + }); + }); + + client.connect(); + + return () => { + client.disconnect(); + clients.delete(account.accountId); + }; + }, + }, + outbound: { + sendText: async ({ to, text, accountId }) => { + const client = getClientForAccount(accountId || DEFAULT_ACCOUNT_ID); + if (!client) { + console.warn(`[QQ] No client for account ${accountId}, cannot send text`); + return { channel: "qq", sent: false, error: "Client not connected" }; + } + + if (to.startsWith("group:")) { + const groupId = parseInt(to.replace("group:", ""), 10); + client.sendGroupMsg(groupId, text); + } else { + const userId = parseInt(to, 10); + client.sendPrivateMsg(userId, text); + } + + return { channel: "qq", sent: true }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId }) => { + const client = getClientForAccount(accountId || DEFAULT_ACCOUNT_ID); + if (!client) { + console.warn(`[QQ] No client for account ${accountId}, cannot send media`); + return { channel: "qq", sent: false, error: "Client not connected" }; + } + + const cqImage = `[CQ:image,file=${mediaUrl}]`; + const msg = text ? `${text}\n${cqImage}` : cqImage; + + if (to.startsWith("group:")) { + const groupId = parseInt(to.replace("group:", ""), 10); + client.sendGroupMsg(groupId, msg); + } else { + const userId = parseInt(to, 10); + client.sendPrivateMsg(userId, msg); + } + return { channel: "qq", sent: true }; + } + }, + messaging: { + normalizeTarget: normalizeTarget, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + } +}; diff --git a/extensions/qq/src/client.ts b/extensions/qq/src/client.ts new file mode 100644 index 000000000..33443cec2 --- /dev/null +++ b/extensions/qq/src/client.ts @@ -0,0 +1,81 @@ +import WebSocket from "ws"; +import EventEmitter from "events"; +import type { OneBotEvent, OneBotMessage } from "./types.js"; + +interface OneBotClientOptions { + wsUrl: string; + accessToken?: string; +} + +export class OneBotClient extends EventEmitter { + private ws: WebSocket | null = null; + private options: OneBotClientOptions; + private reconnectTimer: NodeJS.Timeout | null = null; + private isAlive = false; + + constructor(options: OneBotClientOptions) { + super(); + this.options = options; + } + + connect() { + const headers: Record = {}; + if (this.options.accessToken) { + headers["Authorization"] = `Bearer ${this.options.accessToken}`; + } + + this.ws = new WebSocket(this.options.wsUrl, { headers }); + + this.ws.on("open", () => { + this.isAlive = true; + this.emit("connect"); + console.log("[QQ] Connected to OneBot server"); + }); + + this.ws.on("message", (data) => { + try { + const payload = JSON.parse(data.toString()) as OneBotEvent; + if (payload.post_type === "meta_event" && payload.meta_event_type === "heartbeat") { + this.isAlive = true; + return; + } + this.emit("message", payload); + } catch (err) { + console.error("[QQ] Failed to parse message:", err); + } + }); + + this.ws.on("close", () => { + this.isAlive = false; + this.emit("disconnect"); + console.log("[QQ] Disconnected. Reconnecting in 5s..."); + this.reconnectTimer = setTimeout(() => this.connect(), 5000); + }); + + this.ws.on("error", (err) => { + console.error("[QQ] WebSocket error:", err); + this.ws?.close(); + }); + } + + sendPrivateMsg(userId: number, message: OneBotMessage | string) { + this.send("send_private_msg", { user_id: userId, message }); + } + + sendGroupMsg(groupId: number, message: OneBotMessage | string) { + this.send("send_group_msg", { group_id: groupId, message }); + } + + private send(action: string, params: any) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ action, params })); + } else { + console.warn("[QQ] Cannot send message, WebSocket not open"); + } + } + + disconnect() { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.ws?.close(); + } +} diff --git a/extensions/qq/src/config.ts b/extensions/qq/src/config.ts new file mode 100644 index 000000000..b44b90111 --- /dev/null +++ b/extensions/qq/src/config.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const QQConfigSchema = z.object({ + wsUrl: z.string().url().describe("The WebSocket URL of the OneBot v11 server (e.g. ws://localhost:3001)"), + accessToken: z.string().optional().describe("The access token for the OneBot server"), + admins: z.array(z.number()).optional().describe("List of admin QQ numbers"), +}); + +export type QQConfig = z.infer; diff --git a/extensions/qq/src/runtime.ts b/extensions/qq/src/runtime.ts new file mode 100644 index 000000000..26eafffc0 --- /dev/null +++ b/extensions/qq/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setQQRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getQQRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("QQ runtime not initialized"); + } + return runtime; +} diff --git a/extensions/qq/src/types.ts b/extensions/qq/src/types.ts new file mode 100644 index 000000000..c4ee87af8 --- /dev/null +++ b/extensions/qq/src/types.ts @@ -0,0 +1,27 @@ +export type OneBotMessageSegment = + | { type: "text"; data: { text: string } } + | { type: "image"; data: { file: string; url?: string } } + | { type: "at"; data: { qq: string } } + | { type: "reply"; data: { id: string } }; + +export type OneBotMessage = OneBotMessageSegment[]; + +export type OneBotEvent = { + time: number; + self_id: number; + post_type: string; + meta_event_type?: string; + message_type?: "private" | "group"; + sub_type?: string; + message_id?: number; + user_id?: number; + group_id?: number; + message?: OneBotMessage | string; + raw_message?: string; + sender?: { + user_id: number; + nickname: string; + card?: string; + role?: string; + }; +};