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/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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user