11
This commit is contained in:
10
index.ts
10
index.ts
@@ -1,13 +1,21 @@
|
|||||||
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
import { qqbotPlugin } from "./src/channel.js";
|
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) {
|
register(api: MoltbotPluginApi) {
|
||||||
|
setQQBotRuntime(api.runtime);
|
||||||
api.registerChannel({ plugin: qqbotPlugin });
|
api.registerChannel({ plugin: qqbotPlugin });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
|
|
||||||
export { qqbotPlugin } from "./src/channel.js";
|
export { qqbotPlugin } from "./src/channel.js";
|
||||||
|
export { setQQBotRuntime, getQQBotRuntime } from "./src/runtime.js";
|
||||||
export * from "./src/types.js";
|
export * from "./src/types.js";
|
||||||
export * from "./src/api.js";
|
export * from "./src/api.js";
|
||||||
export * from "./src/config.js";
|
export * from "./src/config.js";
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
"moltbot": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch"
|
"dev": "tsc --watch"
|
||||||
|
|||||||
@@ -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 type { ResolvedQQBotAccount } from "./types.js";
|
||||||
import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js";
|
import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js";
|
||||||
import { sendText } from "./outbound.js";
|
import { sendText } from "./outbound.js";
|
||||||
@@ -39,7 +39,6 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
setup: {
|
setup: {
|
||||||
validateInput: ({ input }) => {
|
validateInput: ({ input }) => {
|
||||||
if (!input.token && !input.tokenFile && !input.useEnv) {
|
if (!input.token && !input.tokenFile && !input.useEnv) {
|
||||||
// token 在这里是 appId:clientSecret 格式
|
|
||||||
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
|
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -49,7 +48,6 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
let clientSecret = "";
|
let clientSecret = "";
|
||||||
|
|
||||||
if (input.token) {
|
if (input.token) {
|
||||||
// 支持 appId:clientSecret 格式
|
|
||||||
const parts = input.token.split(":");
|
const parts = input.token.split(":");
|
||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
appId = parts[0];
|
appId = parts[0];
|
||||||
@@ -80,32 +78,16 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
},
|
},
|
||||||
gateway: {
|
gateway: {
|
||||||
startAccount: async (ctx) => {
|
startAccount: async (ctx) => {
|
||||||
const { account, abortSignal, log, runtime } = ctx;
|
const { account, abortSignal, log, cfg } = ctx;
|
||||||
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Starting gateway`);
|
log?.info(`[qqbot:${account.accountId}] Starting gateway`);
|
||||||
|
|
||||||
await startGateway({
|
await startGateway({
|
||||||
account,
|
account,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
cfg,
|
||||||
log,
|
log,
|
||||||
onMessage: (event) => {
|
onReady: () => {
|
||||||
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`);
|
log?.info(`[qqbot:${account.accountId}] Gateway ready`);
|
||||||
ctx.setStatus({
|
ctx.setStatus({
|
||||||
...ctx.getStatus(),
|
...ctx.getStatus(),
|
||||||
|
|||||||
145
src/gateway.ts
145
src/gateway.ts
@@ -1,18 +1,18 @@
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent } from "./types.js";
|
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
|
// QQ Bot intents
|
||||||
const INTENTS = {
|
const INTENTS = {
|
||||||
PUBLIC_GUILD_MESSAGES: 1 << 30,
|
PUBLIC_GUILD_MESSAGES: 1 << 30,
|
||||||
DIRECT_MESSAGE: 1 << 25,
|
DIRECT_MESSAGE: 1 << 25,
|
||||||
// C2C 私聊在 PUBLIC_GUILD_MESSAGES 里
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface GatewayContext {
|
export interface GatewayContext {
|
||||||
account: ResolvedQQBotAccount;
|
account: ResolvedQQBotAccount;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
onMessage: (event: GatewayMessageEvent) => void;
|
cfg: unknown;
|
||||||
onReady?: (data: unknown) => void;
|
onReady?: (data: unknown) => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
log?: {
|
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 连接
|
* 启动 Gateway WebSocket 连接
|
||||||
*/
|
*/
|
||||||
export async function startGateway(ctx: GatewayContext): Promise<void> {
|
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) {
|
if (!account.appId || !account.clientSecret) {
|
||||||
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginRuntime = getQQBotRuntime();
|
||||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||||
const gatewayUrl = await getGatewayUrl(accessToken);
|
const gatewayUrl = await getGatewayUrl(accessToken);
|
||||||
|
|
||||||
@@ -65,6 +54,121 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
|
|
||||||
abortSignal.addEventListener("abort", cleanup);
|
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", () => {
|
ws.on("open", () => {
|
||||||
log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
|
log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
|
||||||
});
|
});
|
||||||
@@ -105,17 +209,16 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
onReady?.(d);
|
onReady?.(d);
|
||||||
} else if (t === "C2C_MESSAGE_CREATE") {
|
} else if (t === "C2C_MESSAGE_CREATE") {
|
||||||
const event = d as C2CMessageEvent;
|
const event = d as C2CMessageEvent;
|
||||||
onMessage({
|
await handleMessage({
|
||||||
type: "c2c",
|
type: "c2c",
|
||||||
senderId: event.author.user_openid,
|
senderId: event.author.user_openid,
|
||||||
content: event.content,
|
content: event.content,
|
||||||
messageId: event.id,
|
messageId: event.id,
|
||||||
timestamp: event.timestamp,
|
timestamp: event.timestamp,
|
||||||
raw: event,
|
|
||||||
});
|
});
|
||||||
} else if (t === "AT_MESSAGE_CREATE") {
|
} else if (t === "AT_MESSAGE_CREATE") {
|
||||||
const event = d as GuildMessageEvent;
|
const event = d as GuildMessageEvent;
|
||||||
onMessage({
|
await handleMessage({
|
||||||
type: "guild",
|
type: "guild",
|
||||||
senderId: event.author.id,
|
senderId: event.author.id,
|
||||||
senderName: event.author.username,
|
senderName: event.author.username,
|
||||||
@@ -124,11 +227,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
timestamp: event.timestamp,
|
timestamp: event.timestamp,
|
||||||
channelId: event.channel_id,
|
channelId: event.channel_id,
|
||||||
guildId: event.guild_id,
|
guildId: event.guild_id,
|
||||||
raw: event,
|
|
||||||
});
|
});
|
||||||
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
||||||
const event = d as GuildMessageEvent;
|
const event = d as GuildMessageEvent;
|
||||||
onMessage({
|
await handleMessage({
|
||||||
type: "dm",
|
type: "dm",
|
||||||
senderId: event.author.id,
|
senderId: event.author.id,
|
||||||
senderName: event.author.username,
|
senderName: event.author.username,
|
||||||
@@ -136,7 +238,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
messageId: event.id,
|
messageId: event.id,
|
||||||
timestamp: event.timestamp,
|
timestamp: event.timestamp,
|
||||||
guildId: event.guild_id,
|
guildId: event.guild_id,
|
||||||
raw: event,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
14
src/runtime.ts
Normal file
14
src/runtime.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user