import type { ClawdbotConfig, ReplyPayload } from "clawdbot/plugin-sdk"; import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, type ChannelPlugin, } from "clawdbot/plugin-sdk"; import { getKookRuntime } from "./runtime.js"; import type { KookConfig } from "./types.js"; import { probeKookAccount } from "./probe.js"; // 动态加载 WebSocket let wsModule: typeof import("ws") | null = null; async function getWs(): Promise { if (!wsModule) { wsModule = await import("ws"); } return wsModule.default; } function normalizeAllowEntry(entry: string): string { return entry.trim().replace(/^(kook|user):/i, "").toLowerCase(); } function getKookChatType(channelType: string): "direct" | "group" | "channel" { switch (channelType) { case "PERSON": return "direct"; case "BROADCAST": return "group"; default: return "channel"; } } const meta = { id: "kook", label: "Kook", selectionLabel: "Kook", detailLabel: "Kook Bot", docsPath: "/channels/kook", blurb: "Kook chat platform", systemImage: "bubble.left.and.bubble.right", order: 70, } as const; type ResolvedKookAccount = { accountId: string; name?: string; enabled: boolean; token?: string; tokenSource?: string; allowedUserId?: string; config: Record; }; function resolveKookAccount(params: { cfg: ClawdbotConfig; accountId?: string }): ResolvedKookAccount { const { cfg, accountId } = params; const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID; const kookCfg = cfg.channels?.kook as KookConfig | undefined; const account = kookCfg?.accounts?.[resolvedAccountId]; const token = account?.token ?? kookCfg?.token ?? process.env.KOOK_BOT_TOKEN; const tokenSource = account?.token ? "config" : kookCfg?.token ? "config" : process.env.KOOK_BOT_TOKEN ? "env" : undefined; const name = account?.name ?? kookCfg?.name; const enabled = account?.enabled ?? kookCfg?.enabled ?? true; const allowedUserId = account?.allowedUserId ?? kookCfg?.allowedUserId; return { accountId: resolvedAccountId, name, enabled, token, tokenSource, allowedUserId, config: account?.config ?? {}, }; } function listKookAccountIds(cfg: ClawdbotConfig): string[] { const kookCfg = cfg.channels?.kook as KookConfig | undefined; if (!kookCfg?.accounts) { return Object.keys(kookCfg || {}).length > 0 ? [DEFAULT_ACCOUNT_ID] : []; } return Object.keys(kookCfg.accounts); } function resolveDefaultKookAccountId(cfg: ClawdbotConfig): string { const kookCfg = cfg.channels?.kook as KookConfig | undefined; if (kookCfg?.accounts && Object.keys(kookCfg.accounts).length > 0) { return Object.keys(kookCfg.accounts)[0]; } return DEFAULT_ACCOUNT_ID; } async function sendMessageKook( targetId: string, content: string, ): Promise<{ ok: boolean; messageId?: string }> { const runtime = getKookRuntime(); const config = runtime.config.loadConfig(); const account = resolveKookAccount({ cfg: config }); const token = account.token?.trim(); if (!token) { throw new Error("Kook token is required"); } try { const response = await fetch("https://www.kookapp.cn/api/v3/message/create", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bot ${token}`, }, body: JSON.stringify({ target_id: targetId, content, type: 9, }), }); const data = await response.json(); if (data.code === 0) { return { ok: true, messageId: data.data.msg_id }; } return { ok: false }; } catch (error) { throw new Error(`Failed to send Kook message: ${error}`); } } // Kook WebSocket 监控 async function monitorKookWebSocket(opts: { botToken: string; accountId: string; config: ClawdbotConfig; abortSignal?: AbortSignal; onStatus: (status: Record) => void; log: (...args: unknown[]) => void; }): Promise { const { botToken, accountId, abortSignal, onStatus, log } = opts; // 状态常量 const STATUS_INIT = 0; const STATUS_GATEWAY = 10; const STATUS_WS_CONNECTED = 20; const STATUS_CONNECTED = 30; const _STATUS_RETRY = 40; // Reserved for future use let currentStatus = STATUS_INIT; let gatewayUrl = ""; let sessionId = ""; let selfUserId = ""; // 机器人自己的用户ID let maxSn = 0; const messageQueue = new Map(); let ws: InstanceType | null = null; let heartbeatInterval: ReturnType | null = null; let reconnectAttempts = 0; const maxReconnectAttempts = 5; const updateStatus = (patch: Record) => { onStatus(patch); }; const setStatus = (status: number) => { currentStatus = status; updateStatus({ running: status === STATUS_CONNECTED }); log(`Kook status: ${status}`); }; // 获取 Gateway const getGateway = async (): Promise => { const response = await fetch("https://www.kookapp.cn/api/v3/gateway/index?compress=0", { headers: { Authorization: `Bot ${botToken}` }, }); const data = await response.json(); if (data.code !== 0) { throw new Error(`Failed to get gateway: ${data.message}`); } return data.data.url; }; // 获取机器人自己的用户 ID const getSelfUserId = async (): Promise => { const response = await fetch("https://www.kookapp.cn/api/v3/user/me", { headers: { Authorization: `Bot ${botToken}` }, }); const data = await response.json(); if (data.code !== 0) { log(`Failed to get self user ID: ${data.message}`); return ""; } return String(data.data.id ?? ""); }; // 解析消息 const parseMessage = (data: Buffer): { s: number; d?: Record; sn?: number } | null => { try { return JSON.parse(data.toString()); } catch { return null; } }; // 处理消息 const handleMessage = async (msg: { s: number; d?: Record; sn?: number }) => { const { s, d, sn } = msg; switch (s) { case 0: // EVENT - 消息事件 if (sn !== undefined) { messageQueue.set(sn, msg); // 按顺序处理消息 while (messageQueue.has(maxSn + 1)) { maxSn++; const queuedMsg = messageQueue.get(maxSn); messageQueue.delete(maxSn); await processEvent(queuedMsg as { d: Record }); } } break; case 1: // HELLO - 握手结果 const code = d?.code as number ?? 40100; if (code === 0) { sessionId = (d?.session_id as string) ?? ""; selfUserId = String(d?.user_id ?? ""); log(`Kook connected, session: ${sessionId}, userId: ${selfUserId}`); setStatus(STATUS_CONNECTED); startHeartbeat(); } else { log(`Kook hello failed: ${code}`); if ([40100, 40101, 40102, 40103].includes(code)) { setStatus(STATUS_INIT); } } break; case 3: // PONG - 心跳响应 log("Kook pong received"); break; case 5: // RECONNECT - 服务端要求重连 log("Kook reconnect requested"); handleReconnect(); break; case 6: // RESUME ACK - Resume 成功 log("Kook resume successful"); setStatus(STATUS_CONNECTED); break; } }; // 处理事件消息 const processEvent = async (event: { d: Record }) => { const data = event.d; const channelType = data.channel_type as string; const type = data.type as number; const targetId = data.target_id as string; const authorId = data.author_id as string; const content = data.content as string; const msgId = data.msg_id as string; const extra = data.extra as Record; // 跳过系统消息 if (type === 255) return; // 跳过自己发的消息 if (authorId === selfUserId) return; // 只处理文本消息 if (type !== 1 && type !== 9) return; const runtime = getKookRuntime(); const cfg = runtime.config.loadConfig(); const account = resolveKookAccount({ cfg }); // 安全检查:如果配置了 allowedUserId,只允许该用户的消息 if (account.allowedUserId && account.allowedUserId.trim()) { if (authorId !== account.allowedUserId.trim()) { log(`Message from ${authorId} rejected: not in allowedUserId (${account.allowedUserId})`); return; } } const author = extra?.author as Record | undefined; const senderName = (author?.username as string) ?? authorId; const chatType = getKookChatType(channelType); const bodyText = content.trim(); if (!bodyText) return; // 权限检查 const groupPolicy = (account.config.groupPolicy as string) ?? "allowlist"; if (chatType !== "direct" && groupPolicy === "allowlist") { // 简化:允许所有消息 } const fromLabel = chatType === "direct" ? `Kook user ${senderName}` : `Kook user ${senderName} in channel ${targetId}`; const route = runtime.channel.routing.resolveAgentRoute({ cfg, channel: "kook", accountId, teamId: undefined, peer: { kind: chatType, id: chatType === "direct" ? authorId : targetId, }, }); const sessionKey = route.sessionKey; const to = chatType === "direct" ? `user:${authorId}` : `channel:${targetId}`; // 构建消息上下文 const ctxPayload = runtime.channel.reply.finalizeInboundContext({ Body: `${bodyText}\n[kook message id: ${msgId} channel: ${targetId}]`, RawBody: bodyText, CommandBody: bodyText, From: `kook:${authorId}`, To: to, SessionKey: sessionKey, AccountId: route.accountId, ChatType: chatType, ConversationLabel: fromLabel, SenderName: senderName, SenderId: authorId, Provider: "kook" as const, Surface: "kook" as const, MessageSid: msgId, Timestamp: data.msg_timestamp as number, }); // 创建回复分发器 const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: "", humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const text = payload.text ?? ""; const chunks = text.match(/.{1,2000}/g) ?? [text]; for (const chunk of chunks) { if (!chunk) continue; await sendMessageKook(to.replace(/^(user|channel):/, ""), chunk); } }, onError: (err, info) => { runtime.error?.(`Kook ${info.kind} reply failed: ${err}`); }, }); // 派发回复 await runtime.channel.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions, }); markDispatchIdle(); updateStatus({ lastInboundAt: Date.now() }); }; // 开始心跳 const startHeartbeat = () => { if (heartbeatInterval) clearInterval(heartbeatInterval); heartbeatInterval = setInterval(() => { if (ws && ws.readyState === 1 && currentStatus === STATUS_CONNECTED) { ws.send(JSON.stringify({ s: 2, sn: maxSn })); } }, 30000); }; // 停止心跳 const stopHeartbeat = () => { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } }; // 处理重连 const handleReconnect = () => { stopHeartbeat(); if (ws) { ws.close(); ws = null; } messageQueue.clear(); maxSn = 0; sessionId = ""; gatewayUrl = ""; setStatus(STATUS_INIT); }; // 连接 WebSocket const connect = async (resume = false): Promise => { const WS = await getWs(); // gatewayUrl 本身已包含 token,不要重复添加 const url = new URL(gatewayUrl); url.searchParams.set("compress", "0"); if (resume && sessionId) { url.searchParams.set("resume", "1"); url.searchParams.set("sn", String(maxSn)); url.searchParams.set("session_id", sessionId); } ws = new WS(url.toString()); const abortHandler = () => ws?.close(); abortSignal?.addEventListener("abort", abortHandler, { once: true }); return await new Promise((resolve, reject) => { const timeout = setTimeout(() => { ws?.close(); reject(new Error("WebSocket connection timeout")); }, 10000); ws.on("open", () => { clearTimeout(timeout); setStatus(STATUS_WS_CONNECTED); log(`Kook WebSocket ${resume ? "resumed" : "connected"}`); }); ws.on("message", async (data) => { const msg = parseMessage(data as Buffer); if (msg) { await handleMessage(msg); } }); ws.on("close", () => { abortSignal?.removeEventListener("abort", abortHandler); stopHeartbeat(); resolve(); }); ws.on("error", (err) => { clearTimeout(timeout); reject(err); }); }); }; // 主循环 const mainLoop = async () => { // 先获取机器人自己的用户 ID selfUserId = await getSelfUserId(); log(`Kook self user ID: ${selfUserId}`); while (!abortSignal?.aborted) { try { if (currentStatus === STATUS_INIT) { gatewayUrl = await getGateway(); setStatus(STATUS_GATEWAY); } if (currentStatus === STATUS_GATEWAY) { try { await connect(false); } catch (err) { log(`Kook connect error: ${err}`); reconnectAttempts++; } } if (abortSignal?.aborted) break; // 等待断开 await new Promise((resolve) => setTimeout(resolve, 2000)); // 重连逻辑 if (currentStatus !== STATUS_CONNECTED && reconnectAttempts < maxReconnectAttempts) { if (sessionId) { try { await connect(true); } catch (err) { log(`Kook resume error: ${err}`); } } } if (reconnectAttempts >= maxReconnectAttempts) { reconnectAttempts = 0; sessionId = ""; gatewayUrl = ""; setStatus(STATUS_INIT); } } catch (err) { log(`Kook main loop error: ${err}`); await new Promise((resolve) => setTimeout(resolve, 5000)); } } // 清理 stopHeartbeat(); if (ws) ws.close(); updateStatus({ running: false, lastStopAt: Date.now() }); }; // 启动 setStatus(STATUS_INIT); updateStatus({ lastStartAt: Date.now(), running: true }); log(`Kook provider started for account: ${accountId}`); mainLoop(); } export const kookPlugin: ChannelPlugin = { id: "kook", meta, pairing: { idLabel: "kookUserId", normalizeAllowEntry: (entry) => normalizeAllowEntry(entry), notifyApproval: async ({ id }) => { console.log(`[kook] User ${id} approved for pairing`); }, }, capabilities: { chatTypes: ["direct", "channel"], media: true, }, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.kook"] }, configSchema: { schema: { type: "object", additionalProperties: false, properties: { enabled: { type: "boolean" }, token: { type: "string" }, name: { type: "string" }, allowedUserId: { type: "string" }, dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] }, allowFrom: { type: "array", items: { type: "string" } }, groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] }, groupAllowFrom: { type: "array", items: { type: "string" } }, requireMention: { type: "boolean" }, textChunkLimit: { type: "integer", minimum: 1 }, accounts: { type: "object", additionalProperties: { type: "object", properties: { enabled: { type: "boolean" }, token: { type: "string" }, name: { type: "string" }, allowedUserId: { type: "string" }, config: { type: "object" }, }, }, }, }, }, }, config: { listAccountIds: (cfg) => listKookAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveKookAccount({ cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultKookAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ cfg, sectionKey: "kook", accountId, enabled, allowTopLevel: true, }), deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({ cfg, sectionKey: "kook", accountId, clearBaseFields: ["token", "name"], }), isConfigured: (account) => Boolean(account.token?.trim()), describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: Boolean(account.token?.trim()), tokenSource: account.tokenSource, }), resolveAllowFrom: () => [], formatAllowFrom: () => [], }, security: { resolveDmPolicy: () => ({ policy: "pairing" as const, allowFrom: [] as string[], policyPath: "channels.kook.dmPolicy", allowFromPath: "channels.kook.allowFrom", approveHint: formatPairingApproveHint("kook"), normalizeEntry: (raw) => normalizeAllowEntry(raw), }), collectWarnings: () => [], }, groups: { resolveRequireMention: () => false, }, messaging: { normalizeTarget: (target) => target.trim(), targetResolver: { looksLikeId: () => true, hint: "", }, }, outbound: { deliveryMode: "direct", chunker: (text, limit) => { const chunks = []; let remaining = text; while (remaining.length > limit) { chunks.push(remaining.slice(0, limit)); remaining = remaining.slice(limit); } if (remaining) chunks.push(remaining); return chunks; }, textChunkLimit: 2000, resolveTarget: ({ to }) => { const trimmed = to?.trim(); if (!trimmed) { return { ok: false, error: new Error("Delivering to Kook requires --to "), }; } return { ok: true, to: trimmed }; }, sendText: async ({ to, text }) => { const result = await sendMessageKook(to, text); return { channel: "kook", ...result }; }, sendMedia: async ({ to, text, mediaUrl }) => { const result = await sendMessageKook(to, text || mediaUrl); return { channel: "kook", ...result }; }, }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, connected: false, lastStartAt: null, lastStopAt: null, lastError: null, lastInboundAt: null, lastOutboundAt: null, }, buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, tokenSource: snapshot.tokenSource ?? "none", running: snapshot.running ?? false, connected: snapshot.connected ?? false, lastStartAt: snapshot.lastStartAt ?? null, lastStopAt: snapshot.lastStopAt ?? null, lastError: snapshot.lastError ?? null, }), probeAccount: async ({ account }) => { const token = account.token?.trim(); if (!token) { return { ok: false, error: "bot token missing" }; } return await probeKookAccount({ token }); }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: Boolean(account.token?.trim()), tokenSource: account.tokenSource, running: runtime?.running ?? false, connected: runtime?.connected ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, probe, lastInboundAt: runtime?.lastInboundAt ?? null, lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({ cfg, channelKey: "kook", accountId, name, }), validateInput: () => null, applyAccountConfig: ({ cfg, accountId, input }) => { const namedConfig = applyAccountNameToChannelSection({ cfg, channelKey: "kook", accountId, name: input.name, }); const next = accountId !== DEFAULT_ACCOUNT_ID ? migrateBaseNameToDefaultAccount({ cfg: namedConfig, channelKey: "kook", }) : namedConfig; if (accountId === DEFAULT_ACCOUNT_ID) { return { ...next, channels: { ...next.channels, kook: { ...next.channels?.kook, enabled: true, ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), }, }, }; } return { ...next, channels: { ...next.channels, kook: { ...next.channels?.kook, enabled: true, accounts: { ...next.channels?.kook?.accounts, [accountId]: { ...next.channels?.kook?.accounts?.[accountId], enabled: true, ...(input.token ? { token: input.token } : {}), }, }, }, }, }; }, }, gateway: { startAccount: async (ctx) => { const account = ctx.account; const token = account.token?.trim(); if (!token) { throw new Error(`Kook token missing for account "${account.accountId}"`); } ctx.setStatus({ accountId: account.accountId, running: true, }); ctx.log?.info(`[${account.accountId}] Starting Kook provider`); // 启动 WebSocket 监控 return monitorKookWebSocket({ botToken: token, accountId: account.accountId, config: ctx.cfg, abortSignal: ctx.abortSignal, onStatus: (status) => ctx.setStatus({ accountId: account.accountId, ...status }), log: (...args) => ctx.log?.info(`[kook] ${args.join(" ")}`), }); }, }, };