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