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:
parent
34291321b4
commit
ce07a19b61
139
docs/channels/kook.md
Normal file
139
docs/channels/kook.md
Normal 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
86
extensions/kook/README.md
Normal 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
|
||||||
11
extensions/kook/clawdbot.plugin.json
Normal file
11
extensions/kook/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "kook",
|
||||||
|
"channels": [
|
||||||
|
"kook"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
extensions/kook/index.ts
Normal file
26
extensions/kook/index.ts
Normal 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;
|
||||||
11
extensions/kook/package.json
Normal file
11
extensions/kook/package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
785
extensions/kook/src/channel.ts
Normal file
785
extensions/kook/src/channel.ts
Normal 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(" ")}`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
57
extensions/kook/src/config-keys.ts
Normal file
57
extensions/kook/src/config-keys.ts
Normal 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]: "多账户配置",
|
||||||
|
};
|
||||||
47
extensions/kook/src/config-schema.ts
Normal file
47
extensions/kook/src/config-schema.ts
Normal 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 "*"',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
32
extensions/kook/src/probe.ts
Normal file
32
extensions/kook/src/probe.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
extensions/kook/src/runtime.ts
Normal file
14
extensions/kook/src/runtime.ts
Normal 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;
|
||||||
|
}
|
||||||
4
extensions/kook/src/types.ts
Normal file
4
extensions/kook/src/types.ts
Normal 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
8
pnpm-lock.yaml
generated
@ -320,6 +320,8 @@ importers:
|
|||||||
|
|
||||||
extensions/imessage: {}
|
extensions/imessage: {}
|
||||||
|
|
||||||
|
extensions/kook: {}
|
||||||
|
|
||||||
extensions/line:
|
extensions/line:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
moltbot:
|
moltbot:
|
||||||
@ -383,12 +385,12 @@ importers:
|
|||||||
'@microsoft/agents-hosting-extensions-teams':
|
'@microsoft/agents-hosting-extensions-teams':
|
||||||
specifier: ^1.2.2
|
specifier: ^1.2.2
|
||||||
version: 1.2.2
|
version: 1.2.2
|
||||||
moltbot:
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../..
|
|
||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
moltbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
proper-lockfile:
|
proper-lockfile:
|
||||||
specifier: ^4.1.2
|
specifier: ^4.1.2
|
||||||
version: 4.1.2
|
version: 4.1.2
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user