Merge 2fb01a5173 into da71eaebd2
This commit is contained in:
commit
5b2913650e
11
extensions/qq/clawdbot.plugin.json
Normal file
11
extensions/qq/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "qq",
|
||||||
|
"channels": [
|
||||||
|
"qq"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
extensions/qq/index.ts
Normal file
17
extensions/qq/index.ts
Normal 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;
|
||||||
14
extensions/qq/package.json
Normal file
14
extensions/qq/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
236
extensions/qq/src/channel.ts
Normal file
236
extensions/qq/src/channel.ts
Normal 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),
|
||||||
|
}
|
||||||
|
};
|
||||||
81
extensions/qq/src/client.ts
Normal file
81
extensions/qq/src/client.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
extensions/qq/src/config.ts
Normal file
9
extensions/qq/src/config.ts
Normal 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>;
|
||||||
14
extensions/qq/src/runtime.ts
Normal file
14
extensions/qq/src/runtime.ts
Normal 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;
|
||||||
|
}
|
||||||
27
extensions/qq/src/types.ts
Normal file
27
extensions/qq/src/types.ts
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user