diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index fc2bb36e1..7dd0d2c3f 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -8,7 +8,7 @@ "./index.ts" ] }, - "peerDependencies": { - "moltbot": ">=2026.1.26" + "dependencies": { + "moltbot": "workspace:*" } -} +} \ No newline at end of file diff --git a/extensions/qq/README.md b/extensions/qq/README.md new file mode 100644 index 000000000..f003e76c8 --- /dev/null +++ b/extensions/qq/README.md @@ -0,0 +1,38 @@ +# Moltbot QQ Channel Plugin + +QQ 机器人官方 API 渠道插件,支持: +- 单聊 (C2C) 消息 +- 群聊 @机器人 消息 +- 频道消息 (可选) + +## 配置 + +在 `~/.clawdbot/clawdbot.json` 中添加: + +```json5 +{ + channels: { + qq: { + appId: "YOUR_APP_ID", + appSecret: "YOUR_APP_SECRET", + enabled: true + } + } +} +``` + +或使用环境变量: +```bash +export QQ_APP_ID=your_app_id +export QQ_APP_SECRET=your_secret +``` + +## 获取凭据 + +1. 访问 [QQ 开放平台](https://q.qq.com/) +2. 创建机器人应用 +3. 获取 AppID 和 AppSecret + +## 事件订阅 + +需要在 QQ 开放平台配置 WebSocket 事件订阅 Intents。 diff --git a/extensions/qq/clawdbot.plugin.json b/extensions/qq/clawdbot.plugin.json new file mode 100644 index 000000000..5488af0ac --- /dev/null +++ b/extensions/qq/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "qq", + "channels": [ + "qq" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} \ No newline at end of file diff --git a/extensions/qq/index.ts b/extensions/qq/index.ts new file mode 100644 index 000000000..abeaffc9c --- /dev/null +++ b/extensions/qq/index.ts @@ -0,0 +1,22 @@ +/** + * QQ Bot Plugin Entry + */ + +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { qqDock, qqPlugin } from "./src/channel.js"; +import { setQQRuntime } from "./src/runtime.js"; + +const plugin = { + id: "qq", + name: "QQ", + description: "QQ channel plugin (Official Bot API)", + configSchema: emptyPluginConfigSchema(), + register(api: MoltbotPluginApi) { + setQQRuntime(api.runtime); + api.registerChannel({ plugin: qqPlugin, dock: qqDock }); + }, +}; + +export default plugin; diff --git a/extensions/qq/package.json b/extensions/qq/package.json new file mode 100644 index 000000000..dea7715c5 --- /dev/null +++ b/extensions/qq/package.json @@ -0,0 +1,33 @@ +{ + "name": "@moltbot/qq", + "version": "2026.1.27", + "type": "module", + "description": "Moltbot QQ channel plugin (Official Bot API)", + "moltbot": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "qq", + "label": "QQ", + "selectionLabel": "QQ (Official Bot API)", + "docsPath": "/channels/qq", + "docsLabel": "qq", + "blurb": "QQ 机器人官方 API 渠道插件", + "aliases": [ + "qq" + ], + "order": 85, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@moltbot/qq", + "localPath": "extensions/qq", + "defaultChoice": "npm" + } + }, + "dependencies": { + "moltbot": "workspace:*", + "ws": "^8.18.0" + } +} \ No newline at end of file diff --git a/extensions/qq/src/accounts.ts b/extensions/qq/src/accounts.ts new file mode 100644 index 000000000..dc6a8b085 --- /dev/null +++ b/extensions/qq/src/accounts.ts @@ -0,0 +1,114 @@ +/** + * QQ Bot Account Management + */ + +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk"; +import type { QQAccountConfig, QQConfig } from "./config-schema.js"; + +export interface ResolvedQQAccount { + accountId: string; + name?: string; + enabled: boolean; + appId?: string; + appSecret?: string; + tokenSource: "config" | "env" | "none"; + config: QQAccountConfig; +} + +/** + * List all configured QQ account IDs + */ +export function listQQAccountIds(cfg: MoltbotConfig): string[] { + const qqConfig = cfg.channels?.qq as QQConfig | undefined; + if (!qqConfig) return []; + + const ids = new Set(); + + // Check base level config + if (qqConfig.appId || qqConfig.appSecret || process.env.QQ_APP_ID) { + ids.add(DEFAULT_ACCOUNT_ID); + } + + // Check named accounts + if (qqConfig.accounts) { + for (const accountId of Object.keys(qqConfig.accounts)) { + ids.add(accountId); + } + } + + return Array.from(ids); +} + +/** + * Resolve default account ID + */ +export function resolveDefaultQQAccountId(cfg: MoltbotConfig): string { + const ids = listQQAccountIds(cfg); + return ids.includes(DEFAULT_ACCOUNT_ID) + ? DEFAULT_ACCOUNT_ID + : ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +/** + * Resolve a specific QQ account configuration + */ +export function resolveQQAccount(params: { + cfg: MoltbotConfig; + accountId?: string; +}): ResolvedQQAccount { + const { cfg, accountId: rawAccountId } = params; + const accountId = rawAccountId ?? DEFAULT_ACCOUNT_ID; + + const qqConfig = cfg.channels?.qq as QQConfig | undefined; + const accountConfig = + accountId !== DEFAULT_ACCOUNT_ID + ? qqConfig?.accounts?.[accountId] + : undefined; + + const baseConfig: QQAccountConfig = { + enabled: qqConfig?.enabled, + appId: qqConfig?.appId, + appSecret: qqConfig?.appSecret, + name: qqConfig?.name, + allowFrom: qqConfig?.allowFrom, + dmPolicy: qqConfig?.dmPolicy, + intents: qqConfig?.intents, + sandbox: qqConfig?.sandbox, + }; + + const mergedConfig: QQAccountConfig = + accountId !== DEFAULT_ACCOUNT_ID && accountConfig + ? { ...baseConfig, ...accountConfig } + : baseConfig; + + // Resolve credentials + let appId = mergedConfig.appId; + let appSecret = mergedConfig.appSecret; + let tokenSource: ResolvedQQAccount["tokenSource"] = "none"; + + if (appId && appSecret) { + tokenSource = "config"; + } else if (accountId === DEFAULT_ACCOUNT_ID) { + // Fall back to environment variables for default account + const envAppId = process.env.QQ_APP_ID; + const envAppSecret = process.env.QQ_APP_SECRET; + if (envAppId && envAppSecret) { + appId = envAppId; + appSecret = envAppSecret; + tokenSource = "env"; + } + } + + return { + accountId, + name: + mergedConfig.name ?? + (accountId === DEFAULT_ACCOUNT_ID ? "QQ" : accountId), + enabled: mergedConfig.enabled ?? false, + appId, + appSecret, + tokenSource, + config: mergedConfig, + }; +} diff --git a/extensions/qq/src/api.ts b/extensions/qq/src/api.ts new file mode 100644 index 000000000..193b3c632 --- /dev/null +++ b/extensions/qq/src/api.ts @@ -0,0 +1,200 @@ +/** + * QQ Bot API Client + * + * Handles authentication and API calls to QQ Bot platform. + */ + +import type { + AccessTokenResponse, + GatewayResponse, + SendMessageRequest, + SendMessageResponse, + QQApiError, +} from "./types.js"; + +const API_BASE = "https://api.sgroup.qq.com"; +const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; + +export type QQFetch = typeof fetch; + +// Token cache +interface TokenCache { + token: string; + expiresAt: number; +} + +const tokenCacheMap = new Map(); + +/** + * Get Access Token with caching and auto-refresh + */ +export async function getAccessToken( + appId: string, + appSecret: string, + fetcher: QQFetch = fetch, +): Promise { + const cacheKey = appId; + const cached = tokenCacheMap.get(cacheKey); + + // Return cached token if still valid (with 60s buffer) + if (cached && cached.expiresAt > Date.now() + 60_000) { + return cached.token; + } + + const response = await fetcher(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ appId, clientSecret: appSecret }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new QQApiException(`Failed to get access token: ${response.status} ${text}`); + } + + const data = (await response.json()) as AccessTokenResponse; + const expiresIn = parseInt(data.expires_in, 10) * 1000; + + tokenCacheMap.set(cacheKey, { + token: data.access_token, + expiresAt: Date.now() + expiresIn, + }); + + return data.access_token; +} + +/** + * Clear token cache for an app + */ +export function clearTokenCache(appId: string): void { + tokenCacheMap.delete(appId); +} + +/** + * Get WebSocket Gateway URL + */ +export async function getGatewayUrl( + token: string, + fetcher: QQFetch = fetch, +): Promise { + const response = await fetcher(`${API_BASE}/gateway`, { + headers: { + Authorization: `QQBot ${token}`, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new QQApiException(`Failed to get gateway URL: ${response.status} ${text}`); + } + + const data = (await response.json()) as GatewayResponse; + return data.url; +} + +/** + * Send message to C2C (single chat) + */ +export async function sendC2CMessage( + token: string, + openId: string, + request: SendMessageRequest, + fetcher: QQFetch = fetch, +): Promise { + return sendMessage(token, `/v2/users/${openId}/messages`, request, fetcher); +} + +/** + * Send message to group + */ +export async function sendGroupMessage( + token: string, + groupOpenId: string, + request: SendMessageRequest, + fetcher: QQFetch = fetch, +): Promise { + return sendMessage(token, `/v2/groups/${groupOpenId}/messages`, request, fetcher); +} + +/** + * Send message to channel + */ +export async function sendChannelMessage( + token: string, + channelId: string, + request: SendMessageRequest, + fetcher: QQFetch = fetch, +): Promise { + return sendMessage(token, `/channels/${channelId}/messages`, request, fetcher); +} + +/** + * Send message to DMS (频道私信) + */ +export async function sendDmsMessage( + token: string, + guildId: string, + request: SendMessageRequest, + fetcher: QQFetch = fetch, +): Promise { + return sendMessage(token, `/dms/${guildId}/messages`, request, fetcher); +} + +export interface SendMessageResult { + ok: boolean; + messageId?: string; + timestamp?: string; + error?: string; +} + +async function sendMessage( + token: string, + path: string, + request: SendMessageRequest, + fetcher: QQFetch, +): Promise { + try { + const response = await fetcher(`${API_BASE}${path}`, { + method: "POST", + headers: { + Authorization: `QQBot ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as QQApiError; + return { + ok: false, + error: errorData.message || `HTTP ${response.status}`, + }; + } + + const data = (await response.json()) as SendMessageResponse; + return { + ok: true, + messageId: data.id, + timestamp: data.timestamp, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * QQ API Exception + */ +export class QQApiException extends Error { + constructor( + message: string, + public code?: number, + public data?: unknown, + ) { + super(message); + this.name = "QQApiException"; + } +} diff --git a/extensions/qq/src/channel.ts b/extensions/qq/src/channel.ts new file mode 100644 index 000000000..16761aefd --- /dev/null +++ b/extensions/qq/src/channel.ts @@ -0,0 +1,416 @@ +/** + * QQ Bot Channel Plugin + * + * Main channel plugin implementation for QQ Bot. + */ + +import type { + ChannelAccountSnapshot, + ChannelDock, + ChannelPlugin, + MoltbotConfig, +} from "clawdbot/plugin-sdk"; +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + setAccountEnabledInConfigSection, +} from "clawdbot/plugin-sdk"; + +import { + listQQAccountIds, + resolveDefaultQQAccountId, + resolveQQAccount, + type ResolvedQQAccount, +} from "./accounts.js"; +import { QQConfigSchema } from "./config-schema.js"; +import { probeQQ } from "./probe.js"; +import { sendMessageQQ } from "./send.js"; + +const meta = { + id: "qq", + label: "QQ", + selectionLabel: "QQ (Official Bot API)", + docsPath: "/channels/qq", + docsLabel: "qq", + blurb: "QQ 机器人官方 API(支持单聊、群聊)", + aliases: ["qq"], + order: 85, + quickstartAllowFrom: true, +}; + +function normalizeQQMessagingTarget(raw: string): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) return undefined; + // Remove qq: or group: prefix + return trimmed.replace(/^(qq|group):/i, ""); +} + +export const qqDock: ChannelDock = { + id: "qq", + capabilities: { + chatTypes: ["direct", "group"], + media: true, + blockStreaming: true, + }, + outbound: { textChunkLimit: 2000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + ( + resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }).config + .allowFrom ?? [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^(qq|group):/i, "")) + .map((entry) => entry.toLowerCase()), + }, + groups: { + resolveRequireMention: () => true, + }, + threading: { + resolveReplyToMode: () => "off", + }, +}; + +export const qqPlugin: ChannelPlugin = { + id: "qq", + meta, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: false, + threads: false, + polls: false, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.qq"] }, + configSchema: buildChannelConfigSchema(QQConfigSchema), + config: { + listAccountIds: (cfg) => listQQAccountIds(cfg as MoltbotConfig), + resolveAccount: (cfg, accountId) => + resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }), + defaultAccountId: (cfg) => + resolveDefaultQQAccountId(cfg as MoltbotConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as MoltbotConfig, + sectionKey: "qq", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as MoltbotConfig, + sectionKey: "qq", + accountId, + clearBaseFields: ["appId", "appSecret", "name"], + }), + isConfigured: (account) => + Boolean(account.appId?.trim() && account.appSecret?.trim()), + describeAccount: (account): ChannelAccountSnapshot => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.appId?.trim() && account.appSecret?.trim()), + tokenSource: account.tokenSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + ( + resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }).config + .allowFrom ?? [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^(qq|group):/i, "")) + .map((entry) => entry.toLowerCase()), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = + accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + (cfg as MoltbotConfig).channels?.qq?.accounts?.[resolvedAccountId], + ); + const basePath = useAccountPath + ? `channels.qq.accounts.${resolvedAccountId}.` + : "channels.qq."; + return { + policy: account.config.dmPolicy ?? "open", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("qq"), + normalizeEntry: (raw) => raw.replace(/^(qq|group):/i, ""), + }; + }, + }, + groups: { + resolveRequireMention: () => true, + }, + threading: { + resolveReplyToMode: () => "off", + }, + messaging: { + normalizeTarget: normalizeQQMessagingTarget, + targetResolver: { + looksLikeId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return false; + // QQ OpenIDs are typically hex strings + return /^[A-F0-9]{32}$/i.test(trimmed) || /^\d{5,}$/.test(trimmed); + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const account = resolveQQAccount({ + cfg: cfg as MoltbotConfig, + accountId, + }); + const q = query?.trim().toLowerCase() || ""; + const peers = Array.from( + new Set( + (account.config.allowFrom ?? []) + .map((entry) => String(entry).trim()) + .filter((entry) => Boolean(entry) && entry !== "*") + .map((entry) => entry.replace(/^(qq|group):/i, "")), + ), + ) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id }) as const); + return peers; + }, + listGroups: async () => [], + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as MoltbotConfig, + channelKey: "qq", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "QQ_APP_ID/QQ_APP_SECRET can only be used for the default account."; + } + if (!input.useEnv && (!input.appId || !input.appSecret)) { + return "QQ requires appId and appSecret (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as MoltbotConfig, + channelKey: "qq", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: "qq", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + qq: { + ...next.channels?.qq, + enabled: true, + ...(input.useEnv + ? {} + : { + appId: input.appId, + appSecret: input.appSecret, + }), + }, + }, + } as MoltbotConfig; + } + return { + ...next, + channels: { + ...next.channels, + qq: { + ...next.channels?.qq, + enabled: true, + accounts: { + ...(next.channels?.qq?.accounts ?? {}), + [accountId]: { + ...(next.channels?.qq?.accounts?.[accountId] ?? {}), + enabled: true, + appId: input.appId, + appSecret: input.appSecret, + }, + }, + }, + }, + } as MoltbotConfig; + }, + }, + pairing: { + idLabel: "qqOpenId", + normalizeAllowEntry: (entry) => entry.replace(/^(qq|group):/i, ""), + notifyApproval: async ({ cfg, id }) => { + const account = resolveQQAccount({ cfg: cfg as MoltbotConfig }); + if (!account.appId || !account.appSecret) { + throw new Error("QQ appId/appSecret not configured"); + } + await sendMessageQQ("c2c", id, PAIRING_APPROVED_MESSAGE, { + appId: account.appId, + appSecret: account.appSecret, + }); + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => { + if (!text) return []; + if (limit <= 0 || text.length <= limit) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > limit) { + const window = remaining.slice(0, limit); + const lastNewline = window.lastIndexOf("\n"); + const lastSpace = window.lastIndexOf(" "); + let breakIdx = lastNewline > 0 ? lastNewline : lastSpace; + if (breakIdx <= 0) breakIdx = limit; + const rawChunk = remaining.slice(0, breakIdx); + const chunk = rawChunk.trimEnd(); + if (chunk.length > 0) chunks.push(chunk); + const brokeOnSeparator = + breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); + const nextStart = Math.min( + remaining.length, + breakIdx + (brokeOnSeparator ? 1 : 0), + ); + remaining = remaining.slice(nextStart).trimStart(); + } + if (remaining.length) chunks.push(remaining); + return chunks; + }, + chunkerMode: "text", + textChunkLimit: 2000, + sendText: async ({ to, text, accountId, cfg }) => { + // Determine chat type from target format + const isGroup = to.startsWith("group:"); + const targetId = to.replace(/^(qq:|group:)/i, ""); + const chatType = isGroup ? "group" : "c2c"; + + const result = await sendMessageQQ(chatType, targetId, text, { + accountId: accountId ?? undefined, + cfg: cfg as MoltbotConfig, + }); + return { + channel: "qq", + ok: result.ok, + messageId: result.messageId ?? "", + error: result.error ? new Error(result.error) : undefined, + }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { + // TODO: Implement media sending via QQ rich media API + // For now, just send text with media URL + const isGroup = to.startsWith("group:"); + const targetId = to.replace(/^(qq:|group:)/i, ""); + const chatType = isGroup ? "group" : "c2c"; + + const messageText = mediaUrl ? `${text}\n${mediaUrl}` : text; + const result = await sendMessageQQ(chatType, targetId, messageText, { + accountId: accountId ?? undefined, + cfg: cfg as MoltbotConfig, + }); + return { + channel: "qq", + ok: result.ok, + messageId: result.messageId ?? "", + error: result.error ? new Error(result.error) : undefined, + }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: async () => [], + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + running: snapshot.running ?? false, + mode: "websocket", + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + account.appId && account.appSecret + ? probeQQ(account.appId, account.appSecret, timeoutMs) + : { ok: false, error: "appId/appSecret not configured" }, + buildAccountSnapshot: ({ account, runtime }) => { + const configured = Boolean( + account.appId?.trim() && account.appSecret?.trim(), + ); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + mode: "websocket", + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + dmPolicy: account.config.dmPolicy ?? "open", + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + if (!account.appId?.trim() || !account.appSecret?.trim()) { + throw new Error("QQ appId and appSecret are required"); + } + + ctx.log?.info(`[${account.accountId}] Starting QQ provider`); + + const { monitorQQProvider } = await import("./monitor.js"); + return monitorQQProvider({ + account, + config: ctx.cfg as MoltbotConfig, + abortSignal: ctx.abortSignal, + statusSink: (patch) => + ctx.setStatus({ accountId: ctx.accountId, ...patch }), + log: ctx.log, + }); + }, + }, +}; diff --git a/extensions/qq/src/config-schema.ts b/extensions/qq/src/config-schema.ts new file mode 100644 index 000000000..137bf1e18 --- /dev/null +++ b/extensions/qq/src/config-schema.ts @@ -0,0 +1,41 @@ +/** + * QQ Bot Configuration Schema + */ + +import { Type } from "@sinclair/typebox"; + +const StringEnum = (values: [...T]) => + Type.Unsafe({ type: "string", enum: values }); + +export const QQAccountConfigSchema = Type.Object({ + enabled: Type.Optional(Type.Boolean()), + appId: Type.Optional(Type.String()), + appSecret: Type.Optional(Type.String()), + name: Type.Optional(Type.String()), + allowFrom: Type.Optional(Type.Array(Type.String())), + dmPolicy: Type.Optional(StringEnum(["open", "pairing", "disabled"])), + intents: Type.Optional(Type.Number()), + sandbox: Type.Optional(Type.Boolean()), +}); + +export const QQConfigSchema = Type.Object({ + ...QQAccountConfigSchema.properties, + accounts: Type.Optional( + Type.Record(Type.String(), QQAccountConfigSchema), + ), +}); + +export type QQAccountConfig = { + enabled?: boolean; + appId?: string; + appSecret?: string; + name?: string; + allowFrom?: string[]; + dmPolicy?: "open" | "pairing" | "disabled"; + intents?: number; + sandbox?: boolean; +}; + +export type QQConfig = QQAccountConfig & { + accounts?: Record; +}; diff --git a/extensions/qq/src/monitor.ts b/extensions/qq/src/monitor.ts new file mode 100644 index 000000000..baa255fdc --- /dev/null +++ b/extensions/qq/src/monitor.ts @@ -0,0 +1,534 @@ +/** + * QQ Bot WebSocket Monitor + * + * Handles WebSocket connection to QQ Bot Gateway for receiving events. + */ + +import WebSocket from "ws"; +import type { MoltbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk"; + +import type { ResolvedQQAccount } from "./accounts.js"; +import { + getAccessToken, + getGatewayUrl, + sendC2CMessage, + sendGroupMessage, +} from "./api.js"; +import { getQQRuntime } from "./runtime.js"; +import { chunkQQText } from "./send.js"; +import { + OpCode, + EventType, + DEFAULT_INTENTS, + type GatewayPayload, + type HelloData, + type ReadyData, + type QQMessageEvent, + type IdentifyData, + type ResumeData, +} from "./types.js"; + +export interface QQMonitorOptions { + account: ResolvedQQAccount; + config: MoltbotConfig; + abortSignal: AbortSignal; + statusSink?: (patch: { + lastInboundAt?: number; + lastOutboundAt?: number; + sessionId?: string; + }) => void; + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + }; +} + +export interface QQMonitorResult { + stop: () => void; +} + +const QQ_TEXT_LIMIT = 2000; +const DEFAULT_MEDIA_MAX_MB = 5; + +type QQCoreRuntime = ReturnType; + +/** + * Start QQ Bot WebSocket monitor + */ +export async function monitorQQProvider( + options: QQMonitorOptions, +): Promise { + const { account, config, abortSignal, statusSink, log } = options; + + if (!account.appId || !account.appSecret) { + throw new Error("QQ appId and appSecret are required"); + } + + const core = getQQRuntime(); + let stopped = false; + let ws: WebSocket | null = null; + let heartbeatInterval: ReturnType | null = null; + let sessionId: string | null = null; + let lastSeq: number | null = null; + let reconnectAttempts = 0; + const maxReconnectAttempts = 10; + + const stop = () => { + stopped = true; + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + if (ws) { + ws.close(); + ws = null; + } + }; + + abortSignal.addEventListener("abort", stop, { once: true }); + + const connect = async () => { + if (stopped || abortSignal.aborted) return; + + try { + const token = await getAccessToken(account.appId!, account.appSecret!); + const gatewayUrl = await getGatewayUrl(token); + + log?.info(`[${account.accountId}] Connecting to gateway: ${gatewayUrl}`); + + ws = new WebSocket(gatewayUrl); + + ws.on("open", () => { + log?.info(`[${account.accountId}] WebSocket connected`); + reconnectAttempts = 0; + }); + + ws.on("message", async (data) => { + try { + const payload = JSON.parse(data.toString()) as GatewayPayload; + await handlePayload(payload, token); + } catch (err) { + log?.error( + `[${account.accountId}] Failed to parse message: ${String(err)}`, + ); + } + }); + + ws.on("close", (code, reason) => { + log?.info( + `[${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`, + ); + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + scheduleReconnect(); + }); + + ws.on("error", (err) => { + log?.error(`[${account.accountId}] WebSocket error: ${String(err)}`); + }); + } catch (err) { + log?.error(`[${account.accountId}] Failed to connect: ${String(err)}`); + scheduleReconnect(); + } + }; + + const scheduleReconnect = () => { + if (stopped || abortSignal.aborted) return; + if (reconnectAttempts >= maxReconnectAttempts) { + log?.error(`[${account.accountId}] Max reconnect attempts reached`); + return; + } + + reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); + log?.info( + `[${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`, + ); + setTimeout(connect, delay); + }; + + const handlePayload = async ( + payload: GatewayPayload, + token: string, + ): Promise => { + // Update sequence number + if (payload.s !== undefined) { + lastSeq = payload.s; + } + + switch (payload.op) { + case OpCode.Hello: { + const hello = payload.d as HelloData; + log?.info( + `[${account.accountId}] Received Hello, heartbeat interval: ${hello.heartbeat_interval}ms`, + ); + + // Start heartbeat + if (heartbeatInterval) clearInterval(heartbeatInterval); + heartbeatInterval = setInterval(() => { + sendHeartbeat(); + }, hello.heartbeat_interval); + + // Send Identify or Resume + if (sessionId && lastSeq !== null) { + sendResume(token); + } else { + sendIdentify(token); + } + break; + } + + case OpCode.HeartbeatAck: + // Heartbeat acknowledged + break; + + case OpCode.Dispatch: + statusSink?.({ lastInboundAt: Date.now() }); + await handleEvent(payload.t!, payload.d, token); + break; + + case OpCode.Reconnect: + log?.info(`[${account.accountId}] Received Reconnect, reconnecting...`); + ws?.close(); + break; + + case OpCode.InvalidSession: { + const resumable = payload.d as boolean; + log?.info( + `[${account.accountId}] Invalid session, resumable: ${resumable}`, + ); + if (!resumable) { + sessionId = null; + lastSeq = null; + } + // Wait a bit before reconnecting + setTimeout(() => { + if (resumable && sessionId) { + sendResume(token); + } else { + sendIdentify(token); + } + }, 1000 + Math.random() * 4000); + break; + } + } + }; + + const sendHeartbeat = () => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + const payload: GatewayPayload = { + op: OpCode.Heartbeat, + d: lastSeq, + }; + + ws.send(JSON.stringify(payload)); + }; + + const sendIdentify = async (token: string) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + const intents = account.config.intents ?? DEFAULT_INTENTS; + + const payload: GatewayPayload = { + op: OpCode.Identify, + d: { + token: `QQBot ${token}`, + intents, + properties: { + $os: "linux", + $browser: "moltbot", + $device: "moltbot", + }, + }, + }; + + log?.info(`[${account.accountId}] Sending Identify with intents: ${intents}`); + ws.send(JSON.stringify(payload)); + }; + + const sendResume = (token: string) => { + if (!ws || ws.readyState !== WebSocket.OPEN || !sessionId) return; + + const payload: GatewayPayload = { + op: OpCode.Resume, + d: { + token: `QQBot ${token}`, + session_id: sessionId, + seq: lastSeq ?? 0, + }, + }; + + log?.info(`[${account.accountId}] Sending Resume for session: ${sessionId}`); + ws.send(JSON.stringify(payload)); + }; + + const handleEvent = async ( + eventType: string, + data: unknown, + token: string, + ): Promise => { + switch (eventType) { + case EventType.READY: { + const ready = data as ReadyData; + sessionId = ready.session_id; + statusSink?.({ sessionId }); + log?.info( + `[${account.accountId}] Ready! Bot: ${ready.user.username} (${ready.user.id})`, + ); + break; + } + + case EventType.RESUMED: + log?.info(`[${account.accountId}] Session resumed`); + break; + + case EventType.C2C_MESSAGE_CREATE: + await handleC2CMessage(data as QQMessageEvent, token); + break; + + case EventType.GROUP_AT_MESSAGE_CREATE: + await handleGroupMessage(data as QQMessageEvent, token); + break; + + case EventType.DIRECT_MESSAGE_CREATE: + case EventType.AT_MESSAGE_CREATE: + case EventType.MESSAGE_CREATE: + // TODO: Implement channel message handling + log?.info(`[${account.accountId}] Received channel event: ${eventType}`); + break; + + default: + log?.info(`[${account.accountId}] Unhandled event: ${eventType}`); + } + }; + + const handleC2CMessage = async ( + message: QQMessageEvent, + token: string, + ): Promise => { + const senderId = message.author.user_openid; + if (!senderId) return; + + log?.info( + `[${account.accountId}] C2C message from ${senderId}: ${message.content?.slice(0, 50)}`, + ); + + await processMessageWithPipeline({ + message, + token, + chatType: "c2c", + chatId: senderId, + senderId, + isGroup: false, + }); + }; + + const handleGroupMessage = async ( + message: QQMessageEvent, + token: string, + ): Promise => { + const groupId = message.group_openid; + const senderId = message.author.member_openid; + if (!groupId || !senderId) return; + + log?.info( + `[${account.accountId}] Group message in ${groupId} from ${senderId}: ${message.content?.slice(0, 50)}`, + ); + + await processMessageWithPipeline({ + message, + token, + chatType: "group", + chatId: groupId, + senderId, + isGroup: true, + }); + }; + + const processMessageWithPipeline = async (params: { + message: QQMessageEvent; + token: string; + chatType: "c2c" | "group"; + chatId: string; + senderId: string; + isGroup: boolean; + }): Promise => { + const { message, token, chatType, chatId, senderId, isGroup } = params; + + const rawBody = message.content?.trim() || ""; + if (!rawBody) return; + + const dmPolicy = account.config.dmPolicy ?? "open"; + const configAllowFrom = (account.config.allowFrom ?? []).map((v) => + String(v), + ); + + // Check authorization for DMs + if (!isGroup && dmPolicy !== "open") { + const allowed = + configAllowFrom.includes("*") || + configAllowFrom.some( + (entry) => entry.toLowerCase() === senderId.toLowerCase(), + ); + + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "qq", + id: senderId, + meta: {}, + }); + + if (created) { + log?.info(`[${account.accountId}] Pairing request from ${senderId}`); + const replyText = core.channel.pairing.buildPairingReply({ + channel: "qq", + idLine: `Your QQ OpenID: ${senderId}`, + code, + }); + + await sendC2CMessage(token, senderId, { + content: replyText, + msg_type: 0, + msg_id: message.id, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } + } + return; + } + } + + // Resolve agent route + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "qq", + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: chatId, + }, + }); + + const fromLabel = isGroup ? `group:${chatId}` : `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + + const body = core.channel.reply.formatAgentEnvelope({ + channel: "QQ", + from: fromLabel, + timestamp: new Date(message.timestamp).getTime(), + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: isGroup ? `qq:group:${chatId}` : `qq:${senderId}`, + To: `qq:${chatId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: fromLabel, + SenderId: senderId, + Provider: "qq", + Surface: "qq", + MessageSid: message.id, + OriginatingChannel: "qq", + OriginatingTo: `qq:${chatId}`, + }); + + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + log?.error(`[${account.accountId}] Failed updating session: ${String(err)}`); + }, + }); + + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "qq", + accountId: account.accountId, + }); + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + await deliverQQReply({ + payload, + token, + chatType, + chatId, + msgId: message.id, + tableMode, + }); + }, + onError: (err, info) => { + log?.error( + `[${account.accountId}] QQ ${info.kind} reply failed: ${String(err)}`, + ); + }, + }, + }); + }; + + const deliverQQReply = async (params: { + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + token: string; + chatType: "c2c" | "group"; + chatId: string; + msgId?: string; + tableMode?: MarkdownTableMode; + }): Promise => { + const { payload, token, chatType, chatId, msgId } = params; + const tableMode = params.tableMode ?? "code"; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + + if (!text) return; + + const chunkMode = core.channel.text.resolveChunkMode(config, "qq", account.accountId); + const chunks = core.channel.text.chunkMarkdownTextWithMode( + text, + QQ_TEXT_LIMIT, + chunkMode, + ); + + const sendFn = chatType === "c2c" ? sendC2CMessage : sendGroupMessage; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + try { + await sendFn(token, chatId, { + content: chunk, + msg_type: 0, + msg_id: i === 0 ? msgId : undefined, // Only use msg_id for first chunk + msg_seq: i + 1, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + log?.error(`[${account.accountId}] QQ message send failed: ${String(err)}`); + } + } + }; + + // Start initial connection + await connect(); + + return { stop }; +} diff --git a/extensions/qq/src/probe.ts b/extensions/qq/src/probe.ts new file mode 100644 index 000000000..75e8b2396 --- /dev/null +++ b/extensions/qq/src/probe.ts @@ -0,0 +1,46 @@ +/** + * QQ Bot Probe - Verify token validity + */ + +import { getAccessToken, getGatewayUrl, type QQFetch } from "./api.js"; + +export interface ProbeResult { + ok: boolean; + error?: string; + gatewayUrl?: string; +} + +/** + * Probe QQ Bot connection by verifying token and getting gateway URL + */ +export async function probeQQ( + appId: string, + appSecret: string, + timeoutMs = 5000, + fetcher?: QQFetch, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const fetchWithTimeout: QQFetch = (input, init) => + (fetcher ?? fetch)(input, { ...init, signal: controller.signal }); + + try { + const token = await getAccessToken(appId, appSecret, fetchWithTimeout); + const gatewayUrl = await getGatewayUrl(token, fetchWithTimeout); + + clearTimeout(timeoutId); + + return { + ok: true, + gatewayUrl, + }; + } catch (err) { + clearTimeout(timeoutId); + + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/extensions/qq/src/runtime.ts b/extensions/qq/src/runtime.ts new file mode 100644 index 000000000..b29193919 --- /dev/null +++ b/extensions/qq/src/runtime.ts @@ -0,0 +1,18 @@ +/** + * QQ Bot Runtime Context + */ + +import type { MoltbotCoreRuntime } from "clawdbot/plugin-sdk"; + +let qqRuntime: MoltbotCoreRuntime | null = null; + +export function setQQRuntime(runtime: MoltbotCoreRuntime): void { + qqRuntime = runtime; +} + +export function getQQRuntime(): MoltbotCoreRuntime { + if (!qqRuntime) { + throw new Error("QQ runtime not initialized"); + } + return qqRuntime; +} diff --git a/extensions/qq/src/send.ts b/extensions/qq/src/send.ts new file mode 100644 index 000000000..ddbbc88e0 --- /dev/null +++ b/extensions/qq/src/send.ts @@ -0,0 +1,117 @@ +/** + * QQ Bot Message Sending + */ + +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; +import { + getAccessToken, + sendC2CMessage, + sendGroupMessage, + sendChannelMessage, + sendDmsMessage, + type SendMessageResult, +} from "./api.js"; +import { resolveQQAccount } from "./accounts.js"; + +const QQ_TEXT_LIMIT = 2000; + +export interface SendQQMessageOptions { + accountId?: string; + cfg?: MoltbotConfig; + appId?: string; + appSecret?: string; + msgId?: string; + msgSeq?: number; + mediaUrl?: string; +} + +export type ChatType = "c2c" | "group" | "channel" | "dms"; + +/** + * Send a message via QQ Bot API + */ +export async function sendMessageQQ( + chatType: ChatType, + targetId: string, + text: string | undefined, + options: SendQQMessageOptions, +): Promise { + const { accountId, cfg, msgId, msgSeq } = options; + + let appId = options.appId; + let appSecret = options.appSecret; + + // Resolve from config if not provided directly + if ((!appId || !appSecret) && cfg) { + const account = resolveQQAccount({ cfg, accountId }); + appId = account.appId; + appSecret = account.appSecret; + } + + if (!appId || !appSecret) { + return { ok: false, error: "QQ appId or appSecret not configured" }; + } + + try { + const token = await getAccessToken(appId, appSecret); + + const request = { + content: text, + msg_type: 0, // text message + msg_id: msgId, + msg_seq: msgSeq, + }; + + switch (chatType) { + case "c2c": + return sendC2CMessage(token, targetId, request); + case "group": + return sendGroupMessage(token, targetId, request); + case "channel": + return sendChannelMessage(token, targetId, request); + case "dms": + return sendDmsMessage(token, targetId, request); + default: + return { ok: false, error: `Unknown chat type: ${chatType}` }; + } + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Chunk text for QQ message limit + */ +export function chunkQQText(text: string, limit = QQ_TEXT_LIMIT): string[] { + if (!text) return []; + if (text.length <= limit) return [text]; + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > limit) { + const window = remaining.slice(0, limit); + const lastNewline = window.lastIndexOf("\n"); + const lastSpace = window.lastIndexOf(" "); + let breakIdx = lastNewline > 0 ? lastNewline : lastSpace; + if (breakIdx <= 0) breakIdx = limit; + + const rawChunk = remaining.slice(0, breakIdx); + const chunk = rawChunk.trimEnd(); + if (chunk.length > 0) chunks.push(chunk); + + const brokeOnSeparator = + breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); + const nextStart = Math.min( + remaining.length, + breakIdx + (brokeOnSeparator ? 1 : 0), + ); + remaining = remaining.slice(nextStart).trimStart(); + } + + if (remaining.length) chunks.push(remaining); + return chunks; +} diff --git a/extensions/qq/src/types.ts b/extensions/qq/src/types.ts new file mode 100644 index 000000000..5970a8a20 --- /dev/null +++ b/extensions/qq/src/types.ts @@ -0,0 +1,193 @@ +/** + * QQ Bot API Types + */ + +// WebSocket OpCodes +export const OpCode = { + /** Server push event */ + Dispatch: 0, + /** Client/Server heartbeat */ + Heartbeat: 1, + /** Client identify */ + Identify: 2, + /** Client resume */ + Resume: 6, + /** Server reconnect hint */ + Reconnect: 7, + /** Server invalid session */ + InvalidSession: 9, + /** Server hello */ + Hello: 10, + /** Server heartbeat ack */ + HeartbeatAck: 11, + /** Server HTTP callback ack */ + HTTPCallbackAck: 12, +} as const; + +export type OpCodeType = (typeof OpCode)[keyof typeof OpCode]; + +// Gateway Payload +export interface GatewayPayload { + op: OpCodeType; + d?: T; + s?: number; + t?: string; + id?: string; +} + +// Hello Data +export interface HelloData { + heartbeat_interval: number; +} + +// Ready Data +export interface ReadyData { + version: number; + session_id: string; + user: { + id: string; + username: string; + bot: boolean; + }; + shard: [number, number]; +} + +// Identify Payload +export interface IdentifyData { + token: string; + intents: number; + shard?: [number, number]; + properties?: { + $os?: string; + $browser?: string; + $device?: string; + }; +} + +// Resume Payload +export interface ResumeData { + token: string; + session_id: string; + seq: number; +} + +// Message Author +export interface MessageAuthor { + user_openid?: string; + member_openid?: string; + id?: string; + username?: string; + bot?: boolean; +} + +// Message Attachment +export interface MessageAttachment { + content_type: string; + filename?: string; + height?: number; + width?: number; + size?: number; + url: string; +} + +// Inbound Message Event +export interface QQMessageEvent { + id: string; + author: MessageAuthor; + content: string; + timestamp: string; + group_openid?: string; + guild_id?: string; + channel_id?: string; + attachments?: MessageAttachment[]; + msg_seq?: number; +} + +// Access Token Response +export interface AccessTokenResponse { + access_token: string; + expires_in: string; +} + +// Gateway URL Response +export interface GatewayResponse { + url: string; +} + +// Send Message Request +export interface SendMessageRequest { + content?: string; + msg_type: number; // 0=text, 2=markdown, 3=ark, 4=embed, 7=media + msg_id?: string; + msg_seq?: number; + event_id?: string; + markdown?: MarkdownPayload; + keyboard?: unknown; + ark?: unknown; + media?: MediaPayload; +} + +export interface MarkdownPayload { + content: string; +} + +export interface MediaPayload { + file_info: string; +} + +// Send Message Response +export interface SendMessageResponse { + id: string; + timestamp: string; +} + +// API Error Response +export interface QQApiError { + code: number; + message: string; + data?: unknown; +} + +// Intents Flags +export const Intents = { + /** 频道相关 */ + GUILDS: 1 << 0, + GUILD_MEMBERS: 1 << 1, + GUILD_MESSAGES: 1 << 9, + GUILD_MESSAGE_REACTIONS: 1 << 10, + DIRECT_MESSAGE: 1 << 12, + /** 群聊 @ 机器人 */ + GROUP_AT_MESSAGE_CREATE: 1 << 25, + /** 单聊消息 */ + C2C_MESSAGE_CREATE: 1 << 25, + /** 公域消息 (频道 @ 机器人) */ + PUBLIC_GUILD_MESSAGES: 1 << 30, +} as const; + +// Common intents for bot +export const DEFAULT_INTENTS = + Intents.GUILDS | + Intents.GUILD_MEMBERS | + Intents.DIRECT_MESSAGE | + Intents.GROUP_AT_MESSAGE_CREATE | + Intents.C2C_MESSAGE_CREATE; + +// Event Types +export const EventType = { + // 单聊 + C2C_MESSAGE_CREATE: "C2C_MESSAGE_CREATE", + // 群聊 @ 机器人 + GROUP_AT_MESSAGE_CREATE: "GROUP_AT_MESSAGE_CREATE", + // 频道私信 + DIRECT_MESSAGE_CREATE: "DIRECT_MESSAGE_CREATE", + // 频道 @ 机器人 + AT_MESSAGE_CREATE: "AT_MESSAGE_CREATE", + // 频道全量消息 (私域) + MESSAGE_CREATE: "MESSAGE_CREATE", + // 连接就绪 + READY: "READY", + // 连接恢复 + RESUMED: "RESUMED", +} as const; + +export type EventTypeType = (typeof EventType)[keyof typeof EventType];