From ade55c50161d1100246831f2451a9521dfafd7bd Mon Sep 17 00:00:00 2001 From: "feifei.xu" <307327147@qq.com> Date: Sat, 14 Mar 2026 15:54:30 +0800 Subject: [PATCH] feat: PIT Channel plugin v1.0.0 - complete implementation --- .gitignore | 43 +++ CHANGELOG.md | 31 ++ docs/PIT_Channel_Technical_Spec.md | 393 +++++++++++++++++++++ openclaw.plugin.json | 96 +++++ package.json | 64 ++++ src/channel.ts | 338 ++++++++++++++++++ src/config.ts | 221 ++++++++++++ src/gateway.ts | 538 +++++++++++++++++++++++++++++ src/index.ts | 11 + src/outbound.ts | 118 +++++++ src/types.ts | 340 ++++++++++++++++++ src/update/index.ts | 8 + src/update/migrate.ts | 99 ++++++ src/update/rollback.ts | 51 +++ src/update/version.ts | 92 +++++ src/utils.test.ts | 175 ++++++++++ src/utils/chunker.ts | 215 ++++++++++++ src/utils/index.ts | 10 + src/utils/logger.ts | 82 +++++ src/utils/metrics.ts | 123 +++++++ src/utils/payload.ts | 207 +++++++++++ src/utils/queue.ts | 191 ++++++++++ src/webui/api.ts | 227 ++++++++++++ src/webui/index.ts | 8 + src/webui/routes.ts | 101 ++++++ src/webui/static.ts | 248 +++++++++++++ tsconfig.json | 33 ++ 27 files changed, 4063 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 docs/PIT_Channel_Technical_Spec.md create mode 100644 openclaw.plugin.json create mode 100644 package.json create mode 100644 src/channel.ts create mode 100644 src/config.ts create mode 100644 src/gateway.ts create mode 100644 src/index.ts create mode 100644 src/outbound.ts create mode 100644 src/types.ts create mode 100644 src/update/index.ts create mode 100644 src/update/migrate.ts create mode 100644 src/update/rollback.ts create mode 100644 src/update/version.ts create mode 100644 src/utils.test.ts create mode 100644 src/utils/chunker.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/logger.ts create mode 100644 src/utils/metrics.ts create mode 100644 src/utils/payload.ts create mode 100644 src/utils/queue.ts create mode 100644 src/webui/api.ts create mode 100644 src/webui/index.ts create mode 100644 src/webui/routes.ts create mode 100644 src/webui/static.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b68513f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6e75173 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/docs/PIT_Channel_Technical_Spec.md b/docs/PIT_Channel_Technical_Spec.md new file mode 100644 index 0000000..8398e1e --- /dev/null +++ b/docs/PIT_Channel_Technical_Spec.md @@ -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 = 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 = { + "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(); + +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* diff --git a/openclaw.plugin.json b/openclaw.plugin.json new file mode 100644 index 0000000..340c1cf --- /dev/null +++ b/openclaw.plugin.json @@ -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"] + } + } + ] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3026849 --- /dev/null +++ b/package.json @@ -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" + ] +} diff --git a/src/channel.ts b/src/channel.ts new file mode 100644 index 0000000..bf4a958 --- /dev/null +++ b/src/channel.ts @@ -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(); + +/** + * PIT Bot Channel Plugin + */ +export const pitBotPlugin: ChannelPlugin = { + 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: or ", + }, + } 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>("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, + + 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, + + gateway: { + startAccount: async (ctx: { + account: ResolvedPITBotAccount; + abortSignal?: AbortSignal; + cfg: OpenClawConfig; + log?: { info: (msg: string) => void; error: (msg: string) => void }; + setStatus: (state: Partial) => 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) => 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, + + 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; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b85d3a6 --- /dev/null +++ b/src/config.ts @@ -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 }>("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; + }>("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; + } +} diff --git a/src/gateway.ts b/src/gateway.ts new file mode 100644 index 0000000..4c04e47 --- /dev/null +++ b/src/gateway.ts @@ -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 { + 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 void }>(); + + const heartbeatInterval = account.config.heartbeatInterval ?? 30000; + const heartbeatTimeout = account.config.heartbeatTimeout ?? 10000; + const ackTimeout = account.config.ackTimeout ?? 30000; + + /** + * 更新连接状态 + */ + function updateStatus(state: Partial): void { + setStatus(state); + } + + /** + * 连接 WebSocket + */ + async function connect(): Promise { + 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; + 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 = { + 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 { + 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, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a05ccab --- /dev/null +++ b/src/index.ts @@ -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"; diff --git a/src/outbound.ts b/src/outbound.ts new file mode 100644 index 0000000..e55d990 --- /dev/null +++ b/src/outbound.ts @@ -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 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): void { + gatewayMap = map as unknown as typeof gatewayMap; +} + +/** + * 发送文本消息 + */ +export async function sendText(options: { + to: string; + text: string; + accountId: string; + replyToId?: string; +}): Promise { + 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 { + 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 }; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..66a1bce --- /dev/null +++ b/src/types.ts @@ -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) => Record; + +/** + * Gateway 上下文 + */ +export interface GatewayContext { + /** 账户信息 */ + account: ResolvedPITBotAccount; + /** 中止信号 */ + abortSignal?: AbortSignal; + /** 配置 */ + cfg: unknown; + /** 日志器 */ + log: Logger; + /** 状态更新回调 */ + setStatus: (state: Partial) => 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; +} diff --git a/src/update/index.ts b/src/update/index.ts new file mode 100644 index 0000000..8cf3bf0 --- /dev/null +++ b/src/update/index.ts @@ -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"; diff --git a/src/update/migrate.ts b/src/update/migrate.ts new file mode 100644 index 0000000..89c3141 --- /dev/null +++ b/src/update/migrate.ts @@ -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 = { + "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; +} diff --git a/src/update/rollback.ts b/src/update/rollback.ts new file mode 100644 index 0000000..7641626 --- /dev/null +++ b/src/update/rollback.ts @@ -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(); + +/** + * 备份配置 + * @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); +} diff --git a/src/update/version.ts b/src/update/version.ts new file mode 100644 index 0000000..36389f9 --- /dev/null +++ b/src/update/version.ts @@ -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 { + 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], + }; +} diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..b96ce5f --- /dev/null +++ b/src/utils.test.ts @@ -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(); + }); +}); diff --git a/src/utils/chunker.ts b/src/utils/chunker.ts new file mode 100644 index 0000000..bbd8237 --- /dev/null +++ b/src/utils/chunker.ts @@ -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 = {}): 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; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..39573f3 --- /dev/null +++ b/src/utils/index.ts @@ -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"; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..37bd900 --- /dev/null +++ b/src/utils/logger.ts @@ -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 = { + 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); + } +} diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts new file mode 100644 index 0000000..bbaa0b2 --- /dev/null +++ b/src/utils/metrics.ts @@ -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); + } +} diff --git a/src/utils/payload.ts b/src/utils/payload.ts new file mode 100644 index 0000000..bedb099 --- /dev/null +++ b/src/utils/payload.ts @@ -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 = { + 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 { + 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; + } +} diff --git a/src/utils/queue.ts b/src/utils/queue.ts new file mode 100644 index 0000000..3cab612 --- /dev/null +++ b/src/utils/queue.ts @@ -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 = new Map(); + private options: MessageQueueOptions; + private log: Logger; + + constructor(options: Partial = {}) { + 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)}`; + } +} diff --git a/src/webui/api.ts b/src/webui/api.ts new file mode 100644 index 0000000..5f4ae8a --- /dev/null +++ b/src/webui/api.ts @@ -0,0 +1,227 @@ +/** + * Web UI API 处理 + * @module webui/api + */ + +import { createLogger } from "../utils/logger.js"; + +const MODULE = "webui:api"; + +// 简化的类型定义 +type ApiContext = { + getConfig: () => { + getSection: (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; +}; + +type HttpResponse = { + status: number; + headers: Record; + body: string; +}; + +/** + * 获取配置 + */ +export async function handleGetConfig( + api: ApiContext, + _req: HttpRequest +): Promise { + const log = createLogger(MODULE); + + try { + const cfg = api.getConfig(); + const section = cfg.getSection>("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)) { + if (account && typeof account === "object") { + const acc = account as Record; + 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 { + 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 { + 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 { + 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 { + 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 { + 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" }), + }; + } +} diff --git a/src/webui/index.ts b/src/webui/index.ts new file mode 100644 index 0000000..460fb6c --- /dev/null +++ b/src/webui/index.ts @@ -0,0 +1,8 @@ +/** + * Web UI 模块入口 + * @module webui + */ + +export { registerWebUIRoutes } from "./routes.js"; +export * from "./api.js"; +export { serveUI, serveStatic } from "./static.js"; diff --git a/src/webui/routes.ts b/src/webui/routes.ts new file mode 100644 index 0000000..780c5c6 --- /dev/null +++ b/src/webui/routes.ts @@ -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; + }) => 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"); +} diff --git a/src/webui/static.ts b/src/webui/static.ts new file mode 100644 index 0000000..7c01a2b --- /dev/null +++ b/src/webui/static.ts @@ -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; +}; + +type HttpResponse = { + status: number; + headers: Record; + body: string; +}; + +// 内联 HTML 页面 +const INDEX_HTML = ` + + + + + PIT Bot 配置 + + + +
+

PIT Bot 配置

+ +
+
+
+ Loading... +
+
+ + +
+
+ +
+

配置

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

指标

+
+
+
0
+
已发送
+
+
+
0
+
已接收
+
+
+
0
+
队列长度
+
+
+
0ms
+
平均延迟
+
+
+
+
+ + + +`; + +/** + * 服务 Web UI 页面 + */ +export async function serveUI( + _api: unknown, + _req: HttpRequest +): Promise { + 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 { + const log = createLogger(MODULE); + + const path = req.path || ""; + log.debug("Serving static", { path }); + + return { + status: 404, + headers: { "Content-Type": "text/plain" }, + body: "Not found", + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0512668 --- /dev/null +++ b/tsconfig.json @@ -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" + ] +}