first commit

This commit is contained in:
sliverp
2026-01-28 17:18:41 +08:00
commit 9a531cd6eb
10 changed files with 778 additions and 0 deletions

9
clawdbot.plugin.json Normal file
View File

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

15
index.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
import { qqbotPlugin } from "./src/channel.js";
export default {
register(api: MoltbotPluginApi) {
api.registerChannel({ plugin: qqbotPlugin });
},
};
export { qqbotPlugin } from "./src/channel.js";
export * from "./src/types.js";
export * from "./src/api.js";
export * from "./src/config.js";
export * from "./src/gateway.js";
export * from "./src/outbound.js";

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "qqbot",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/ws": "^8.5.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"clawdbot": "*"
}
}

116
src/api.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* QQ Bot API 鉴权和请求封装
*/
const API_BASE = "https://api.sgroup.qq.com";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
let cachedToken: { token: string; expiresAt: number } | null = null;
/**
* 获取 AccessToken带缓存
*/
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
// 检查缓存,提前 5 分钟刷新
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
return cachedToken.token;
}
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ appId, clientSecret }),
});
const data = (await response.json()) as { access_token?: string; expires_in?: number };
if (!data.access_token) {
throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
}
cachedToken = {
token: data.access_token,
expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
};
return cachedToken.token;
}
/**
* 清除 Token 缓存
*/
export function clearTokenCache(): void {
cachedToken = null;
}
/**
* API 请求封装
*/
export async function apiRequest<T = unknown>(
accessToken: string,
method: string,
path: string,
body?: unknown
): Promise<T> {
const url = `${API_BASE}${path}`;
const options: RequestInit = {
method,
headers: {
Authorization: `QQBot ${accessToken}`,
"Content-Type": "application/json",
},
};
if (body) {
options.body = JSON.stringify(body);
}
const res = await fetch(url, options);
const data = (await res.json()) as T;
if (!res.ok) {
const error = data as { message?: string; code?: number };
throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`);
}
return data;
}
/**
* 获取 WebSocket Gateway URL
*/
export async function getGatewayUrl(accessToken: string): Promise<string> {
const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
return data.url;
}
/**
* 发送 C2C 单聊消息
*/
export async function sendC2CMessage(
accessToken: string,
openid: string,
content: string,
msgId?: string
): Promise<{ id: string; timestamp: number }> {
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
content,
msg_type: 0,
...(msgId ? { msg_id: msgId } : {}),
});
}
/**
* 发送频道消息
*/
export async function sendChannelMessage(
accessToken: string,
channelId: string,
content: string,
msgId?: string
): Promise<{ id: string; timestamp: string }> {
return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, {
content,
...(msgId ? { msg_id: msgId } : {}),
});
}

147
src/channel.ts Normal file
View File

@@ -0,0 +1,147 @@
import type { ChannelPlugin, MoltbotPluginApi } from "clawdbot/plugin-sdk";
import type { ResolvedQQBotAccount } from "./types.js";
import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js";
import { sendText } from "./outbound.js";
import { startGateway } from "./gateway.js";
const DEFAULT_ACCOUNT_ID = "default";
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
id: "qqbot",
meta: {
id: "qqbot",
label: "QQ Bot",
selectionLabel: "QQ Bot",
docsPath: "/docs/channels/qqbot",
blurb: "Connect to QQ via official QQ Bot API",
order: 50,
},
capabilities: {
chatTypes: ["direct", "group"],
media: false,
reactions: false,
threads: false,
},
reload: { configPrefixes: ["channels.qqbot"] },
config: {
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
isConfigured: (account) => Boolean(account.appId && account.clientSecret),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.appId && account.clientSecret),
tokenSource: account.secretSource,
}),
},
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;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
let appId = "";
let clientSecret = "";
if (input.token) {
// 支持 appId:clientSecret 格式
const parts = input.token.split(":");
if (parts.length === 2) {
appId = parts[0];
clientSecret = parts[1];
}
}
return applyQQBotAccountConfig(cfg, accountId, {
appId,
clientSecret,
clientSecretFile: input.tokenFile,
name: input.name,
});
},
},
outbound: {
deliveryMode: "direct",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
const account = resolveQQBotAccount(cfg, accountId);
const result = await sendText({ to, text, accountId, replyToId, account });
return {
channel: "qqbot",
messageId: result.messageId,
error: result.error ? new Error(result.error) : undefined,
};
},
},
gateway: {
startAccount: async (ctx) => {
const { account, abortSignal, log, runtime } = ctx;
log?.info(`[qqbot:${account.accountId}] Starting gateway`);
await startGateway({
account,
abortSignal,
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) => {
log?.info(`[qqbot:${account.accountId}] Gateway ready`);
ctx.setStatus({
...ctx.getStatus(),
running: true,
connected: true,
lastConnectedAt: Date.now(),
});
},
onError: (error) => {
log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
ctx.setStatus({
...ctx.getStatus(),
lastError: error.message,
});
},
});
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
connected: false,
lastConnectedAt: null,
lastError: null,
},
buildAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.appId && account.clientSecret),
tokenSource: account.secretSource,
running: runtime?.running ?? false,
connected: runtime?.connected ?? false,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastError: runtime?.lastError ?? null,
}),
},
};

152
src/config.ts Normal file
View File

@@ -0,0 +1,152 @@
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
const DEFAULT_ACCOUNT_ID = "default";
interface MoltbotConfig {
channels?: {
qqbot?: QQBotChannelConfig;
[key: string]: unknown;
};
[key: string]: unknown;
}
interface QQBotChannelConfig extends QQBotAccountConfig {
accounts?: Record<string, QQBotAccountConfig>;
}
/**
* 列出所有 QQBot 账户 ID
*/
export function listQQBotAccountIds(cfg: MoltbotConfig): string[] {
const ids = new Set<string>();
const qqbot = cfg.channels?.qqbot;
if (qqbot?.appId) {
ids.add(DEFAULT_ACCOUNT_ID);
}
if (qqbot?.accounts) {
for (const accountId of Object.keys(qqbot.accounts)) {
if (qqbot.accounts[accountId]?.appId) {
ids.add(accountId);
}
}
}
return Array.from(ids);
}
/**
* 解析 QQBot 账户配置
*/
export function resolveQQBotAccount(
cfg: MoltbotConfig,
accountId?: string | null
): ResolvedQQBotAccount {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const qqbot = cfg.channels?.qqbot;
// 基础配置
let accountConfig: QQBotAccountConfig = {};
let appId = "";
let clientSecret = "";
let secretSource: "config" | "file" | "env" | "none" = "none";
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
// 默认账户从顶层读取
accountConfig = {
enabled: qqbot?.enabled,
name: qqbot?.name,
appId: qqbot?.appId,
clientSecret: qqbot?.clientSecret,
clientSecretFile: qqbot?.clientSecretFile,
dmPolicy: qqbot?.dmPolicy,
allowFrom: qqbot?.allowFrom,
};
appId = qqbot?.appId ?? "";
} else {
// 命名账户从 accounts 读取
const account = qqbot?.accounts?.[resolvedAccountId];
accountConfig = account ?? {};
appId = account?.appId ?? "";
}
// 解析 clientSecret
if (accountConfig.clientSecret) {
clientSecret = accountConfig.clientSecret;
secretSource = "config";
} else if (accountConfig.clientSecretFile) {
// 从文件读取(运行时处理)
secretSource = "file";
} else if (process.env.QQBOT_CLIENT_SECRET && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
clientSecret = process.env.QQBOT_CLIENT_SECRET;
secretSource = "env";
}
// AppId 也可以从环境变量读取
if (!appId && process.env.QQBOT_APP_ID && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
appId = process.env.QQBOT_APP_ID;
}
return {
accountId: resolvedAccountId,
name: accountConfig.name,
enabled: accountConfig.enabled !== false,
appId,
clientSecret,
secretSource,
config: accountConfig,
};
}
/**
* 应用账户配置
*/
export function applyQQBotAccountConfig(
cfg: MoltbotConfig,
accountId: string,
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string }
): MoltbotConfig {
const next = { ...cfg };
if (accountId === DEFAULT_ACCOUNT_ID) {
next.channels = {
...next.channels,
qqbot: {
...next.channels?.qqbot,
enabled: true,
...(input.appId ? { appId: input.appId } : {}),
...(input.clientSecret
? { clientSecret: input.clientSecret }
: input.clientSecretFile
? { clientSecretFile: input.clientSecretFile }
: {}),
...(input.name ? { name: input.name } : {}),
},
};
} else {
next.channels = {
...next.channels,
qqbot: {
...next.channels?.qqbot,
enabled: true,
accounts: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
[accountId]: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
enabled: true,
...(input.appId ? { appId: input.appId } : {}),
...(input.clientSecret
? { clientSecret: input.clientSecret }
: input.clientSecretFile
? { clientSecretFile: input.clientSecretFile }
: {}),
...(input.name ? { name: input.name } : {}),
},
},
},
};
}
return next;
}

173
src/gateway.ts Normal file
View File

@@ -0,0 +1,173 @@
import WebSocket from "ws";
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent } from "./types.js";
import { getAccessToken, getGatewayUrl } from "./api.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;
onReady?: (data: unknown) => void;
onError?: (error: Error) => void;
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
}
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;
if (!account.appId || !account.clientSecret) {
throw new Error("QQBot not configured (missing appId or clientSecret)");
}
const accessToken = await getAccessToken(account.appId, account.clientSecret);
const gatewayUrl = await getGatewayUrl(accessToken);
log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
const ws = new WebSocket(gatewayUrl);
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let lastSeq: number | null = null;
const cleanup = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
};
abortSignal.addEventListener("abort", cleanup);
ws.on("open", () => {
log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
});
ws.on("message", async (data) => {
try {
const payload = JSON.parse(data.toString()) as WSPayload;
const { op, d, s, t } = payload;
if (s) lastSeq = s;
log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
switch (op) {
case 10: // Hello
log?.info(`[qqbot:${account.accountId}] Hello received, starting heartbeat`);
// Identify
ws.send(
JSON.stringify({
op: 2,
d: {
token: `QQBot ${accessToken}`,
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE,
shard: [0, 1],
},
})
);
// Heartbeat
const interval = (d as { heartbeat_interval: number }).heartbeat_interval;
heartbeatInterval = setInterval(() => {
ws.send(JSON.stringify({ op: 1, d: lastSeq }));
}, interval);
break;
case 0: // Dispatch
if (t === "READY") {
log?.info(`[qqbot:${account.accountId}] Ready`);
onReady?.(d);
} else if (t === "C2C_MESSAGE_CREATE") {
const event = d as C2CMessageEvent;
onMessage({
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({
type: "guild",
senderId: event.author.id,
senderName: event.author.username,
content: event.content,
messageId: event.id,
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({
type: "dm",
senderId: event.author.id,
senderName: event.author.username,
content: event.content,
messageId: event.id,
timestamp: event.timestamp,
guildId: event.guild_id,
raw: event,
});
}
break;
case 11: // Heartbeat ACK
log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`);
break;
case 9: // Invalid Session
log?.error(`[qqbot:${account.accountId}] Invalid session`);
onError?.(new Error("Invalid session"));
cleanup();
break;
}
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`);
}
});
ws.on("close", (code, reason) => {
log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason}`);
cleanup();
});
ws.on("error", (err) => {
log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`);
onError?.(err);
});
// 等待 abort 信号
return new Promise((resolve) => {
abortSignal.addEventListener("abort", () => resolve());
});
}

47
src/outbound.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { ResolvedQQBotAccount } from "./types.js";
import { getAccessToken, sendC2CMessage, sendChannelMessage } from "./api.js";
export interface OutboundContext {
to: string;
text: string;
accountId?: string | null;
replyToId?: string | null;
account: ResolvedQQBotAccount;
}
export interface OutboundResult {
channel: string;
messageId?: string;
timestamp?: string | number;
error?: string;
}
/**
* 发送文本消息
*/
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
const { to, text, replyToId, account } = ctx;
if (!account.appId || !account.clientSecret) {
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
}
try {
const accessToken = await getAccessToken(account.appId, account.clientSecret);
// 判断目标类型openid (C2C) 或 channel_id (频道)
// openid 通常是 32 位十六进制channel_id 通常是数字
const isC2C = /^[A-F0-9]{32}$/i.test(to);
if (isC2C) {
const result = await sendC2CMessage(accessToken, to, text, replyToId ?? undefined);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else {
const result = await sendChannelMessage(accessToken, to, text, replyToId ?? undefined);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { channel: "qqbot", error: message };
}
}

81
src/types.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* QQ Bot 配置类型
*/
export interface QQBotConfig {
appId: string;
clientSecret?: string;
clientSecretFile?: string;
}
/**
* 解析后的 QQ Bot 账户
*/
export interface ResolvedQQBotAccount {
accountId: string;
name?: string;
enabled: boolean;
appId: string;
clientSecret: string;
secretSource: "config" | "file" | "env" | "none";
config: QQBotAccountConfig;
}
/**
* QQ Bot 账户配置
*/
export interface QQBotAccountConfig {
enabled?: boolean;
name?: string;
appId?: string;
clientSecret?: string;
clientSecretFile?: string;
dmPolicy?: "open" | "pairing" | "allowlist";
allowFrom?: string[];
}
/**
* C2C 消息事件
*/
export interface C2CMessageEvent {
author: {
id: string;
union_openid: string;
user_openid: string;
};
content: string;
id: string;
timestamp: string;
message_scene?: {
source: string;
};
}
/**
* 频道 AT 消息事件
*/
export interface GuildMessageEvent {
id: string;
channel_id: string;
guild_id: string;
content: string;
timestamp: string;
author: {
id: string;
username?: string;
bot?: boolean;
};
member?: {
nick?: string;
joined_at?: string;
};
}
/**
* WebSocket 事件负载
*/
export interface WSPayload {
op: number;
d?: unknown;
s?: number;
t?: string;
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}