From ce07a19b619b5cebb955d08161ba4fc8cc3ebf35 Mon Sep 17 00:00:00 2001 From: dongxiaolong Date: Thu, 29 Jan 2026 13:25:08 +0800 Subject: [PATCH] feat(channels): add Kook channel plugin - Add Kook chat platform integration via WebSocket - Support private messages and channel messages - Implement user allowlist (allowedUserId) for security - Add automatic message chunking for long messages - Include auto-reconnection mechanism - Add comprehensive documentation --- docs/channels/kook.md | 139 +++++ extensions/kook/README.md | 86 +++ extensions/kook/clawdbot.plugin.json | 11 + extensions/kook/index.ts | 26 + extensions/kook/package.json | 11 + extensions/kook/src/channel.ts | 785 +++++++++++++++++++++++++++ extensions/kook/src/config-keys.ts | 57 ++ extensions/kook/src/config-schema.ts | 47 ++ extensions/kook/src/probe.ts | 32 ++ extensions/kook/src/runtime.ts | 14 + extensions/kook/src/types.ts | 4 + pnpm-lock.yaml | 8 +- 12 files changed, 1217 insertions(+), 3 deletions(-) create mode 100644 docs/channels/kook.md create mode 100644 extensions/kook/README.md create mode 100644 extensions/kook/clawdbot.plugin.json create mode 100644 extensions/kook/index.ts create mode 100644 extensions/kook/package.json create mode 100644 extensions/kook/src/channel.ts create mode 100644 extensions/kook/src/config-keys.ts create mode 100644 extensions/kook/src/config-schema.ts create mode 100644 extensions/kook/src/probe.ts create mode 100644 extensions/kook/src/runtime.ts create mode 100644 extensions/kook/src/types.ts diff --git a/docs/channels/kook.md b/docs/channels/kook.md new file mode 100644 index 000000000..643deefe4 --- /dev/null +++ b/docs/channels/kook.md @@ -0,0 +1,139 @@ +--- +summary: "Kook bot support status, capabilities, and configuration" +read_when: + - Working on Kook channel features +--- +# Kook (Bot API) + + +Status: production-ready for bot DMs + channels via WebSocket. + +## Quick setup (beginner) +1) Create a bot on the [Kook Developer Platform](https://developer.kookapp.cn/bot/) and copy the token. +2) Set the token: + - Env: `KOOK_BOT_TOKEN=...` + - Or config: `channels.kook.token: "..."`. + - If both are set, config takes precedence (env fallback is default-account only). +3) Start the gateway. +4) **Security**: Set `channels.kook.allowedUserId` to restrict bot access to your user ID only. + +Minimal config: +```json5 +{ + channels: { + kook: { + enabled: true, + token: "your-bot-token", + allowedUserId: "your-user-id" // strongly recommended for security + } + } +} +``` + +## What it is +- A Kook Bot API channel owned by the Gateway. +- Deterministic routing: replies go back to Kook; the model never chooses channels. +- DMs share the agent's main session; channels stay isolated (`agent::kook:channel:`). + +## Setup (fast path) +### 1) Create a bot token +1) Visit the [Kook Developer Platform](https://developer.kookapp.cn/bot/). +2) Create a new bot application and copy the Bot Token. +3) Store the token safely. + +### 2) Configure the token (env or config) +Example: + +```json5 +{ + channels: { + kook: { + enabled: true, + token: "your-bot-token", + allowedUserId: "your-user-id" + } + } +} +``` + +Env option: `KOOK_BOT_TOKEN=...` (works for the default account). +If both env and config are set, config takes precedence. + +### 3) Get your user ID (for security) +**Method 1**: Enable Developer Mode in Kook +1. Open Kook → Personal Settings → Advanced Settings → Developer Mode → Enable +2. Right-click your avatar in any server channel → Copy ID + +**Method 2**: Check logs +Send a message to the bot and check the gateway logs for `authorId`. + +### 4) Start the gateway +```bash +moltbot gateway +``` + +Kook channel starts when a token is resolved (config first, env fallback) and `channels.kook.enabled` is not `false`. + +## Features +- **WebSocket connection**: Real-time message delivery via Kook's WebSocket API +- **Private messages**: Support for direct messages with the bot +- **Channel messages**: Support for messages in server channels +- **User allowlist**: `allowedUserId` restricts bot control to specific users (security) +- **Message chunking**: Automatically splits long messages to fit Kook's limits +- **Auto-reconnection**: Handles connection drops and reconnects automatically + +## Security +- **Strongly recommended**: Set `channels.kook.allowedUserId` to your Kook user ID +- Without `allowedUserId`, anyone who can DM the bot or is in the same channel can control it +- The bot will only respond to messages from the specified user ID(s) + +## Configuration reference + +### Core settings +```json5 +{ + channels: { + kook: { + enabled: true, // Enable/disable the Kook channel + token: "YOUR_BOT_TOKEN", // Required: Bot token from developer platform + allowedUserId: "USER_ID" // Strongly recommended: Your Kook user ID + } + } +} +``` + +### Environment variables +- `KOOK_BOT_TOKEN`: Bot token (fallback for default account) + +## Troubleshooting + +### Bot not responding +1. Check that `channels.kook.enabled` is `true` +2. Verify your bot token is correct +3. Check gateway logs for connection errors +4. Ensure `allowedUserId` matches your Kook user ID + +### Connection issues +- Kook WebSocket connection requires stable internet +- The bot automatically reconnects on connection drops +- Check gateway logs for WebSocket connection status + +### Message not sent +- Verify the bot has permission to send messages in the channel +- Check if message length exceeds limits (automatically chunked) +- Review gateway logs for delivery errors + +## Message routing +- **Private messages**: Collapse into the agent's main session (default `agent:main:main`) +- **Channel messages**: Isolated per channel (`agent::kook:channel:`) +- Replies always go back to the channel they arrived on (deterministic routing) + +## Known limitations +- Kook API rate limits apply (handled automatically with retries) +- Message formatting may differ from other platforms +- Rich media support depends on Kook API capabilities + +## See also +- [Channels overview](/channels/) +- [Configuration reference](/gateway/configuration) +- [Security guidelines](/security/) diff --git a/extensions/kook/README.md b/extensions/kook/README.md new file mode 100644 index 000000000..fb4077595 --- /dev/null +++ b/extensions/kook/README.md @@ -0,0 +1,86 @@ +# moltbot-kook + +Kook 聊天平台的 Moltbot/Clawdbot 通道插件。 + +## 安装 + +```bash +# Moltbot 用户 +moltbot plugins install @kookapp/moltbot-kook + +# 或 Clawdbot 用户 +clawdbot plugins install @kookapp/moltbot-kook +``` + +## 配置 + +### 1. 获取 Kook Bot Token + +访问 [Kook 开发者平台](https://developer.kookapp.cn/bot/) 创建机器人并获取 Bot Token。 + +### 2. 通过 Web 界面配置(推荐) + +打开 Moltbot 配置页面: + +``` +http://127.0.0.1:18789/chat +``` + +在设置中找到 Kook 通道,配置以下参数: + +| 配置项 | 必填 | 说明 | +|--------|------|------| +| `token` | **是** | Kook Bot Token | +| `allowedUserId` | **强烈推荐** | 只允许此用户 ID 控制机器人(安全设置) | + +**如何获取你的用户 ID**: +1. kook - 个人设置 - 高级设置 - 开发者模式 - 打开;kook 服务器频道中 右键自己的头像 - 复制ID 即可; +2. 或者给机器人发送一条消息,在日志中查看 `authorId`。 + +### 3. 或使用命令行配置 + +```bash +# Moltbot +moltbot config set channels.kook.token "你的Bot Token" +moltbot config set channels.kook.allowedUserId "你的用户ID" +moltbot config set channels.kook.enabled true + +# Clawdbot +clawdbot config set channels.kook.token "你的Bot Token" +clawdbot config set channels.kook.allowedUserId "你的用户ID" +clawdbot config set channels.kook.enabled true +``` + +### 4. 重启服务 + +```bash +# Moltbot +moltbot gateway restart + +# Clawdbot +clawdbot gateway restart +``` + +## 配置项说明 + +核心配置(通过 Web 界面或命令行配置): + +```yaml +channels: + kook: + enabled: true + token: "你的Bot Token" # 必填 + allowedUserId: "你的用户ID" # 强烈推荐(安全设置) +``` + +## 功能特性 + +- ✅ WebSocket 实时连接 +- ✅ 私聊和频道消息支持 +- ✅ **用户白名单**(allowedUserId)- 仅允许指定用户控制 +- ✅ 消息分块(自动处理长消息) +- ✅ 自动重连机制 + +## License + +MIT diff --git a/extensions/kook/clawdbot.plugin.json b/extensions/kook/clawdbot.plugin.json new file mode 100644 index 000000000..8445dd498 --- /dev/null +++ b/extensions/kook/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "kook", + "channels": [ + "kook" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/kook/index.ts b/extensions/kook/index.ts new file mode 100644 index 000000000..0d39183f1 --- /dev/null +++ b/extensions/kook/index.ts @@ -0,0 +1,26 @@ +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; +import { kookPlugin } from "./src/channel.js"; +import { setKookRuntime } from "./src/runtime.js"; + +export { kookPlugin } from "./src/channel.js"; +export { probeKookAccount } from "./src/probe.js"; +export { + KookConfigKey, + ENABLED_CONFIG_KEYS, + ALL_CONFIG_KEYS, + CONFIG_KEY_DESCRIPTIONS, +} from "./src/config-keys.js"; + +const plugin = { + id: "kook", + name: "Kook", + description: "Kook channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: MoltbotPluginApi) { + setKookRuntime(api.runtime); + api.registerChannel({ plugin: kookPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/kook/package.json b/extensions/kook/package.json new file mode 100644 index 000000000..d1fe7f0fc --- /dev/null +++ b/extensions/kook/package.json @@ -0,0 +1,11 @@ +{ + "name": "@moltbot/kook", + "version": "2026.1.27-beta.1", + "type": "module", + "description": "Moltbot Kook channel plugin", + "moltbot": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/kook/src/channel.ts b/extensions/kook/src/channel.ts new file mode 100644 index 000000000..43a4cf3fe --- /dev/null +++ b/extensions/kook/src/channel.ts @@ -0,0 +1,785 @@ +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(" ")}`), + }); + }, + }, +}; diff --git a/extensions/kook/src/config-keys.ts b/extensions/kook/src/config-keys.ts new file mode 100644 index 000000000..0138c05bc --- /dev/null +++ b/extensions/kook/src/config-keys.ts @@ -0,0 +1,57 @@ +/** + * Kook 插件所有可配置的配置项 + */ +export enum KookConfigKey { + // 基础配置 + ENABLED = "enabled", + TOKEN = "token", + NAME = "name", + + // 安全配置 + ALLOWED_USER_ID = "allowedUserId", + + // 私聊策略 + DM_POLICY = "dmPolicy", + ALLOW_FROM = "allowFrom", + + // 群组策略 + GROUP_POLICY = "groupPolicy", + GROUP_ALLOW_FROM = "groupAllowFrom", + REQUIRE_MENTION = "requireMention", + + // 消息配置 + TEXT_CHUNK_LIMIT = "textChunkLimit", + + // 多账户 + ACCOUNTS = "accounts", +} + +/** + * 当前启用的配置项列表(仅 token 和 allowedUserId) + */ +export const ENABLED_CONFIG_KEYS: KookConfigKey[] = [ + KookConfigKey.TOKEN, + KookConfigKey.ALLOWED_USER_ID, +]; + +/** + * 所有配置项列表 + */ +export const ALL_CONFIG_KEYS: KookConfigKey[] = Object.values(KookConfigKey); + +/** + * 配置项说明 + */ +export const CONFIG_KEY_DESCRIPTIONS: Record = { + [KookConfigKey.ENABLED]: "是否启用 Kook 通道", + [KookConfigKey.TOKEN]: "Kook Bot Token(必需)", + [KookConfigKey.NAME]: "账户名称", + [KookConfigKey.ALLOWED_USER_ID]: "只允许此用户 ID 控制机器人(强烈推荐)", + [KookConfigKey.DM_POLICY]: "私聊策略: open/pairing/allowlist", + [KookConfigKey.ALLOW_FROM]: "私聊白名单(用户 ID 列表)", + [KookConfigKey.GROUP_POLICY]: "群组策略: open/allowlist/disabled", + [KookConfigKey.GROUP_ALLOW_FROM]: "群组白名单(频道 ID 列表)", + [KookConfigKey.REQUIRE_MENTION]: "群组中是否需要 @提及机器人", + [KookConfigKey.TEXT_CHUNK_LIMIT]: "单条消息最大字符数", + [KookConfigKey.ACCOUNTS]: "多账户配置", +}; diff --git a/extensions/kook/src/config-schema.ts b/extensions/kook/src/config-schema.ts new file mode 100644 index 000000000..1335ec306 --- /dev/null +++ b/extensions/kook/src/config-schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +export { z }; + +const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]); +const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]); + +export const KookConfigSchema = z + .object({ + enabled: z.boolean().optional(), + token: z.string().optional(), + name: z.string().optional(), + allowedUserId: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groupAllowFrom: z.array(z.string()).optional(), + requireMention: z.boolean().optional().default(true), + textChunkLimit: z.number().int().positive().optional(), + accounts: z + .record( + z.string(), + z + .object({ + enabled: z.boolean().optional(), + token: z.string().optional(), + name: z.string().optional(), + allowedUserId: z.string().optional(), + config: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + ) + .optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (value.dmPolicy === "open") { + const allowFrom = value.allowFrom ?? []; + const hasWildcard = allowFrom.some((entry) => entry.trim() === "*"); + if (!hasWildcard) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: 'channels.kook.dmPolicy="open" requires channels.kook.allowFrom to include "*"', + }); + } + } + }); diff --git a/extensions/kook/src/probe.ts b/extensions/kook/src/probe.ts new file mode 100644 index 000000000..c562f8aca --- /dev/null +++ b/extensions/kook/src/probe.ts @@ -0,0 +1,32 @@ +export async function probeKookAccount(params: { + token: string; +}): Promise<{ ok: boolean; user?: { id: string; username: string }; error?: string }> { + const { token } = params; + + try { + const response = await fetch("https://www.kookapp.cn/api/v3/user/me", { + headers: { Authorization: `Bot ${token}` }, + }); + const data = await response.json(); + + if (data.code === 0) { + return { + ok: true, + user: { + id: String(data.data.id ?? ""), + username: data.data.username ?? "bot", + }, + }; + } + + return { + ok: false, + error: data.message ?? "Unknown error", + }; + } catch (err) { + return { + ok: false, + error: String(err), + }; + } +} diff --git a/extensions/kook/src/runtime.ts b/extensions/kook/src/runtime.ts new file mode 100644 index 000000000..520480cbd --- /dev/null +++ b/extensions/kook/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setKookRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getKookRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Kook runtime not initialized"); + } + return runtime; +} diff --git a/extensions/kook/src/types.ts b/extensions/kook/src/types.ts new file mode 100644 index 000000000..9f0e4f44e --- /dev/null +++ b/extensions/kook/src/types.ts @@ -0,0 +1,4 @@ +import type { z } from "zod"; +import type { KookConfigSchema } from "./config-schema.js"; + +export type KookConfig = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..b3dbf44cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,8 @@ importers: extensions/imessage: {} + extensions/kook: {} + extensions/line: devDependencies: moltbot: @@ -383,12 +385,12 @@ importers: '@microsoft/agents-hosting-extensions-teams': specifier: ^1.2.2 version: 1.2.2 - moltbot: - specifier: workspace:* - version: link:../.. express: specifier: ^5.2.1 version: 5.2.1 + moltbot: + specifier: workspace:* + version: link:../.. proper-lockfile: specifier: ^4.1.2 version: 4.1.2