This commit is contained in:
constansino 2026-01-30 20:27:33 +08:00 committed by GitHub
commit 5b2913650e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 409 additions and 0 deletions

View File

@ -0,0 +1,11 @@
{
"id": "qq",
"channels": [
"qq"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

17
extensions/qq/index.ts Normal file
View File

@ -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;

View File

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

View File

@ -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<string, OneBotClient>();
function getClientForAccount(accountId: string) {
return clients.get(accountId);
}
export const qqChannel: ChannelPlugin<ResolvedQQAccount> = {
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),
}
};

View File

@ -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<string, string> = {};
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();
}
}

View File

@ -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<typeof QQConfigSchema>;

View File

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

View File

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