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
This commit is contained in:
dongxiaolong 2026-01-29 13:25:08 +08:00
parent 34291321b4
commit ce07a19b61
12 changed files with 1217 additions and 3 deletions

139
docs/channels/kook.md Normal file
View File

@ -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:<agentId>:kook:channel:<channelId>`).
## 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:<agentId>:kook:channel:<channelId>`)
- 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/)

86
extensions/kook/README.md Normal file
View File

@ -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

View File

@ -0,0 +1,11 @@
{
"id": "kook",
"channels": [
"kook"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

26
extensions/kook/index.ts Normal file
View File

@ -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;

View File

@ -0,0 +1,11 @@
{
"name": "@moltbot/kook",
"version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Kook channel plugin",
"moltbot": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -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<typeof import("ws").default> {
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<string, unknown>;
};
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<string, unknown>) => void;
log: (...args: unknown[]) => void;
}): Promise<void> {
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<number, unknown>();
let ws: InstanceType<typeof import("ws").default> | null = null;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const updateStatus = (patch: Record<string, unknown>) => {
onStatus(patch);
};
const setStatus = (status: number) => {
currentStatus = status;
updateStatus({ running: status === STATUS_CONNECTED });
log(`Kook status: ${status}`);
};
// 获取 Gateway
const getGateway = async (): Promise<string> => {
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<string> => {
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<string, unknown>; sn?: number } | null => {
try {
return JSON.parse(data.toString());
} catch {
return null;
}
};
// 处理消息
const handleMessage = async (msg: { s: number; d?: Record<string, unknown>; 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<string, unknown> });
}
}
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<string, unknown> }) => {
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<string, unknown>;
// 跳过系统消息
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<string, unknown> | 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<void> => {
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<ResolvedKookAccount> = {
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: "<channelId|user:ID>",
},
},
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 <channelId|user:ID>"),
};
}
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(" ")}`),
});
},
},
};

View File

@ -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, string> = {
[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]: "多账户配置",
};

View File

@ -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 "*"',
});
}
}
});

View File

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

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
import type { z } from "zod";
import type { KookConfigSchema } from "./config-schema.js";
export type KookConfig = z.infer<typeof KookConfigSchema>;

8
pnpm-lock.yaml generated
View File

@ -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