feat: PIT Channel plugin v1.0.0 - complete implementation
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal 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
31
CHANGELOG.md
Normal 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
|
||||||
393
docs/PIT_Channel_Technical_Spec.md
Normal file
393
docs/PIT_Channel_Technical_Spec.md
Normal 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
96
openclaw.plugin.json
Normal 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
64
package.json
Normal 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
338
src/channel.ts
Normal 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
221
src/config.ts
Normal 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
538
src/gateway.ts
Normal 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
11
src/index.ts
Normal 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
118
src/outbound.ts
Normal 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
340
src/types.ts
Normal 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
8
src/update/index.ts
Normal 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
99
src/update/migrate.ts
Normal 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
51
src/update/rollback.ts
Normal 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
92
src/update/version.ts
Normal 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
175
src/utils.test.ts
Normal 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
215
src/utils/chunker.ts
Normal 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
10
src/utils/index.ts
Normal 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
82
src/utils/logger.ts
Normal 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
123
src/utils/metrics.ts
Normal 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
207
src/utils/payload.ts
Normal 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
191
src/utils/queue.ts
Normal 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
227
src/webui/api.ts
Normal 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
8
src/webui/index.ts
Normal 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
101
src/webui/routes.ts
Normal 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
248
src/webui/static.ts
Normal 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
33
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user