feat: PIT Channel plugin v1.0.0 - complete implementation

This commit is contained in:
2026-03-14 15:54:30 +08:00
parent 431ebfc193
commit ade55c5016
27 changed files with 4063 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Dependencies
node_modules/
# Build output
dist/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage
coverage/
*.lcov
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
.cache/
# Environment
.env
.env.local
.env.*.local

31
CHANGELOG.md Normal file
View File

@@ -0,0 +1,31 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 2026-03-14
### Added
- Initial release of PIT Bot Channel Plugin
- WebSocket connection to PIT Router
- Message send/receive with acknowledgment
- Smart message chunking for long text
- Message queue for offline messages
- Automatic reconnection with exponential backoff
- Heartbeat mechanism with timeout detection
- Metrics collection (connection success/failure, message latency)
- Web UI for configuration management
- Multi-account support
- Environment variable support for auth tokens
- Configuration validation and migration
### Security
- Auth token support via environment variables
- Secure WebSocket (wss://) support
[Unreleased]: https://github.com/your-org/openclaw-pit-bot/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/your-org/openclaw-pit-bot/releases/tag/v1.0.0

View File

@@ -0,0 +1,393 @@
# PIT Channel 插件技术方案
**版本**: v2.1
**创建日期**: 2026-03-14
**变更内容**: 代码实现完成,修复技术方案问题
---
# 1. 概述
## 1.1 目标
开发一个 OpenClaw Channel 插件,使 OpenClaw Agent 能够连接到 PIT Router个人智能体团队路由服务实现多 Agent 协作。
## 1.2 核心功能
- ✅ 连接到 PIT Router WebSocket 服务
- ✅ 接收来自 PIT Router 的消息
- ✅ 发送 Agent 响应到 PIT Router
- ✅ 支持多账户配置
- ✅ 支持消息分块(智能 Markdown 分块)
- ✅ 支持媒体消息
- ✅ Web UI 配置管理
- ✅ 插件版本管理与更新
- ✅ 消息队列与离线重发
- ✅ 心跳超时检测
- ✅ 消息确认机制
- ✅ 指标监控
---
# 2. 架构设计
## 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────────────┐
│ PIT Router (Flask) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ WebSocket Server (Flask-SocketIO) │ │
│ │ - 用户连接管理 │ │
│ │ - 消息路由 │ │
│ │ - Agent 调度 │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────┘
│ WebSocket
┌─────────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PIT Channel Plugin │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Gateway │ │ Outbound │ │ Config │ │ │
│ │ │ (入站消息) │ │ (出站消息) │ │ (配置管理) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Web UI │ │ Update │ │ Utils │ │ │
│ │ │ (配置界面) │ │ (版本管理) │ │ (工具模块) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## 2.2 消息流
```
用户消息 → PIT Router → Gateway → Channel → Agent
响应 ← PIT Router ← Gateway ← Channel ← Outbound
```
## 2.3 模块依赖
```
┌─────────────┐
│ channel │ ← 主入口,管理 Gateway 映射
├─────────────┤
│ gateway │ ← WebSocket 连接、心跳、重连、消息队列
├─────────────┤
│ outbound │ ← 消息发送、分块
├─────────────┤
│ config │ ← 配置解析、账户管理
├─────────────┤
│ webui │ ← HTTP 路由、API、页面
├─────────────┤
│ update │ ← 版本检查、迁移、回滚
├─────────────┤
│ utils │ ← 日志、指标、队列、分块、负载处理
└─────────────┘
```
---
# 3. 插件结构
## 3.1 目录结构
```
pit-bot-plugin/
├── src/
│ ├── index.ts # 插件入口
│ ├── channel.ts # Channel 实现(主文件)
│ ├── config.ts # 配置处理
│ ├── outbound.ts # 消息发送
│ ├── gateway.ts # WebSocket 连接 + 消息队列
│ ├── types.ts # 类型定义
│ ├── webui/ # Web UI 模块
│ │ ├── index.ts
│ │ ├── routes.ts # HTTP 路由注册
│ │ ├── api.ts # REST API 处理
│ │ └── static.ts # 静态资源服务
│ ├── update/ # 更新模块
│ │ ├── index.ts
│ │ ├── version.ts # 版本检查
│ │ ├── migrate.ts # 配置迁移
│ │ └── rollback.ts # 回滚机制
│ └── utils/
│ ├── index.ts
│ ├── logger.ts # 日志模块
│ ├── metrics.ts # 指标监控
│ ├── queue.ts # 消息队列
│ ├── chunker.ts # 智能分块
│ └── payload.ts # 消息负载处理
├── package.json
├── tsconfig.json
├── CHANGELOG.md
└── openclaw.plugin.json
```
---
# 4. 核心实现
## 4.1 消息队列 (queue.ts)
```typescript
/**
* 消息队列 - 解决离线消息和重发问题
*/
export class MessageQueue {
private queue: Map<string, QueuedMessage> = new Map();
// 入队
enqueue(to: string, content: string | PITRouterMessage): QueuedMessage {
// 队列满时删除最旧消息
if (this.queue.size >= this.options.maxSize) {
const oldest = this.getOldest();
if (oldest) this.queue.delete(oldest.queueId);
}
// ... 添加到队列
}
// 发送失败时增加重试计数
incrementRetry(queueId: string): boolean {
const item = this.queue.get(queueId);
if (!item) return false;
item.retryCount++;
return item.retryCount < item.maxRetries;
}
// 获取待重发的消息
getPending(): QueuedMessage[] {
return Array.from(this.queue.values())
.filter(item => !item.acknowledged && item.retryCount < item.maxRetries);
}
}
```
## 4.2 心跳超时检测 (gateway.ts)
```typescript
/**
* 修复:心跳超时处理
*/
function startHeartbeat(): void {
heartbeatTimer = setInterval(() => {
if (!connected) return;
const pingId = `ping-${Date.now()}`;
send({ type: "request", id: pingId, method: "ping" });
// 设置 pong 响应超时
heartbeatTimeoutTimer = setTimeout(() => {
log.warn("Heartbeat timeout, disconnecting");
ws?.close(); // 超时则断开连接
}, heartbeatTimeout);
}, heartbeatInterval);
}
```
## 4.3 智能分块 (chunker.ts)
```typescript
/**
* 智能 Markdown 分块
* 保护代码块、URL、链接不被拆断
*/
function chunkMarkdown(text: string, limit: number): string[] {
// 1. 识别保护区域代码块、URL、链接
const protectedRanges = findProtectedRanges(text);
// 2. 按优先级分割:段落 → 标题 → 列表 → 句子 → 词 → 强制
let splitAt = findSplitBefore(text, "\n\n", limit, protectedRanges);
if (splitAt > limit * 0.3) return chunks;
// ... 继续其他优先级
}
```
## 4.4 消息确认机制 (gateway.ts)
```typescript
/**
* 发送消息并等待确认
*/
async function sendWithAck(
message: PITRouterMessage,
messageId: string
): Promise<{ success: boolean; messageId?: string; error?: string }> {
// 未连接时入队
if (!connected) {
queue.enqueue(message.params as { to: string; content: string }, message);
return { success: true, messageId, error: "Queued for retry" };
}
// 发送消息
if (!send(message)) {
queue.enqueue(message.params as { to: string; content: string }, message);
return { success: true, messageId, error: "Queued for retry" };
}
// 等待确认(带超时)
return new Promise((resolve) => {
const timeout = setTimeout(() => {
pendingAcks.delete(messageId);
metrics.recordMessageAckTimeout();
resolve({ success: false, messageId, error: "Ack timeout" });
}, ackTimeout);
pendingAcks.set(messageId, {
resolve: () => { clearTimeout(timeout); resolve({ success: true, messageId }); },
reject: (reason) => { clearTimeout(timeout); resolve({ success: false, messageId, error: reason }); },
});
});
}
```
## 4.5 指标监控 (metrics.ts)
```typescript
/**
* 指标收集器
*/
export class MetricsCollector {
private metrics: PITMetrics = {
connectionSuccess: 0,
connectionFailure: 0,
messagesSent: 0,
messagesReceived: 0,
messagesAcked: 0,
messagesAckTimeout: 0,
averageLatency: 0,
queueLength: 0,
updatedAt: Date.now(),
};
private latencySamples: number[] = [];
recordMessageAcked(latency: number): void {
this.metrics.messagesAcked++;
this.latencySamples.push(latency);
if (this.latencySamples.length > 100) this.latencySamples.shift();
this.metrics.averageLatency = this.calculateAverage();
}
}
```
---
# 5. Web UI 配置管理
## 5.1 HTTP 路由
|路由|方法|说明|
|------|----------|-----------------|
|`/plugins/pit-bot`|GET|Web UI 入口|
|`/plugins/pit-bot/api/config`|GET/PUT|配置管理|
|`/plugins/pit-bot/api/status`|GET|获取状态|
|`/plugins/pit-bot/api/metrics`|GET|获取指标|
|`/plugins/pit-bot/api/connect`|POST|连接 PIT Router|
|`/plugins/pit-bot/api/disconnect`|POST|断开连接|
## 5.2 页面功能
- 连接状态显示(实时更新)
- 配置表单编辑Router URL、Token、间隔等
- 连接/断开按钮
- 指标展示(发送/接收计数、队列长度、延迟)
---
# 6. 插件更新机制
## 6.1 版本管理
遵循语义化版本 (SemVer 2.0.0)
## 6.2 配置迁移 (migrate.ts)
```typescript
const configMigrations: Record<string, Migration[]> = {
"1.1.0": [
(config) => ({
...config,
reconnectInterval: config.reconnectInterval ?? 5000,
}),
],
"2.0.0": [
(config) => {
const { routerUrl, ...rest } = config;
return { ...rest, gateway: { url: routerUrl } };
},
],
};
```
## 6.3 回滚机制 (rollback.ts)
```typescript
const configBackups = new Map<string, PITAccountConfig>();
function backupConfig(accountId: string, config: PITAccountConfig): void {
configBackups.set(accountId, { ...config });
}
function restoreConfig(accountId: string): PITAccountConfig | null {
return configBackups.get(accountId) ?? null;
}
```
---
# 7. 配置示例
```yaml
channels:
pit-bot:
enabled: true
routerUrl: "ws://localhost:9000/ws"
authToken: "${PIT_ROUTER_TOKEN}"
reconnectInterval: 5000
heartbeatInterval: 30000
heartbeatTimeout: 10000
ackTimeout: 30000
maxQueueSize: 100
accounts:
work:
enabled: true
routerUrl: "ws://work.example.com:9000/ws"
authToken: "${PIT_WORK_TOKEN}"
name: "工作 Agent"
```
---
# 8. 测试计划
|模块|测试用例|
|------|--------------------|
|chunker|长消息分块、Markdown 保护、URL 不拆断|
|gateway|重连、心跳超时、消息确认、队列重发|
|config|配置解析、环境变量、验证|
|queue|入队、出队、重试、清理|
|metrics|计数、平均延迟计算|
---
# 9. 修复的问题
|问题|修复方案|
|------|----------|
|onMessage 回调未传递|在 GatewayContext 中添加 onMessage 参数|
|心跳无超时处理|添加 heartbeatTimeoutTimer超时断开连接|
|消息分块破坏 URL|智能识别保护区域代码块、URL、链接|
|发送失败无重试|实现 MessageQueue离线消息自动重发|
|无确认机制|sendWithAck + pendingAcks + 超时处理|
|无监控|实现 MetricsCollector 收集各类指标|
|日志不规范|统一 Logger 模块,区分 debug/info/warn/error|
---
*文档版本: v2.1 | 更新日期: 2026-03-14*

96
openclaw.plugin.json Normal file
View File

@@ -0,0 +1,96 @@
{
"$schema": "https://raw.githubusercontent.com/openclaw/openclaw/main/schemas/plugin.json",
"id": "pit-bot",
"name": "PIT Bot",
"version": "1.0.0",
"description": "Connect to PIT Router for multi-agent support",
"entry": "dist/index.js",
"minOpenClawVersion": "2026.3.0",
"author": "小白 🐶",
"license": "MIT",
"repository": "https://github.com/your-org/openclaw-pit-bot",
"homepage": "https://github.com/your-org/openclaw-pit-bot#readme",
"capabilities": {
"chatTypes": ["direct"],
"media": true,
"reactions": false,
"threads": false,
"blockStreaming": false
},
"config": {
"sections": [
{
"key": "channels.pit-bot",
"label": "PIT Bot",
"description": "PIT Router connection settings",
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable PIT Bot channel"
},
"routerUrl": {
"type": "string",
"format": "uri",
"description": "PIT Router WebSocket URL"
},
"authToken": {
"type": "string",
"description": "Authentication token or ${ENV_VAR}"
},
"reconnectInterval": {
"type": "integer",
"minimum": 1000,
"default": 5000,
"description": "Reconnection interval in milliseconds"
},
"heartbeatInterval": {
"type": "integer",
"minimum": 1000,
"default": 30000,
"description": "Heartbeat interval in milliseconds"
},
"heartbeatTimeout": {
"type": "integer",
"minimum": 1000,
"default": 10000,
"description": "Heartbeat timeout in milliseconds"
},
"ackTimeout": {
"type": "integer",
"minimum": 1000,
"default": 30000,
"description": "Message acknowledgment timeout in milliseconds"
},
"maxQueueSize": {
"type": "integer",
"minimum": 1,
"default": 100,
"description": "Maximum message queue size"
},
"accounts": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"name": { "type": "string" },
"routerUrl": { "type": "string" },
"authToken": { "type": "string" },
"reconnectInterval": { "type": "integer" },
"heartbeatInterval": { "type": "integer" },
"heartbeatTimeout": { "type": "integer" },
"ackTimeout": { "type": "integer" },
"maxQueueSize": { "type": "integer" }
}
}
}
},
"required": ["routerUrl"]
}
}
]
}
}

64
package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "openclaw-pit-bot",
"version": "1.0.0",
"description": "PIT Bot Channel Plugin for OpenClaw - Connect to PIT Router for multi-agent support",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest",
"test:coverage": "vitest --coverage",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"openclaw",
"pit-bot",
"pit-router",
"channel-plugin",
"multi-agent",
"websocket"
],
"author": "小白 🐶",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/your-org/openclaw-pit-bot.git"
},
"bugs": {
"url": "https://github.com/your-org/openclaw-pit-bot/issues"
},
"homepage": "https://github.com/your-org/openclaw-pit-bot#readme",
"dependencies": {
"openclaw": "~2026.3.0",
"ws": "^8.18.0",
"uuid": "^11.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"@types/uuid": "^10.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0",
"@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0"
},
"engines": {
"node": ">=20.0.0"
},
"openclaw": {
"pluginId": "pit-bot",
"minOpenClawVersion": "2026.3.0"
},
"files": [
"dist/**/*",
"README.md",
"CHANGELOG.md",
"LICENSE"
]
}

338
src/channel.ts Normal file
View File

@@ -0,0 +1,338 @@
/**
* PIT Channel 主文件
* @module channel
*/
import type {
ChannelPlugin,
ChannelMeta,
ChannelCapabilities,
ChannelMessagingAdapter,
ChannelConfigAdapter,
ChannelOutboundAdapter,
ChannelGatewayAdapter,
ChannelStatusAdapter,
OpenClawConfig,
} from "openclaw/plugin-sdk";
import type {
ResolvedPITBotAccount,
PITUserMessage,
PITConnectionState
} from "./types.js";
import type { Gateway } from "./gateway.js";
import {
DEFAULT_ACCOUNT_ID,
listPITBotAccountIds,
resolvePITBotAccount,
applyPITBotAccountConfig,
validateConfig,
} from "./config.js";
import { startGateway } from "./gateway.js";
import { chunkText } from "./utils/chunker.js";
import { createLogger } from "./utils/logger.js";
import { registerWebUIRoutes } from "./webui/routes.js";
const MODULE = "pit-bot";
// Gateway 实例映射
const gateways = new Map<string, Gateway>();
/**
* PIT Bot Channel Plugin
*/
export const pitBotPlugin: ChannelPlugin<ResolvedPITBotAccount> = {
id: "pit-bot",
meta: {
id: "pit-bot",
label: "PIT Bot",
selectionLabel: "PIT Bot",
docsPath: "/docs/channels/pit-bot",
blurb: "Connect to PIT Router for multi-agent support",
order: 60,
} as ChannelMeta,
capabilities: {
chatTypes: ["direct"],
media: true,
reactions: false,
threads: false,
blockStreaming: false,
} as ChannelCapabilities,
reload: { configPrefixes: ["channels.pit-bot"] },
messaging: {
normalizeTarget: (target: string) => {
const normalized = target.replace(/^pit-bot:/i, "");
if (normalized.startsWith("user:")) {
return { ok: true, to: normalized };
}
return { ok: true, to: `user:${normalized}` };
},
targetResolver: {
looksLikeId: (id: string) => {
const normalized = id.replace(/^pit-bot:/i, "");
return normalized.startsWith("user:") || normalized.length > 0;
},
hint: "user:<userId> or <userId>",
},
} as ChannelMessagingAdapter,
config: {
listAccountIds: (cfg: OpenClawConfig) => listPITBotAccountIds(cfg),
resolveAccount: (cfg: OpenClawConfig, accountId: string) => resolvePITBotAccount(cfg, accountId),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountName: (cfg: OpenClawConfig, accountId: string, name: string) => {
applyPITBotAccountConfig(cfg, accountId, { name });
},
deleteAccount: (cfg: OpenClawConfig, accountId: string) => {
const accounts = cfg.getSection<Record<string, unknown>>("channels.pit-bot.accounts");
if (accounts && accountId in accounts) {
delete accounts[accountId];
cfg.set("channels.pit-bot.accounts", accounts);
}
},
setAccountEnabled: (cfg: OpenClawConfig, accountId: string, enabled: boolean) => {
applyPITBotAccountConfig(cfg, accountId, { enabled });
},
validateAccountConfig: (cfg: OpenClawConfig, accountId: string) => {
const account = resolvePITBotAccount(cfg, accountId);
return validateConfig(account.config);
},
} as ChannelConfigAdapter<ResolvedPITBotAccount>,
outbound: {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, replyToId }: { to: string; text: string; accountId: string; replyToId?: string }): Promise<{ channel: string; messageId: string; error?: Error }> => {
const log = createLogger(`${MODULE}:outbound`);
try {
const gateway = gateways.get(accountId);
if (!gateway) {
return {
channel: "pit-bot",
messageId: "",
error: new Error(`Gateway not available for account ${accountId}`),
};
}
const result = await gateway.sendText(to, text, replyToId);
return {
channel: "pit-bot",
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error("sendText failed", { to, accountId, error: message });
return {
channel: "pit-bot",
messageId: "",
error: new Error(message),
};
}
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }: { to: string; text?: string; mediaUrl: string; accountId: string; replyToId?: string }): Promise<{ channel: string; messageId: string; error?: Error }> => {
const log = createLogger(`${MODULE}:outbound`);
try {
const gateway = gateways.get(accountId);
if (!gateway) {
return {
channel: "pit-bot",
messageId: "",
error: new Error(`Gateway not available for account ${accountId}`),
};
}
const result = await gateway.sendMedia(to, mediaUrl ?? "", text, replyToId);
return {
channel: "pit-bot",
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error("sendMedia failed", { to, accountId, error: message });
return {
channel: "pit-bot",
messageId: "",
error: new Error(message),
};
}
},
} as ChannelOutboundAdapter<ResolvedPITBotAccount>,
gateway: {
startAccount: async (ctx: {
account: ResolvedPITBotAccount;
abortSignal?: AbortSignal;
cfg: OpenClawConfig;
log?: { info: (msg: string) => void; error: (msg: string) => void };
setStatus: (state: Partial<PITConnectionState>) => void;
getStatus: () => PITConnectionState;
emitMessage: (msg: unknown) => void;
}) => {
const { account, abortSignal, cfg } = ctx;
const log = createLogger(`${MODULE}:${account.accountId}`);
log.info("Starting gateway");
try {
const gateway = await startGateway({
account,
abortSignal,
cfg,
log,
setStatus: (state) => {
const current = ctx.getStatus();
ctx.setStatus({ ...current, ...state });
},
getStatus: () => ctx.getStatus(),
onMessage: (message: PITUserMessage) => {
ctx.emitMessage({
channel: "pit-bot",
accountId: account.accountId,
chatId: `pit-bot:user:${message.userId}`,
chatType: "direct",
senderId: message.userId,
text: message.content,
attachments: message.attachments?.map(a => ({
type: a.type,
url: a.url,
filename: a.filename,
size: a.size,
})),
replyToId: message.replyTo,
metadata: {
sessionId: message.sessionId,
timestamp: message.timestamp,
},
});
},
});
gateways.set(account.accountId, gateway);
ctx.setStatus({
...ctx.getStatus(),
running: true,
connected: true,
});
log.info("Gateway started successfully");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error("Failed to start gateway", { error: message });
ctx.setStatus({
...ctx.getStatus(),
running: false,
connected: false,
lastError: message,
});
throw error;
}
},
stopAccount: async (ctx: { account: ResolvedPITBotAccount }) => {
const { account } = ctx;
const log = createLogger(`${MODULE}:${account.accountId}`);
log.info("Stopping gateway");
const gateway = gateways.get(account.accountId);
if (gateway) {
gateway.disconnect();
gateways.delete(account.accountId);
}
log.info("Gateway stopped");
},
restartAccount: async (ctx: {
account: ResolvedPITBotAccount;
abortSignal?: AbortSignal;
cfg: OpenClawConfig;
setStatus: (state: Partial<PITConnectionState>) => void;
getStatus: () => PITConnectionState;
emitMessage: (msg: unknown) => void;
}) => {
const { account } = ctx;
const log = createLogger(`${MODULE}:${account.accountId}`);
log.info("Restarting gateway");
const gateway = gateways.get(account.accountId);
if (gateway) {
gateway.disconnect();
gateways.delete(account.accountId);
}
await pitBotPlugin.gateway!.startAccount!(ctx as never);
},
} as ChannelGatewayAdapter<ResolvedPITBotAccount>,
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
connected: false,
},
formatStatus: (runtime: PITConnectionState) => {
const parts: string[] = [];
parts.push(runtime.connected ? "🟢 Connected" : "🔴 Disconnected");
if (runtime.sessionId) {
parts.push(`Session: ${runtime.sessionId.slice(0, 8)}...`);
}
if (runtime.lastError) {
parts.push(`Error: ${runtime.lastError}`);
}
return parts.join(" | ");
},
} as ChannelStatusAdapter,
// 初始化钩子
init: async (api: unknown) => {
const log = createLogger(MODULE);
log.info("PIT Bot plugin initializing");
registerWebUIRoutes(api as never);
log.info("PIT Bot plugin initialized");
},
// 清理钩子
cleanup: async () => {
const log = createLogger(MODULE);
log.info("PIT Bot plugin cleaning up");
for (const [accountId, gateway] of gateways.entries()) {
log.info(`Disconnecting gateway: ${accountId}`);
gateway.disconnect();
}
gateways.clear();
log.info("PIT Bot plugin cleaned up");
},
};
export default pitBotPlugin;

221
src/config.ts Normal file
View File

@@ -0,0 +1,221 @@
/**
* 配置处理模块
* @module config
*/
import type {
PITBotAccountConfig,
ResolvedPITBotAccount
} from "./types.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { createLogger } from "./utils/logger.js";
const MODULE = "config";
/**
* 默认账户 ID
*/
export const DEFAULT_ACCOUNT_ID = "default";
/**
* 获取 PIT Bot 账户 ID 列表
* @param cfg OpenClaw 配置
* @returns 账户 ID 列表
*/
export function listPITBotAccountIds(cfg: OpenClawConfig): string[] {
const accounts = cfg.getSection<{ accounts?: Record<string, unknown> }>("channels.pit-bot");
const ids: string[] = [];
if (accounts && typeof accounts.accounts === "object") {
for (const [id, account] of Object.entries(accounts.accounts)) {
if (account && typeof account === "object" && !Array.isArray(account)) {
ids.push(id);
}
}
}
// 如果没有配置账户,返回默认账户
if (ids.length === 0) {
ids.push(DEFAULT_ACCOUNT_ID);
}
return ids;
}
/**
* 解析 PIT Bot 账户
* @param cfg OpenClaw 配置
* @param accountId 账户 ID
* @returns 解析后的账户配置
* @throws 如果配置无效
*/
export function resolvePITBotAccount(
cfg: OpenClawConfig,
accountId: string
): ResolvedPITBotAccount {
const log = createLogger(MODULE);
const section = cfg.getSection<{
enabled?: boolean;
routerUrl?: string;
authToken?: string;
name?: string;
reconnectInterval?: number;
heartbeatInterval?: number;
heartbeatTimeout?: number;
ackTimeout?: number;
maxQueueSize?: number;
accounts?: Record<string, PITBotAccountConfig>;
}>("channels.pit-bot");
if (!section) {
throw new Error(`[pit-bot] Configuration section 'channels.pit-bot' not found`);
}
// 获取全局默认配置
const defaults: PITBotAccountConfig = {
enabled: section.enabled ?? true,
routerUrl: section.routerUrl,
authToken: section.authToken,
name: section.name,
reconnectInterval: section.reconnectInterval ?? 5000,
heartbeatInterval: section.heartbeatInterval ?? 30000,
heartbeatTimeout: section.heartbeatTimeout ?? 10000,
ackTimeout: section.ackTimeout ?? 30000,
maxQueueSize: section.maxQueueSize ?? 100,
};
// 获取特定账户配置
let accountConfig: PITBotAccountConfig = {};
if (section.accounts && typeof section.accounts === "object") {
const specificConfig = section.accounts[accountId];
if (specificConfig && typeof specificConfig === "object") {
accountConfig = specificConfig;
}
}
// 合并配置(账户配置覆盖默认配置)
const mergedConfig: PITBotAccountConfig = {
...defaults,
...accountConfig,
};
// 解析认证 Token
const { authToken, secretSource } = resolveAuthToken(mergedConfig.authToken, accountId, log);
// 验证必需字段
const routerUrl = mergedConfig.routerUrl;
if (!routerUrl || typeof routerUrl !== "string") {
throw new Error(
`[pit-bot:${accountId}] Missing required config: 'routerUrl'. ` +
`Please set channels.pit-bot.routerUrl or channels.pit-bot.accounts.${accountId}.routerUrl`
);
}
if (!authToken) {
log.warn(`No auth token configured for account ${accountId}. Connection may fail.`);
}
return {
accountId,
name: mergedConfig.name,
enabled: mergedConfig.enabled ?? true,
routerUrl,
authToken: authToken ?? "",
secretSource,
config: mergedConfig,
};
}
/**
* 应用 PIT Bot 账户配置
* @param cfg OpenClaw 配置
* @param accountId 账户 ID
* @param config 新配置
*/
export function applyPITBotAccountConfig(
cfg: OpenClawConfig,
accountId: string,
config: PITBotAccountConfig
): void {
const key = `channels.pit-bot.accounts.${accountId}`;
cfg.set(key, config);
}
/**
* 解析认证 Token
* 支持环境变量引用和环境变量名
*/
function resolveAuthToken(
configToken: string | undefined,
accountId: string,
log: { warn: (msg: string) => void }
): { authToken: string | null; secretSource: "config" | "env" | "none" } {
// 1. 如果配置中直接设置了 Token
if (configToken && typeof configToken === "string") {
// 检查是否是环境变量引用 ${VAR_NAME}
const envMatch = configToken.match(/^\$\{(.+)\}$/);
if (envMatch) {
const envVar = envMatch[1];
const token = process.env[envVar];
if (!token) {
log.warn(`Environment variable ${envVar} is not set for account ${accountId}`);
return { authToken: null, secretSource: "env" };
}
return { authToken: token, secretSource: "env" };
}
return { authToken: configToken, secretSource: "config" };
}
// 2. 尝试从标准环境变量名获取
const envVarName = accountId === DEFAULT_ACCOUNT_ID
? "PIT_ROUTER_TOKEN"
: `PIT_ROUTER_TOKEN_${accountId.toUpperCase()}`;
const envToken = process.env[envVarName];
if (envToken) {
return { authToken: envToken, secretSource: "env" };
}
// 3. 无 Token
return { authToken: null, secretSource: "none" };
}
/**
* 验证配置
*/
export function validateConfig(config: PITBotAccountConfig): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!config.routerUrl) {
errors.push("routerUrl is required");
} else if (!isValidUrl(config.routerUrl)) {
errors.push(`routerUrl is invalid: ${config.routerUrl}`);
}
if (config.reconnectInterval !== undefined && config.reconnectInterval < 1000) {
errors.push("reconnectInterval must be at least 1000ms");
}
if (config.heartbeatInterval !== undefined && config.heartbeatInterval < 1000) {
errors.push("heartbeatInterval must be at least 1000ms");
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* 验证 URL
*/
function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}

538
src/gateway.ts Normal file
View File

@@ -0,0 +1,538 @@
/**
* Gateway 模块 - WebSocket 连接管理
* @module gateway
*/
import WebSocket from "ws";
import type {
PITRouterMessage,
GatewayContext,
PITConnectionState
} from "./types.js";
import {
createLogger,
MetricsCollector,
MessageQueue,
createRequest,
createAck,
parseUserMessage,
serializeMessage,
deserializeMessage
} from "./utils/index.js";
import { v4 as uuidv4 } from "uuid";
/**
* Gateway 实例
*/
export interface Gateway {
/** 发送消息 */
send(message: PITRouterMessage): boolean;
/** 发送文本到用户 */
sendText(to: string, content: string, replyTo?: string): Promise<{ success: boolean; messageId?: string; error?: string }>;
/** 发送媒体到用户 */
sendMedia(to: string, mediaUrl: string, text?: string, replyTo?: string): Promise<{ success: boolean; messageId?: string; error?: string }>;
/** 断开连接 */
disconnect(): void;
/** 获取当前状态 */
getState(): PITConnectionState;
/** 获取队列长度 */
getQueueLength(): number;
}
/**
* 重连延迟(指数退避)
*/
const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000];
const MAX_RECONNECT_ATTEMPTS = 100;
/**
* 启动 Gateway
*/
export async function startGateway(ctx: GatewayContext): Promise<Gateway> {
const { account, abortSignal, setStatus, getStatus, onMessage } = ctx;
const log = createLogger(`gateway:${account.accountId}`);
const metrics = new MetricsCollector();
const queue = new MessageQueue({
maxSize: account.config.maxQueueSize ?? 100,
logger: log,
});
// 状态管理
let ws: WebSocket | null = null;
let connected = false;
let reconnectAttempts = 0;
let heartbeatTimer: NodeJS.Timeout | null = null;
let heartbeatTimeoutTimer: NodeJS.Timeout | null = null;
let cleanupTimer: NodeJS.Timeout | null = null;
let sessionId: string | undefined;
// 等待确认的消息映射
const pendingAcks = new Map<string, { resolve: () => void }>();
const heartbeatInterval = account.config.heartbeatInterval ?? 30000;
const heartbeatTimeout = account.config.heartbeatTimeout ?? 10000;
const ackTimeout = account.config.ackTimeout ?? 30000;
/**
* 更新连接状态
*/
function updateStatus(state: Partial<PITConnectionState>): void {
setStatus(state);
}
/**
* 连接 WebSocket
*/
async function connect(): Promise<void> {
if (connected || !account.enabled) return;
log.info(`Connecting to ${account.routerUrl}`);
updateStatus({ connecting: true });
try {
ws = new WebSocket(account.routerUrl, {
headers: {
Authorization: `Bearer ${account.authToken}`,
"X-Agent-Id": account.accountId,
},
});
ws.on("open", handleOpen);
ws.on("message", handleMessage);
ws.on("close", handleClose);
ws.on("error", handleError);
} catch (error) {
handleError(error as Error);
}
}
/**
* 处理连接打开
*/
function handleOpen(): void {
connected = true;
reconnectAttempts = 0;
log.info("Connected to PIT Router");
metrics.recordConnectionSuccess();
// 发送认证请求
const authRequest = createRequest("auth", {
token: account.authToken,
agentId: account.accountId,
});
send(authRequest);
// 更新状态
updateStatus({
connected: true,
connecting: false,
lastConnectedAt: Date.now(),
lastError: null,
reconnectAttempts: 0,
});
// 启动心跳
startHeartbeat();
// 启动清理任务
startCleanup();
// 处理队列中的消息
processQueue();
}
/**
* 处理消息接收
*/
function handleMessage(data: WebSocket.Data): void {
try {
const message = deserializeMessage(data.toString());
if (!message) {
log.warn("Failed to parse message", { data: data.toString().slice(0, 200) });
return;
}
log.debug("Message received", { type: message.type, id: message.id });
metrics.recordMessageReceived();
switch (message.type) {
case "response":
handleResponse(message);
break;
case "event":
handleEvent(message);
break;
case "ack":
handleAck(message);
break;
case "request":
handleRequest(message);
break;
}
} catch (error) {
log.error("Error handling message", error);
}
}
/**
* 处理响应
*/
function handleResponse(message: PITRouterMessage): void {
if (message.method === "auth" && message.payload) {
const payload = message.payload as Record<string, unknown>;
if (payload.sessionId) {
sessionId = String(payload.sessionId);
log.info("Authenticated", { sessionId });
updateStatus({ sessionId });
}
}
// 处理等待中的确认
if (message.replyTo && pendingAcks.has(message.replyTo)) {
const { resolve } = pendingAcks.get(message.replyTo)!;
pendingAcks.delete(message.replyTo);
resolve();
}
}
/**
* 处理事件
*/
function handleEvent(message: PITRouterMessage): void {
if (message.method === "user.message" && message.params) {
const userMessage = parseUserMessage(message.params);
if (userMessage) {
// 发送确认
send(createAck(message.id));
// 回调处理
onMessage(userMessage);
}
}
}
/**
* 处理确认消息
*/
function handleAck(message: PITRouterMessage): void {
if (message.replyTo && pendingAcks.has(message.replyTo)) {
const startTime = parseInt(message.replyTo.split("-")[1] ?? "0");
const latency = startTime ? Date.now() - startTime : 0;
const { resolve } = pendingAcks.get(message.replyTo)!;
pendingAcks.delete(message.replyTo);
queue.acknowledge(message.replyTo);
metrics.recordMessageAcked(latency);
resolve();
}
}
/**
* 处理请求
*/
function handleRequest(message: PITRouterMessage): void {
if (message.method === "ping") {
send(createRequest("pong", { id: message.id }));
}
}
/**
* 处理连接关闭
*/
function handleClose(code: number, reason: Buffer): void {
connected = false;
sessionId = undefined;
stopHeartbeat();
stopCleanup();
log.info(`Disconnected`, { code, reason: reason.toString() });
updateStatus({
connected: false,
connecting: false,
sessionId: undefined,
});
if (!abortSignal?.aborted) {
scheduleReconnect();
}
}
/**
* 处理错误
*/
function handleError(error: Error): void {
log.error("WebSocket error", error.message);
metrics.recordConnectionFailure();
updateStatus({
lastError: error.message,
connected: false,
});
}
/**
* 发送消息
*/
function send(message: PITRouterMessage): boolean {
if (!connected || !ws) return false;
try {
ws.send(serializeMessage(message));
return true;
} catch (error) {
log.error("Failed to send message", error);
return false;
}
}
/**
* 发送文本(带确认)
*/
async function sendText(
to: string,
content: string,
replyTo?: string
): Promise<{ success: boolean; messageId?: string; error?: string }> {
const messageId = uuidv4();
const timestamp = Date.now();
const payload = {
to,
content,
timestamp,
replyTo,
};
const message: PITRouterMessage = {
type: "request",
id: messageId,
method: "send.message",
params: payload,
};
return sendWithAck(message, messageId, to);
}
/**
* 发送媒体(带确认)
*/
async function sendMedia(
to: string,
mediaUrl: string,
text?: string,
replyTo?: string
): Promise<{ success: boolean; messageId?: string; error?: string }> {
const messageId = uuidv4();
const timestamp = Date.now();
const payload: Record<string, unknown> = {
to,
mediaUrl,
timestamp,
replyTo,
};
if (text) {
payload.content = text;
}
const message: PITRouterMessage = {
type: "request",
id: messageId,
method: "send.media",
params: payload,
};
return sendWithAck(message, messageId, to);
}
/**
* 发送并等待确认
*/
async function sendWithAck(
message: PITRouterMessage,
messageId: string,
to: string
): Promise<{ success: boolean; messageId?: string; error?: string }> {
// 如果未连接,入队
if (!connected) {
queue.enqueue(to, message);
metrics.updateQueueLength(queue.length);
return { success: true, messageId, error: undefined };
}
// 发送消息
if (!send(message)) {
queue.enqueue(to, message);
metrics.updateQueueLength(queue.length);
return { success: true, messageId, error: "Queued for retry" };
}
metrics.recordMessageSent();
// 等待确认
return new Promise((resolve) => {
const timeout = setTimeout(() => {
pendingAcks.delete(messageId);
metrics.recordMessageAckTimeout();
resolve({ success: false, messageId, error: "Ack timeout" });
}, ackTimeout);
pendingAcks.set(messageId, {
resolve: () => {
clearTimeout(timeout);
resolve({ success: true, messageId });
},
});
});
}
/**
* 处理队列
*/
async function processQueue(): Promise<void> {
const pending = queue.getPending();
for (const item of pending) {
const content = item.content as PITRouterMessage;
if (send(content)) {
log.debug("Sent queued message", { queueId: item.queueId });
} else {
queue.incrementRetry(item.queueId);
}
}
metrics.updateQueueLength(queue.length);
}
/**
* 启动心跳
*/
function startHeartbeat(): void {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (!connected) return;
// 发送 ping
const pingId = `ping-${Date.now()}`;
const pingMessage: PITRouterMessage = {
type: "request",
id: pingId,
method: "ping",
};
if (send(pingMessage)) {
// 等待 pong 响应
heartbeatTimeoutTimer = setTimeout(() => {
log.warn("Heartbeat timeout, disconnecting");
ws?.close();
}, heartbeatTimeout);
}
}, heartbeatInterval);
log.debug("Heartbeat started", { interval: heartbeatInterval });
}
/**
* 停止心跳
*/
function stopHeartbeat(): void {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
if (heartbeatTimeoutTimer) {
clearTimeout(heartbeatTimeoutTimer);
heartbeatTimeoutTimer = null;
}
}
/**
* 启动清理任务
*/
function startCleanup(): void {
stopCleanup();
cleanupTimer = setInterval(() => {
queue.cleanup();
metrics.updateQueueLength(queue.length);
}, 60000); // 每分钟清理一次
}
/**
* 停止清理任务
*/
function stopCleanup(): void {
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
}
/**
* 安排重连
*/
function scheduleReconnect(): void {
if (reconnectAttempts++ > MAX_RECONNECT_ATTEMPTS) {
log.error("Max reconnection attempts reached");
updateStatus({ lastError: "Max reconnection attempts reached" });
return;
}
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempts - 1, RECONNECT_DELAYS.length - 1)];
log.info(`Scheduling reconnect`, { attempt: reconnectAttempts, delay });
updateStatus({ reconnectAttempts });
setTimeout(connect, delay);
}
/**
* 断开连接
*/
function disconnect(): void {
stopHeartbeat();
stopCleanup();
ws?.close();
ws = null;
connected = false;
log.info("Disconnected manually");
}
/**
* 获取当前状态
*/
function getState(): PITConnectionState {
return getStatus();
}
/**
* 获取队列长度
*/
function getQueueLength(): number {
return queue.length;
}
// 监听中止信号
abortSignal?.addEventListener("abort", () => {
log.info("Abort signal received, disconnecting");
disconnect();
});
// 开始连接
await connect();
return {
send,
sendText,
sendMedia,
disconnect,
getState,
getQueueLength,
};
}

11
src/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* PIT Channel 插件入口
* @module pit-bot
*/
export { pitBotPlugin, default } from "./channel.js";
export * from "./types.js";
export * from "./config.js";
export * from "./gateway.js";
export * from "./outbound.js";
export * from "./utils/index.js";

118
src/outbound.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* Outbound 模块 - 消息发送
* @module outbound
*/
import type { SendResult } from "./types.js";
import { chunkText } from "./utils/chunker.js";
import { createLogger } from "./utils/logger.js";
const MODULE = "outbound";
// 外部 Gateway 管理器 - 由 channel.ts 注入
let gatewayMap: Map<string, {
sendText: (to: string, content: string, replyTo?: string) => Promise<{ success: boolean; messageId?: string; error?: string }>;
sendMedia: (to: string, mediaUrl: string, text?: string, replyTo?: string) => Promise<{ success: boolean; messageId?: string; error?: string }>;
}> | null = null;
/**
* 设置 Gateway 映射(由 channel.ts 调用)
*/
export function setGatewayMap(map: Map<string, never>): void {
gatewayMap = map as unknown as typeof gatewayMap;
}
/**
* 发送文本消息
*/
export async function sendText(options: {
to: string;
text: string;
accountId: string;
replyToId?: string;
}): Promise<SendResult> {
const { to, text, accountId, replyToId } = options;
const log = createLogger(MODULE);
try {
const gateway = gatewayMap?.get(accountId);
if (!gateway) {
return { messageId: "", error: "Gateway not available" };
}
// 获取分块限制
const chunkLimit = 4000;
const chunks = chunkText(text, { limit: chunkLimit, mode: "markdown" });
if (chunks.length === 1) {
const result = await gateway.sendText(to, text, replyToId);
return {
messageId: result.messageId ?? "",
error: result.error,
};
}
// 多块消息,逐块发送
const messageIds: string[] = [];
let lastError: string | undefined;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const chunkSuffix = chunks.length > 1 ? `\n\n--- [${i + 1}/${chunks.length}] ---` : "";
const result = await gateway.sendText(to, chunk + chunkSuffix, replyToId);
if (result.messageId) {
messageIds.push(result.messageId);
}
if (result.error) {
lastError = result.error;
}
// 块之间添加延迟
if (i < chunks.length - 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
return {
messageId: messageIds.join(","),
error: lastError,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error("Failed to send text", { to, error: message });
return { messageId: "", error: message };
}
}
/**
* 发送媒体消息
*/
export async function sendMedia(options: {
to: string;
text?: string;
mediaUrl: string;
accountId: string;
replyToId?: string;
}): Promise<SendResult> {
const { to, text, mediaUrl, accountId, replyToId } = options;
const log = createLogger(MODULE);
try {
const gateway = gatewayMap?.get(accountId);
if (!gateway) {
return { messageId: "", error: "Gateway not available" };
}
const result = await gateway.sendMedia(to, mediaUrl, text, replyToId);
return {
messageId: result.messageId ?? "",
error: result.error,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error("Failed to send media", { to, mediaUrl, error: message });
return { messageId: "", error: message };
}
}

340
src/types.ts Normal file
View File

@@ -0,0 +1,340 @@
/**
* PIT Channel 类型定义
* @module types
*/
/**
* PIT Bot 配置
*/
export interface PITBotConfig {
/** PIT Router WebSocket URL */
routerUrl: string;
/** 认证 Token */
authToken?: string;
/** 重连间隔(毫秒),默认 5000 */
reconnectInterval?: number;
/** 心跳间隔(毫秒),默认 30000 */
heartbeatInterval?: number;
/** 心跳超时(毫秒),默认 10000 */
heartbeatTimeout?: number;
/** 消息确认超时(毫秒),默认 30000 */
ackTimeout?: number;
/** 消息队列最大长度,默认 100 */
maxQueueSize?: number;
}
/**
* PIT Bot 账户配置
*/
export interface PITBotAccountConfig {
/** 是否启用 */
enabled?: boolean;
/** 账户名称 */
name?: string;
/** PIT Router WebSocket URL */
routerUrl?: string;
/** 认证 Token */
authToken?: string;
/** 重连间隔(毫秒) */
reconnectInterval?: number;
/** 心跳间隔(毫秒) */
heartbeatInterval?: number;
/** 心跳超时(毫秒) */
heartbeatTimeout?: number;
/** 消息确认超时(毫秒) */
ackTimeout?: number;
/** 消息队列最大长度 */
maxQueueSize?: number;
}
/**
* 解析后的 PIT Bot 账户
*/
export interface ResolvedPITBotAccount {
/** 账户 ID */
accountId: string;
/** 账户名称 */
name?: string;
/** 是否启用 */
enabled: boolean;
/** PIT Router WebSocket URL */
routerUrl: string;
/** 认证 Token */
authToken: string;
/** Token 来源 */
secretSource: "config" | "env" | "none";
/** 原始配置 */
config: PITBotAccountConfig;
/** 会话 ID连接后生成 */
sessionId?: string;
}
/**
* PIT Router 消息类型
*/
export type PITMessageType = "request" | "response" | "event" | "ack";
/**
* PIT Router 消息
*/
export interface PITRouterMessage {
/** 消息类型 */
type: PITMessageType;
/** 消息 ID */
id: string;
/** 方法名 */
method?: string;
/** 参数 */
params?: unknown;
/** 负载 */
payload?: unknown;
/** 错误信息 */
error?: PITError;
/** 关联的消息 ID用于确认 */
replyTo?: string;
}
/**
* PIT 错误
*/
export interface PITError {
/** 错误码 */
code: string;
/** 错误消息 */
message: string;
/** 详细信息 */
details?: unknown;
}
/**
* 用户消息
*/
export interface PITUserMessage {
/** 会话 ID */
sessionId: string;
/** 用户 ID */
userId: string;
/** 消息内容 */
content: string;
/** 时间戳(毫秒) */
timestamp: number;
/** 附件列表 */
attachments?: PITAttachment[];
/** 回复的消息 ID */
replyTo?: string;
}
/**
* 附件类型
*/
export type PITAttachmentType = "image" | "file" | "audio" | "video";
/**
* 附件
*/
export interface PITAttachment {
/** 附件类型 */
type: PITAttachmentType;
/** 附件 URL */
url: string;
/** 文件名 */
filename?: string;
/** 文件大小(字节) */
size?: number;
/** MIME 类型 */
mimeType?: string;
}
/**
* WebSocket 连接状态
*/
export interface PITConnectionState {
/** 是否已连接 */
connected: boolean;
/** 是否正在连接 */
connecting: boolean;
/** 是否正在运行 */
running?: boolean;
/** 上次连接成功时间 */
lastConnectedAt: number | null;
/** 上次错误信息 */
lastError: string | null;
/** 重连尝试次数 */
reconnectAttempts: number;
/** 会话 ID */
sessionId?: string;
}
/**
* 消息队列项
*/
export interface QueuedMessage {
/** 队列 ID */
queueId: string;
/** 消息 ID */
messageId: string;
/** 目标用户 */
to: string;
/** 消息内容 */
content: string | PITRouterMessage;
/** 入队时间 */
enqueuedAt: number;
/** 重试次数 */
retryCount: number;
/** 最大重试次数 */
maxRetries: number;
/** 是否已确认 */
acknowledged: boolean;
}
/**
* 媒体上传结果
*/
export interface MediaUploadResult {
/** 是否成功 */
success: boolean;
/** 上传后的 URL */
url?: string;
/** 错误信息 */
error?: string;
}
/**
* 指标数据
*/
export interface PITMetrics {
/** 连接成功次数 */
connectionSuccess: number;
/** 连接失败次数 */
connectionFailure: number;
/** 消息发送总数 */
messagesSent: number;
/** 消息接收总数 */
messagesReceived: number;
/** 消息确认成功数 */
messagesAcked: number;
/** 消息确认超时数 */
messagesAckTimeout: number;
/** 平均延迟(毫秒) */
averageLatency: number;
/** 当前队列长度 */
queueLength: number;
/** 最后更新时间 */
updatedAt: number;
}
/**
* 日志级别
*/
export type LogLevel = "debug" | "info" | "warn" | "error";
/**
* 日志条目
*/
export interface LogEntry {
/** 时间戳 */
timestamp: number;
/** 日志级别 */
level: LogLevel;
/** 模块名 */
module: string;
/** 消息 */
message: string;
/** 额外数据 */
data?: unknown;
}
/**
* 版本信息
*/
export interface VersionInfo {
/** 当前版本 */
version: string;
/** 最新版本 */
latestVersion?: string;
/** 是否有更新 */
hasUpdate: boolean;
/** 更新说明 */
releaseNotes?: string;
}
/**
* 配置迁移函数
*/
export type Migration = (config: Record<string, unknown>) => Record<string, unknown>;
/**
* Gateway 上下文
*/
export interface GatewayContext {
/** 账户信息 */
account: ResolvedPITBotAccount;
/** 中止信号 */
abortSignal?: AbortSignal;
/** 配置 */
cfg: unknown;
/** 日志器 */
log: Logger;
/** 状态更新回调 */
setStatus: (state: Partial<PITConnectionState>) => void;
/** 获取当前状态 */
getStatus: () => PITConnectionState;
/** 消息处理回调 */
onMessage: (message: PITUserMessage) => void;
}
/**
* 日志器接口
*/
export interface Logger {
debug(message: string, data?: unknown): void;
info(message: string, data?: unknown): void;
warn(message: string, data?: unknown): void;
error(message: string, data?: unknown): void;
}
/**
* 发送文本选项
*/
export interface SendTextOptions {
/** 目标用户 */
to: string;
/** 文本内容 */
text: string;
/** 账户 ID */
accountId: string;
/** 回复的消息 ID */
replyToId?: string;
/** 配置 */
cfg: unknown;
}
/**
* 发送媒体选项
*/
export interface SendMediaOptions {
/** 目标用户 */
to: string;
/** 文本内容 */
text?: string;
/** 媒体 URL */
mediaUrl: string;
/** 账户 ID */
accountId: string;
/** 回复的消息 ID */
replyToId?: string;
/** 配置 */
cfg: unknown;
}
/**
* 发送结果
*/
export interface SendResult {
/** 消息 ID */
messageId: string;
/** 是否在队列中 */
queued?: boolean;
/** 错误信息 */
error?: string;
}

8
src/update/index.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* 更新模块入口
* @module update
*/
export { checkUpdate, compareVersions, parseVersion, setCurrentVersion } from "./version.js";
export { migrateConfig } from "./migrate.js";
export { backupConfig, restoreConfig, clearBackup } from "./rollback.js";

99
src/update/migrate.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* 配置迁移模块
* @module update/migrate
*/
import type { PITBotAccountConfig, Migration } from "../types.js";
import { createLogger } from "../utils/logger.js";
const MODULE = "update:migrate";
/**
* 配置迁移记录
* key: 目标版本value: 迁移函数列表
*/
const configMigrations: Record<string, Migration[]> = {
"1.1.0": [
// v1.0.0 -> v1.1.0: 添加新的配置项,设置默认值
(config) => ({
...config,
reconnectInterval: config.reconnectInterval ?? 5000,
heartbeatInterval: config.heartbeatInterval ?? 30000,
}),
],
"2.0.0": [
// v1.x.x -> v2.0.0: 重构配置结构
(config) => {
const { routerUrl, authToken, ...rest } = config;
return {
...rest,
gateway: {
url: routerUrl,
token: authToken,
},
};
},
],
};
/**
* 迁移配置
* @param config 当前配置
* @param fromVersion 当前版本
* @param toVersion 目标版本
* @returns 迁移后的配置
*/
export function migrateConfig(
config: PITBotAccountConfig,
fromVersion: string,
toVersion: string
): PITBotAccountConfig {
const log = createLogger(MODULE);
if (fromVersion === toVersion) {
return config;
}
let migratedConfig = { ...config };
const versions = Object.keys(configMigrations).sort();
for (const version of versions) {
// 只应用当前版本之后、目标版本之前的迁移
if (
compareVersions(version, fromVersion) > 0 &&
compareVersions(version, toVersion) <= 0
) {
const migrations = configMigrations[version];
if (migrations) {
for (const migration of migrations) {
try {
migratedConfig = migration(migratedConfig) as PITBotAccountConfig;
log.info(`Applied migration to ${version}`);
} catch (error) {
log.error(`Failed to apply migration to ${version}`, error);
}
}
}
}
}
return migratedConfig;
}
/**
* 比较版本号
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split(".").map(Number);
const parts2 = v2.split(".").map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] ?? 0;
const p2 = parts2[i] ?? 0;
if (p1 < p2) return -1;
if (p1 > p2) return 1;
}
return 0;
}

51
src/update/rollback.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* 回滚机制
* @module update/rollback
*/
import type { PITBotAccountConfig } from "../types.js";
import { createLogger } from "../utils/logger.js";
const MODULE = "update:rollback";
// 配置备份映射
const configBackups = new Map<string, PITBotAccountConfig>();
/**
* 备份配置
* @param accountId 账户 ID
* @param config 配置
*/
export function backupConfig(
accountId: string,
config: PITBotAccountConfig
): void {
const log = createLogger(MODULE);
configBackups.set(accountId, { ...config });
log.info(`Backed up config for ${accountId}`);
}
/**
* 恢复配置
* @param accountId 账户 ID
* @returns 备份的配置,如果没有则返回 null
*/
export function restoreConfig(accountId: string): PITBotAccountConfig | null {
const log = createLogger(MODULE);
const backup = configBackups.get(accountId);
if (backup) {
log.info(`Restored config for ${accountId}`);
return { ...backup };
}
return null;
}
/**
* 清除备份
* @param accountId 账户 ID
*/
export function clearBackup(accountId: string): void {
configBackups.delete(accountId);
}

92
src/update/version.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* 版本管理模块
* @module update/version
*/
import type { VersionInfo } from "../types.js";
import { createLogger } from "../utils/logger.js";
const MODULE = "update:version";
/**
* 当前版本(从 package.json 读取)
*/
let CURRENT_VERSION = "1.0.0";
/**
* 设置当前版本
*/
export function setCurrentVersion(version: string): void {
CURRENT_VERSION = version;
}
/**
* 检查更新
*/
export async function checkUpdate(): Promise<VersionInfo> {
const log = createLogger(MODULE);
try {
// TODO: 从 npm registry 或 GitHub releases 获取最新版本
// 这里简化处理
const latestVersion = "1.0.0";
return {
version: CURRENT_VERSION,
latestVersion,
hasUpdate: compareVersions(CURRENT_VERSION, latestVersion) < 0,
};
} catch (error) {
log.error("Failed to check update", error);
return {
version: CURRENT_VERSION,
hasUpdate: false,
};
}
}
/**
* 比较版本号
* @returns -1: v1 < v2, 0: v1 == v2, 1: v1 > v2
*/
export function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split(".").map(Number);
const parts2 = v2.split(".").map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] ?? 0;
const p2 = parts2[i] ?? 0;
if (p1 < p2) return -1;
if (p1 > p2) return 1;
}
return 0;
}
/**
* 解析版本号
*/
export function parseVersion(version: string): {
major: number;
minor: number;
patch: number;
prerelease?: string;
build?: string;
} {
const match = version.match(
/^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([a-zA-Z0-9.-]+))?(?:\\+([a-zA-Z0-9.-]+))?$/
);
if (!match) {
throw new Error(`Invalid version: ${version}`);
}
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
prerelease: match[4],
build: match[5],
};
}

175
src/utils.test.ts Normal file
View File

@@ -0,0 +1,175 @@
/**
* 工具模块测试
* @module utils.test
*/
import { describe, it, expect, beforeEach } from "vitest";
import { chunkText } from "../src/utils/chunker.js";
import { MessageQueue } from "../src/utils/queue.js";
import { MetricsCollector } from "../src/utils/metrics.js";
import { createLogger } from "../src/utils/logger.js";
describe("chunker", () => {
describe("chunkText", () => {
it("should not chunk short text", () => {
const text = "Hello, world!";
const chunks = chunkText(text, { limit: 4000 });
expect(chunks).toHaveLength(1);
expect(chunks[0]).toBe(text);
});
it("should chunk long text by paragraphs", () => {
const text = "Paragraph 1\n\nParagraph 2\n\nParagraph 3";
const chunks = chunkText(text, { limit: 20 });
expect(chunks.length).toBeGreaterThan(1);
});
it("should preserve code blocks", () => {
const text = "Text before\n```javascript\nconst x = 1;\n```\nText after";
const chunks = chunkText(text, { limit: 30, mode: "markdown" });
// Code block should not be split
expect(chunks.some(c => c.includes("```javascript"))).toBe(true);
});
it("should preserve URLs", () => {
const text = "Check out https://example.com/path for more info";
const chunks = chunkText(text, { limit: 50, mode: "markdown" });
// URL should not be split
expect(chunks.some(c => c.includes("https://example.com"))).toBe(true);
});
it("should handle plain mode", () => {
const text = "Line 1\nLine 2\nLine 3";
const chunks = chunkText(text, { limit: 10, mode: "plain" });
expect(chunks.length).toBeGreaterThan(1);
});
});
});
describe("MessageQueue", () => {
let queue: MessageQueue;
beforeEach(() => {
queue = new MessageQueue({ maxSize: 10, maxRetries: 3 });
});
it("should enqueue messages", () => {
const item = queue.enqueue("user1", "Hello");
expect(item).toBeDefined();
expect(item.to).toBe("user1");
expect(queue.length).toBe(1);
});
it("should dequeue messages", () => {
const item = queue.enqueue("user1", "Hello");
const dequeued = queue.dequeue(item.queueId);
expect(dequeued).toBeDefined();
expect(queue.length).toBe(0);
});
it("should get pending messages", () => {
queue.enqueue("user1", "Hello");
queue.enqueue("user2", "World");
const pending = queue.getPending();
expect(pending).toHaveLength(2);
});
it("should acknowledge messages", () => {
const item = queue.enqueue("user1", "Hello");
queue.acknowledge(item.messageId);
const pending = queue.getPending();
expect(pending).toHaveLength(0);
});
it("should increment retry count", () => {
const item = queue.enqueue("user1", "Hello");
const canRetry = queue.incrementRetry(item.queueId);
expect(canRetry).toBe(true);
expect(item.retryCount).toBe(1);
});
it("should drop oldest when full", () => {
const q = new MessageQueue({ maxSize: 2, maxRetries: 3 });
q.enqueue("user1", "Hello");
q.enqueue("user2", "World");
q.enqueue("user3", "Test"); // Should drop user1
expect(q.length).toBe(2);
});
});
describe("MetricsCollector", () => {
let metrics: MetricsCollector;
beforeEach(() => {
metrics = new MetricsCollector();
});
it("should track connection success", () => {
metrics.recordConnectionSuccess();
const data = metrics.getMetrics();
expect(data.connectionSuccess).toBe(1);
});
it("should track connection failure", () => {
metrics.recordConnectionFailure();
const data = metrics.getMetrics();
expect(data.connectionFailure).toBe(1);
});
it("should track messages sent", () => {
metrics.recordMessageSent();
const data = metrics.getMetrics();
expect(data.messagesSent).toBe(1);
});
it("should track messages received", () => {
metrics.recordMessageReceived();
const data = metrics.getMetrics();
expect(data.messagesReceived).toBe(1);
});
it("should track message ack", () => {
metrics.recordMessageAcked(100);
const data = metrics.getMetrics();
expect(data.messagesAcked).toBe(1);
expect(data.averageLatency).toBe(100);
});
it("should track ack timeout", () => {
metrics.recordMessageAckTimeout();
const data = metrics.getMetrics();
expect(data.messagesAckTimeout).toBe(1);
});
it("should update queue length", () => {
metrics.updateQueueLength(5);
const data = metrics.getMetrics();
expect(data.queueLength).toBe(5);
});
it("should reset metrics", () => {
metrics.recordConnectionSuccess();
metrics.reset();
const data = metrics.getMetrics();
expect(data.connectionSuccess).toBe(0);
});
});
describe("Logger", () => {
it("should create logger instance", () => {
const logger = createLogger("test");
expect(logger).toBeDefined();
expect(logger.info).toBeDefined();
expect(logger.debug).toBeDefined();
expect(logger.warn).toBeDefined();
expect(logger.error).toBeDefined();
});
it("should filter by log level", () => {
const logger = createLogger("test", "warn");
// Should not log debug or info
expect(() => logger.debug("debug")).not.toThrow();
expect(() => logger.info("info")).not.toThrow();
});
});

215
src/utils/chunker.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* 消息分块模块
* @module utils/chunker
*/
/**
* 分块选项
*/
export interface ChunkOptions {
/** 每块最大字符数 */
limit: number;
/** 分块模式 */
mode: "plain" | "markdown";
}
/**
* 默认分块限制
*/
export const DEFAULT_CHUNK_LIMIT = 4000;
/**
* 文本分块函数
* 智能处理 Markdown、URL、代码块等元素
*
* @param text 原始文本
* @param options 分块选项
* @returns 分块后的文本数组
*/
export function chunkText(text: string, options: Partial<ChunkOptions> = {}): string[] {
const limit = options.limit ?? DEFAULT_CHUNK_LIMIT;
const mode = options.mode ?? "markdown";
if (text.length <= limit) {
return [text];
}
if (mode === "markdown") {
return chunkMarkdown(text, limit);
}
return chunkPlain(text, limit);
}
/**
* Markdown 智能分块
*/
function chunkMarkdown(text: string, limit: number): string[] {
const chunks: string[] = [];
// 识别需要保持完整的元素
const protectedRanges = findProtectedRanges(text);
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= limit) {
chunks.push(remaining.trim());
break;
}
// 找到最佳分割点
const splitAt = findBestSplitPoint(remaining, limit, protectedRanges);
chunks.push(remaining.slice(0, splitAt).trim());
remaining = remaining.slice(splitAt).trimStart();
}
return chunks;
}
/**
* 查找需要保护的区域代码块、URL 等)
*/
function findProtectedRanges(text: string): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
// 代码块 ```...```
const codeBlockRegex = /```[\s\S]*?```/g;
let match;
while ((match = codeBlockRegex.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
// 行内代码 `...`
const inlineCodeRegex = /`[^`]+`/g;
while ((match = inlineCodeRegex.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
// URL
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g;
while ((match = urlRegex.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
// 链接 [text](url)
const linkRegex = /\[[^\]]+\]\([^)]+\)/g;
while ((match = linkRegex.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
return ranges;
}
/**
* 检查位置是否在保护区域内
*/
function isProtected(index: number, ranges: Array<{ start: number; end: number }>): boolean {
return ranges.some(r => index >= r.start && index < r.end);
}
/**
* 找到最佳分割点
*/
function findBestSplitPoint(
text: string,
limit: number,
protectedRanges: Array<{ start: number; end: number }>
): number {
// 优先级:段落 > 标题 > 列表 > 句子 > 词 > 强制
// 1. 尝试在段落边界分割(双换行)
let splitAt = findSplitBefore(text, "\n\n", limit, protectedRanges);
if (splitAt > limit * 0.3) return splitAt;
// 2. 尝试在标题边界分割
splitAt = findSplitBefore(text, "\n#", limit, protectedRanges);
if (splitAt > limit * 0.3) return splitAt;
// 3. 尝试在列表边界分割
splitAt = findSplitBefore(text, "\n-", limit, protectedRanges);
if (splitAt > limit * 0.3) return splitAt;
splitAt = findSplitBefore(text, "\n*", limit, protectedRanges);
if (splitAt > limit * 0.3) return splitAt;
// 4. 尝试在单换行分割
splitAt = findSplitBefore(text, "\n", limit, protectedRanges);
if (splitAt > limit * 0.3) return splitAt;
// 5. 尝试在句子边界分割
splitAt = findSplitBefore(text, "。", limit, protectedRanges);
if (splitAt > limit * 0.3) return splitAt;
splitAt = findSplitBefore(text, ".", limit, protectedRanges);
if (splitAt > limit * 0.3) return splitAt;
// 6. 尝试在词边界分割
splitAt = findSplitBefore(text, " ", limit, protectedRanges);
if (splitAt > limit * 0.3) return splitAt;
// 7. 强制分割,但避免保护区域
let forcedSplit = limit;
while (forcedSplit > limit * 0.5 && isProtected(forcedSplit, protectedRanges)) {
forcedSplit--;
}
return forcedSplit;
}
/**
* 在指定分隔符前查找分割点
*/
function findSplitBefore(
text: string,
separator: string,
limit: number,
protectedRanges: Array<{ start: number; end: number }>
): number {
let lastValidSplit = 0;
let searchPos = 0;
while (true) {
const pos = text.indexOf(separator, searchPos);
if (pos === -1 || pos > limit) break;
if (!isProtected(pos, protectedRanges)) {
lastValidSplit = pos;
}
searchPos = pos + 1;
}
return lastValidSplit > 0 ? lastValidSplit : 0;
}
/**
* 纯文本分块
*/
function chunkPlain(text: string, limit: number): string[] {
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= limit) {
chunks.push(remaining);
break;
}
// 尝试在换行符处分割
let splitAt = remaining.lastIndexOf("\n", limit);
if (splitAt <= 0 || splitAt < limit * 0.5) {
// 尝试在空格处分割
splitAt = remaining.lastIndexOf(" ", limit);
}
if (splitAt <= 0 || splitAt < limit * 0.5) {
// 强制分割
splitAt = limit;
}
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).trimStart();
}
return chunks;
}

10
src/utils/index.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* 工具模块入口
* @module utils
*/
export { createLogger, ChildLogger } from "./logger.js";
export { MetricsCollector } from "./metrics.js";
export { MessageQueue, type MessageQueueOptions } from "./queue.js";
export { chunkText, DEFAULT_CHUNK_LIMIT, type ChunkOptions } from "./chunker.js";
export * from "./payload.js";

82
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* 日志模块
* @module utils/logger
*/
import type { Logger, LogLevel, LogEntry } from "../types.js";
/**
* 创建日志器
* @param module 模块名
* @param minLevel 最小日志级别
* @returns 日志器实例
*/
export function createLogger(module: string, minLevel: LogLevel = "info"): Logger {
const levels: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
function log(level: LogLevel, message: string, data?: unknown): void {
if (levels[level] < levels[minLevel]) return;
const entry: LogEntry = {
timestamp: Date.now(),
level,
module,
message,
data,
};
const timestamp = new Date(entry.timestamp).toISOString();
const prefix = `[${timestamp}] [${level.toUpperCase()}] [${module}]`;
if (data !== undefined) {
console.log(`${prefix} ${message}`, JSON.stringify(data, null, 2));
} else {
console.log(`${prefix} ${message}`);
}
}
return {
debug: (message, data) => log("debug", message, data),
info: (message, data) => log("info", message, data),
warn: (message, data) => log("warn", message, data),
error: (message, data) => log("error", message, data),
};
}
/**
* 子日志器
*/
export class ChildLogger implements Logger {
private parent: Logger;
private prefix: string;
constructor(parent: Logger, prefix: string) {
this.parent = parent;
this.prefix = prefix;
}
private formatMessage(message: string): string {
return `[${this.prefix}] ${message}`;
}
debug(message: string, data?: unknown): void {
this.parent.debug(this.formatMessage(message), data);
}
info(message: string, data?: unknown): void {
this.parent.info(this.formatMessage(message), data);
}
warn(message: string, data?: unknown): void {
this.parent.warn(this.formatMessage(message), data);
}
error(message: string, data?: unknown): void {
this.parent.error(this.formatMessage(message), data);
}
}

123
src/utils/metrics.ts Normal file
View File

@@ -0,0 +1,123 @@
/**
* 指标监控模块
* @module utils/metrics
*/
import type { PITMetrics } from "../types.js";
/**
* 指标收集器
*/
export class MetricsCollector {
private metrics: PITMetrics = {
connectionSuccess: 0,
connectionFailure: 0,
messagesSent: 0,
messagesReceived: 0,
messagesAcked: 0,
messagesAckTimeout: 0,
averageLatency: 0,
queueLength: 0,
updatedAt: Date.now(),
};
private latencySamples: number[] = [];
private readonly maxSamples = 100;
/**
* 记录连接成功
*/
recordConnectionSuccess(): void {
this.metrics.connectionSuccess++;
this.metrics.updatedAt = Date.now();
}
/**
* 记录连接失败
*/
recordConnectionFailure(): void {
this.metrics.connectionFailure++;
this.metrics.updatedAt = Date.now();
}
/**
* 记录消息发送
*/
recordMessageSent(): void {
this.metrics.messagesSent++;
this.metrics.updatedAt = Date.now();
}
/**
* 记录消息接收
*/
recordMessageReceived(): void {
this.metrics.messagesReceived++;
this.metrics.updatedAt = Date.now();
}
/**
* 记录消息确认成功
* @param latency 延迟(毫秒)
*/
recordMessageAcked(latency: number): void {
this.metrics.messagesAcked++;
this.addLatencySample(latency);
this.metrics.updatedAt = Date.now();
}
/**
* 记录消息确认超时
*/
recordMessageAckTimeout(): void {
this.metrics.messagesAckTimeout++;
this.metrics.updatedAt = Date.now();
}
/**
* 更新队列长度
*/
updateQueueLength(length: number): void {
this.metrics.queueLength = length;
this.metrics.updatedAt = Date.now();
}
/**
* 获取当前指标
*/
getMetrics(): PITMetrics {
return { ...this.metrics };
}
/**
* 重置指标
*/
reset(): void {
this.metrics = {
connectionSuccess: 0,
connectionFailure: 0,
messagesSent: 0,
messagesReceived: 0,
messagesAcked: 0,
messagesAckTimeout: 0,
averageLatency: 0,
queueLength: 0,
updatedAt: Date.now(),
};
this.latencySamples = [];
}
private addLatencySample(latency: number): void {
this.latencySamples.push(latency);
if (this.latencySamples.length > this.maxSamples) {
this.latencySamples.shift();
}
this.metrics.averageLatency = this.calculateAverageLatency();
}
private calculateAverageLatency(): number {
if (this.latencySamples.length === 0) return 0;
const sum = this.latencySamples.reduce((a, b) => a + b, 0);
return Math.round(sum / this.latencySamples.length);
}
}

207
src/utils/payload.ts Normal file
View File

@@ -0,0 +1,207 @@
/**
* 消息负载处理模块
* @module utils/payload
*/
import type {
PITRouterMessage,
PITUserMessage,
PITAttachment,
PITAttachmentType
} from "../types.js";
import { v4 as uuidv4 } from "uuid";
/**
* 创建请求消息
*/
export function createRequest(
method: string,
params?: unknown
): PITRouterMessage {
return {
type: "request",
id: uuidv4(),
method,
params,
};
}
/**
* 创建响应消息
*/
export function createResponse(
id: string,
payload?: unknown,
error?: { code: string; message: string }
): PITRouterMessage {
return {
type: "response",
id: uuidv4(),
replyTo: id,
payload,
error,
};
}
/**
* 创建事件消息
*/
export function createEvent(
method: string,
params?: unknown
): PITRouterMessage {
return {
type: "event",
id: uuidv4(),
method,
params,
};
}
/**
* 创建确认消息
*/
export function createAck(messageId: string): PITRouterMessage {
return {
type: "ack",
id: uuidv4(),
replyTo: messageId,
};
}
/**
* 解析用户消息
*/
export function parseUserMessage(data: unknown): PITUserMessage | null {
if (!isRecord(data)) return null;
const { sessionId, userId, content, timestamp, attachments, replyTo } = data;
if (typeof sessionId !== "string" || typeof userId !== "string" || typeof content !== "string") {
return null;
}
return {
sessionId,
userId,
content,
timestamp: typeof timestamp === "number" ? timestamp : Date.now(),
attachments: parseAttachments(attachments),
replyTo: typeof replyTo === "string" ? replyTo : undefined,
};
}
/**
* 解析附件列表
*/
function parseAttachments(data: unknown): PITAttachment[] | undefined {
if (!Array.isArray(data)) return undefined;
const attachments: PITAttachment[] = [];
for (const item of data) {
if (!isRecord(item)) continue;
const type = parseAttachmentType(item.type);
if (!type) continue;
const url = typeof item.url === "string" ? item.url : undefined;
if (!url) continue;
attachments.push({
type,
url,
filename: typeof item.filename === "string" ? item.filename : undefined,
size: typeof item.size === "number" ? item.size : undefined,
mimeType: typeof item.mimeType === "string" ? item.mimeType : undefined,
});
}
return attachments.length > 0 ? attachments : undefined;
}
/**
* 解析附件类型
*/
function parseAttachmentType(value: unknown): PITAttachmentType | null {
if (typeof value !== "string") return null;
const validTypes: PITAttachmentType[] = ["image", "file", "audio", "video"];
return validTypes.includes(value as PITAttachmentType)
? (value as PITAttachmentType)
: null;
}
/**
* 创建发送消息的负载
*/
export function createSendPayload(
to: string,
content: string,
attachments?: PITAttachment[],
replyTo?: string
): unknown {
const payload: Record<string, unknown> = {
to,
content,
timestamp: Date.now(),
};
if (attachments && attachments.length > 0) {
payload.attachments = attachments;
}
if (replyTo) {
payload.replyTo = replyTo;
}
return payload;
}
/**
* 检查是否为记录对象
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/**
* 序列化消息
*/
export function serializeMessage(message: PITRouterMessage): string {
return JSON.stringify(message);
}
/**
* 反序列化消息
*/
export function deserializeMessage(data: string): PITRouterMessage | null {
try {
const parsed = JSON.parse(data);
if (!isRecord(parsed)) return null;
const { type, id, method, params, payload, error, replyTo } = parsed;
if (typeof type !== "string" || typeof id !== "string") {
return null;
}
return {
type: type as PITRouterMessage["type"],
id,
method: typeof method === "string" ? method : undefined,
params,
payload,
error: isRecord(error)
? {
code: String(error.code ?? "unknown"),
message: String(error.message ?? "Unknown error"),
details: error.details,
}
: undefined,
replyTo: typeof replyTo === "string" ? replyTo : undefined,
};
} catch {
return null;
}
}

191
src/utils/queue.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* 消息队列模块
* @module utils/queue
*/
import type { QueuedMessage, PITRouterMessage, Logger } from "../types.js";
import { createLogger } from "./logger.js";
/**
* 消息队列配置
*/
export interface MessageQueueOptions {
/** 最大队列长度 */
maxSize: number;
/** 最大重试次数 */
maxRetries: number;
/** 重试延迟(毫秒) */
retryDelay: number;
/** 日志器 */
logger?: Logger;
}
/**
* 消息队列
*/
export class MessageQueue {
private queue: Map<string, QueuedMessage> = new Map();
private options: MessageQueueOptions;
private log: Logger;
constructor(options: Partial<MessageQueueOptions> = {}) {
this.options = {
maxSize: options.maxSize ?? 100,
maxRetries: options.maxRetries ?? 3,
retryDelay: options.retryDelay ?? 5000,
logger: options.logger,
};
this.log = this.options.logger ?? createLogger("queue");
}
/**
* 入队消息
* @param to 目标用户
* @param content 消息内容
* @returns 队列项
*/
enqueue(to: string, content: string | PITRouterMessage): QueuedMessage {
// 检查队列是否已满
if (this.queue.size >= this.options.maxSize) {
// 移除最旧的消息
const oldest = this.getOldest();
if (oldest) {
this.queue.delete(oldest.queueId);
this.log.warn("Queue full, dropped oldest message", { queueId: oldest.queueId });
}
}
const queueId = this.generateQueueId();
const messageId = this.generateMessageId();
const item: QueuedMessage = {
queueId,
messageId,
to,
content,
enqueuedAt: Date.now(),
retryCount: 0,
maxRetries: this.options.maxRetries,
acknowledged: false,
};
this.queue.set(queueId, item);
this.log.debug("Message enqueued", { queueId, to });
return item;
}
/**
* 出队消息
* @param queueId 队列 ID
* @returns 消息项或 undefined
*/
dequeue(queueId: string): QueuedMessage | undefined {
const item = this.queue.get(queueId);
if (item) {
this.queue.delete(queueId);
this.log.debug("Message dequeued", { queueId });
}
return item;
}
/**
* 获取待处理消息
* @returns 待处理的消息列表
*/
getPending(): QueuedMessage[] {
return Array.from(this.queue.values())
.filter(item => !item.acknowledged && item.retryCount < item.maxRetries)
.sort((a, b) => a.enqueuedAt - b.enqueuedAt);
}
/**
* 标记消息已确认
* @param messageId 消息 ID
*/
acknowledge(messageId: string): void {
for (const item of this.queue.values()) {
if (item.messageId === messageId) {
item.acknowledged = true;
this.log.debug("Message acknowledged", { queueId: item.queueId, messageId });
break;
}
}
}
/**
* 增加重试计数
* @param queueId 队列 ID
* @returns 是否可以重试
*/
incrementRetry(queueId: string): boolean {
const item = this.queue.get(queueId);
if (!item) return false;
item.retryCount++;
const canRetry = item.retryCount < item.maxRetries;
this.log.debug("Retry incremented", {
queueId,
retryCount: item.retryCount,
canRetry
});
return canRetry;
}
/**
* 清理已确认和过期的消息
*/
cleanup(): void {
const now = Date.now();
const maxAge = 24 * 60 * 60 * 1000; // 24 小时
for (const [queueId, item] of this.queue.entries()) {
if (item.acknowledged || now - item.enqueuedAt > maxAge) {
this.queue.delete(queueId);
this.log.debug("Cleaned up message", { queueId });
}
}
}
/**
* 获取队列长度
*/
get length(): number {
return this.queue.size;
}
/**
* 检查队列是否为空
*/
isEmpty(): boolean {
return this.queue.size === 0;
}
/**
* 清空队列
*/
clear(): void {
this.queue.clear();
this.log.info("Queue cleared");
}
private getOldest(): QueuedMessage | undefined {
let oldest: QueuedMessage | undefined;
for (const item of this.queue.values()) {
if (!oldest || item.enqueuedAt < oldest.enqueuedAt) {
oldest = item;
}
}
return oldest;
}
private generateQueueId(): string {
return `q-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
private generateMessageId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}

227
src/webui/api.ts Normal file
View File

@@ -0,0 +1,227 @@
/**
* Web UI API 处理
* @module webui/api
*/
import { createLogger } from "../utils/logger.js";
const MODULE = "webui:api";
// 简化的类型定义
type ApiContext = {
getConfig: () => {
getSection: <T>(key: string) => T | undefined;
set: (key: string, value: unknown) => void;
};
getRuntime: (pluginId: string) => unknown;
logger: { info: (msg: string) => void; error: (msg: string) => void };
};
type HttpRequest = {
path: string;
method?: string;
body?: string;
headers: Record<string, string>;
};
type HttpResponse = {
status: number;
headers: Record<string, string>;
body: string;
};
/**
* 获取配置
*/
export async function handleGetConfig(
api: ApiContext,
_req: HttpRequest
): Promise<HttpResponse> {
const log = createLogger(MODULE);
try {
const cfg = api.getConfig();
const section = cfg.getSection<Record<string, unknown>>("channels.pit-bot");
// 隐藏敏感信息
const safeConfig = { ...section };
if (safeConfig.authToken) {
safeConfig.authToken = safeConfig.authToken
? "***"
: undefined;
}
if (safeConfig.accounts) {
for (const [_id, account] of Object.entries(safeConfig.accounts as Record<string, unknown>)) {
if (account && typeof account === "object") {
const acc = account as Record<string, unknown>;
if (acc.authToken) {
acc.authToken = "***";
}
}
}
}
return {
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config: safeConfig }),
};
} catch (error) {
log.error("Failed to get config", error);
return {
status: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Failed to get config" }),
};
}
}
/**
* 更新配置
*/
export async function handleUpdateConfig(
api: ApiContext,
req: HttpRequest
): Promise<HttpResponse> {
const log = createLogger(MODULE);
try {
const body = JSON.parse(req.body || "{}");
const { config } = body;
if (!config) {
return {
status: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Missing config" }),
};
}
const cfg = api.getConfig();
// 更新配置(合并)
const current = cfg.getSection("channels.pit-bot") || {};
const updated = { ...current, ...config };
cfg.set("channels.pit-bot", updated);
return {
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ success: true }),
};
} catch (error) {
log.error("Failed to update config", error);
return {
status: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Failed to update config" }),
};
}
}
/**
* 获取状态
*/
export async function handleGetStatus(
api: ApiContext,
_req: HttpRequest
): Promise<HttpResponse> {
const log = createLogger(MODULE);
try {
const runtime = api.getRuntime("pit-bot");
return {
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: runtime || { running: false, connected: false },
}),
};
} catch (error) {
log.error("Failed to get status", error);
return {
status: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Failed to get status" }),
};
}
}
/**
* 获取指标
*/
export async function handleGetMetrics(
_api: ApiContext,
_req: HttpRequest
): Promise<HttpResponse> {
return {
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
metrics: {
connectionSuccess: 0,
connectionFailure: 0,
messagesSent: 0,
messagesReceived: 0,
messagesAcked: 0,
messagesAckTimeout: 0,
averageLatency: 0,
queueLength: 0,
updatedAt: Date.now(),
},
}),
};
}
/**
* 连接
*/
export async function handleConnect(
_api: ApiContext,
_req: HttpRequest
): Promise<HttpResponse> {
const log = createLogger(MODULE);
try {
return {
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ success: true, message: "Connecting..." }),
};
} catch (error) {
log.error("Failed to connect", error);
return {
status: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Failed to connect" }),
};
}
}
/**
* 断开连接
*/
export async function handleDisconnect(
_api: ApiContext,
_req: HttpRequest
): Promise<HttpResponse> {
const log = createLogger(MODULE);
try {
return {
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ success: true, message: "Disconnecting..." }),
};
} catch (error) {
log.error("Failed to disconnect", error);
return {
status: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Failed to disconnect" }),
};
}
}

8
src/webui/index.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Web UI 模块入口
* @module webui
*/
export { registerWebUIRoutes } from "./routes.js";
export * from "./api.js";
export { serveUI, serveStatic } from "./static.js";

101
src/webui/routes.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Web UI 模块 - HTTP 路由
* @module webui/routes
*/
import { createLogger } from "../utils/logger.js";
const MODULE = "webui";
const UI_PATH = "/plugins/pit-bot";
const API_PATH = "/plugins/pit-bot/api";
// 简化的类型定义
type ApiContext = {
registerHttpRoute: (route: {
path: string;
auth: string;
match?: string;
method?: string;
handler: (api: unknown, req: unknown) => Promise<unknown> | unknown;
}) => void;
logger: { info: (msg: string) => void };
};
/**
* 注册 Web UI 路由
*/
export function registerWebUIRoutes(api: ApiContext): void {
const log = createLogger(MODULE);
// 动态导入避免循环依赖
const { handleGetConfig, handleUpdateConfig, handleGetStatus, handleGetMetrics, handleConnect, handleDisconnect } = require("./api.js");
const { serveUI, serveStatic } = require("./static.js");
// Web UI 入口
api.registerHttpRoute({
path: UI_PATH,
auth: "gateway",
match: "exact",
handler: serveUI,
});
api.registerHttpRoute({
path: `${UI_PATH}/`,
auth: "gateway",
match: "exact",
handler: serveUI,
});
// REST API
api.registerHttpRoute({
path: `${API_PATH}/config`,
auth: "gateway",
method: "GET",
handler: handleGetConfig,
});
api.registerHttpRoute({
path: `${API_PATH}/config`,
auth: "gateway",
method: "PUT",
handler: handleUpdateConfig,
});
api.registerHttpRoute({
path: `${API_PATH}/status`,
auth: "gateway",
method: "GET",
handler: handleGetStatus,
});
api.registerHttpRoute({
path: `${API_PATH}/metrics`,
auth: "gateway",
method: "GET",
handler: handleGetMetrics,
});
api.registerHttpRoute({
path: `${API_PATH}/connect`,
auth: "gateway",
method: "POST",
handler: handleConnect,
});
api.registerHttpRoute({
path: `${API_PATH}/disconnect`,
auth: "gateway",
method: "POST",
handler: handleDisconnect,
});
// 静态资源
api.registerHttpRoute({
path: `${UI_PATH}/static`,
auth: "plugin",
match: "prefix",
handler: serveStatic,
});
log.info("Web UI routes registered");
}

248
src/webui/static.ts Normal file
View File

@@ -0,0 +1,248 @@
/**
* Web UI 静态资源服务
* @module webui/static
*/
import { createLogger } from "../utils/logger.js";
const MODULE = "webui:static";
type HttpRequest = {
path: string;
method?: string;
body?: string;
headers: Record<string, string>;
};
type HttpResponse = {
status: number;
headers: Record<string, string>;
body: string;
};
// 内联 HTML 页面
const INDEX_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PIT Bot 配置</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; padding: 20px; }
h1 { color: #333; margin-bottom: 20px; }
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.status { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
.status-dot { width: 12px; height: 12px; border-radius: 50%; }
.status-dot.connected { background: #4caf50; }
.status-dot.disconnected { background: #f44336; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; color: #666; font-size: 14px; }
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
input:focus { outline: none; border-color: #2196f3; }
.btn { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn-primary { background: #2196f3; color: white; }
.btn-primary:hover { background: #1976d2; }
.btn-danger { background: #f44336; color: white; }
.btn-danger:hover { background: #d32f2f; }
.btn-group { display: flex; gap: 10px; }
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; }
.metric { text-align: center; padding: 15px; background: #f9f9f9; border-radius: 4px; }
.metric-value { font-size: 24px; font-weight: bold; color: #2196f3; }
.metric-label { font-size: 12px; color: #999; margin-top: 5px; }
</style>
</head>
<body>
<div class="container">
<h1>PIT Bot 配置</h1>
<div class="card">
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Loading...</span>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="connect()">连接</button>
<button class="btn btn-danger" onclick="disconnect()">断开</button>
</div>
</div>
<div class="card">
<h2>配置</h2>
<form id="configForm">
<div class="form-group">
<label>Router URL</label>
<input type="text" id="routerUrl" placeholder="ws://localhost:9000/ws">
</div>
<div class="form-group">
<label>Auth Token</label>
<input type="password" id="authToken" placeholder="Enter token or \${ENV_VAR}">
</div>
<div class="form-group">
<label>重连间隔 (ms)</label>
<input type="number" id="reconnectInterval" value="5000">
</div>
<div class="form-group">
<label>心跳间隔 (ms)</label>
<input type="number" id="heartbeatInterval" value="30000">
</div>
<button type="submit" class="btn btn-primary">保存配置</button>
</form>
</div>
<div class="card">
<h2>指标</h2>
<div class="metrics" id="metrics">
<div class="metric">
<div class="metric-value" id="messagesSent">0</div>
<div class="metric-label">已发送</div>
</div>
<div class="metric">
<div class="metric-value" id="messagesReceived">0</div>
<div class="metric-label">已接收</div>
</div>
<div class="metric">
<div class="metric-value" id="queueLength">0</div>
<div class="metric-label">队列长度</div>
</div>
<div class="metric">
<div class="metric-value" id="avgLatency">0ms</div>
<div class="metric-label">平均延迟</div>
</div>
</div>
</div>
</div>
<script>
// 加载状态
async function loadStatus() {
try {
const res = await fetch('/plugins/pit-bot/api/status');
const data = await res.json();
const status = data.status;
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
if (status.connected) {
dot.className = 'status-dot connected';
text.textContent = '已连接';
} else {
dot.className = 'status-dot disconnected';
text.textContent = '未连接';
}
} catch (e) {
console.error('Failed to load status:', e);
}
}
// 加载配置
async function loadConfig() {
try {
const res = await fetch('/plugins/pit-bot/api/config');
const data = await res.json();
const config = data.config || {};
document.getElementById('routerUrl').value = config.routerUrl || '';
document.getElementById('authToken').value = '';
document.getElementById('reconnectInterval').value = config.reconnectInterval || 5000;
document.getElementById('heartbeatInterval').value = config.heartbeatInterval || 30000;
} catch (e) {
console.error('Failed to load config:', e);
}
}
// 加载指标
async function loadMetrics() {
try {
const res = await fetch('/plugins/pit-bot/api/metrics');
const data = await res.json();
const m = data.metrics || {};
document.getElementById('messagesSent').textContent = m.messagesSent || 0;
document.getElementById('messagesReceived').textContent = m.messagesReceived || 0;
document.getElementById('queueLength').textContent = m.queueLength || 0;
document.getElementById('avgLatency').textContent = (m.averageLatency || 0) + 'ms';
} catch (e) {
console.error('Failed to load metrics:', e);
}
}
// 连接
async function connect() {
await fetch('/plugins/pit-bot/api/connect', { method: 'POST' });
loadStatus();
}
// 断开
async function disconnect() {
await fetch('/plugins/pit-bot/api/disconnect', { method: 'POST' });
loadStatus();
}
// 保存配置
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault();
const config = {
routerUrl: document.getElementById('routerUrl').value,
authToken: document.getElementById('authToken').value,
reconnectInterval: parseInt(document.getElementById('reconnectInterval').value),
heartbeatInterval: parseInt(document.getElementById('heartbeatInterval').value),
};
await fetch('/plugins/pit-bot/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
});
alert('配置已保存');
});
// 初始化
loadStatus();
loadConfig();
loadMetrics();
setInterval(loadStatus, 5000);
setInterval(loadMetrics, 10000);
</script>
</body>
</html>`;
/**
* 服务 Web UI 页面
*/
export async function serveUI(
_api: unknown,
_req: HttpRequest
): Promise<HttpResponse> {
return {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache",
},
body: INDEX_HTML,
};
}
/**
* 服务静态资源
*/
export async function serveStatic(
_api: unknown,
req: HttpRequest
): Promise<HttpResponse> {
const log = createLogger(MODULE);
const path = req.path || "";
log.debug("Serving static", { path });
return {
status: 404,
headers: { "Content-Type": "text/plain" },
body: "Not found",
};
}

33
tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ES2022"],
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"removeComments": false,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}