first commit
This commit is contained in:
9
clawdbot.plugin.json
Normal file
9
clawdbot.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "qqbot",
|
||||||
|
"channels": ["qqbot"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
index.ts
Normal file
15
index.ts
Normal 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
22
package.json
Normal 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
116
src/api.ts
Normal 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
147
src/channel.ts
Normal 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
152
src/config.ts
Normal 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
173
src/gateway.ts
Normal 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
47
src/outbound.ts
Normal 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
81
src/types.ts
Normal 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
16
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user