/** * 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, }); }, }, };