This commit is contained in:
sliverp
2026-01-28 17:36:53 +08:00
parent 9a531cd6eb
commit 6154e4f9e1
5 changed files with 154 additions and 46 deletions

View File

@@ -1,13 +1,21 @@
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
import { qqbotPlugin } from "./src/channel.js";
import { setQQBotRuntime } from "./src/runtime.js";
export default {
const plugin = {
id: "qqbot",
name: "QQ Bot",
description: "QQ Bot channel plugin",
register(api: MoltbotPluginApi) {
setQQBotRuntime(api.runtime);
api.registerChannel({ plugin: qqbotPlugin });
},
};
export default plugin;
export { qqbotPlugin } from "./src/channel.js";
export { setQQBotRuntime, getQQBotRuntime } from "./src/runtime.js";
export * from "./src/types.js";
export * from "./src/api.js";
export * from "./src/config.js";

View File

@@ -4,6 +4,9 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"moltbot": {
"extensions": ["./index.ts"]
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"

View File

@@ -1,4 +1,4 @@
import type { ChannelPlugin, MoltbotPluginApi } from "clawdbot/plugin-sdk";
import type { ChannelPlugin } from "clawdbot/plugin-sdk";
import type { ResolvedQQBotAccount } from "./types.js";
import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js";
import { sendText } from "./outbound.js";
@@ -39,7 +39,6 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
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;
@@ -49,7 +48,6 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
let clientSecret = "";
if (input.token) {
// 支持 appId:clientSecret 格式
const parts = input.token.split(":");
if (parts.length === 2) {
appId = parts[0];
@@ -80,32 +78,16 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
},
gateway: {
startAccount: async (ctx) => {
const { account, abortSignal, log, runtime } = ctx;
const { account, abortSignal, log, cfg } = ctx;
log?.info(`[qqbot:${account.accountId}] Starting gateway`);
await startGateway({
account,
abortSignal,
cfg,
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) => {
onReady: () => {
log?.info(`[qqbot:${account.accountId}] Gateway ready`);
ctx.setStatus({
...ctx.getStatus(),

View File

@@ -1,18 +1,18 @@
import WebSocket from "ws";
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent } from "./types.js";
import { getAccessToken, getGatewayUrl } from "./api.js";
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage } from "./api.js";
import { getQQBotRuntime } from "./runtime.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;
cfg: unknown;
onReady?: (data: unknown) => void;
onError?: (error: Error) => void;
log?: {
@@ -22,28 +22,17 @@ export interface GatewayContext {
};
}
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<void> {
const { account, abortSignal, onMessage, onReady, onError, log } = ctx;
const { account, abortSignal, cfg, onReady, onError, log } = ctx;
if (!account.appId || !account.clientSecret) {
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);
@@ -65,6 +54,121 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
abortSignal.addEventListener("abort", cleanup);
// 处理收到的消息
const handleMessage = async (event: {
type: "c2c" | "guild" | "dm";
senderId: string;
senderName?: string;
content: string;
messageId: string;
timestamp: string;
channelId?: string;
guildId?: 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";
const peerId = isGroup ? `channel:${event.channelId}` : 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 = isGroup
? `qqbot:channel:${event.channelId}`
: `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,
});
// 分发到 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.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}`);
}
};
ws.on("open", () => {
log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
});
@@ -105,17 +209,16 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
onReady?.(d);
} else if (t === "C2C_MESSAGE_CREATE") {
const event = d as C2CMessageEvent;
onMessage({
await handleMessage({
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({
await handleMessage({
type: "guild",
senderId: event.author.id,
senderName: event.author.username,
@@ -124,11 +227,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
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({
await handleMessage({
type: "dm",
senderId: event.author.id,
senderName: event.author.username,
@@ -136,7 +238,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
messageId: event.id,
timestamp: event.timestamp,
guildId: event.guild_id,
raw: event,
});
}
break;

14
src/runtime.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setQQBotRuntime(next: PluginRuntime) {
runtime = next;
}
export function getQQBotRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("QQBot runtime not initialized");
}
return runtime;
}