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