forked from datawhale/whale-town-end
feat(zulip): 添加全面的 Zulip 集成系统
* **新增 Zulip 模块**:包含完整的集成服务,涵盖客户端池(client pool)、会话管理及事件处理。 * **新增 WebSocket 网关**:用于处理 Zulip 的实时事件监听与双向通信。 * **新增安全服务**:支持 API 密钥加密存储及凭据的安全管理。 * **新增配置管理服务**:支持配置热加载(hot-reload),实现动态配置更新。 * **新增错误处理与监控服务**:提升系统的可靠性与可观测性。 * **新增消息过滤服务**:用于内容校验及速率限制(流控)。 * **新增流初始化与会话清理服务**:优化资源管理与回收。 * **完善测试覆盖**:包含单元测试及端到端(e2e)集成测试。 * **完善详细文档**:包括 API 参考手册、配置指南及集成概述。 * **新增地图配置系统**:实现游戏地点与 Zulip Stream(频道)及 Topic(话题)的逻辑映射。 * **新增环境变量配置**:涵盖 Zulip 服务器地址、身份验证及监控相关设置。 * **更新 App 模块**:注册并启用新的 Zulip 集成模块。 * **更新 Redis 接口**:以支持增强型的会话管理功能。 * **实现 WebSocket 协议支持**:确保与 Zulip 之间的实时双向通信。
This commit is contained in:
377
docs/systems/zulip/README.md
Normal file
377
docs/systems/zulip/README.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# Zulip 集成系统文档
|
||||
|
||||
## 概述
|
||||
|
||||
Zulip 集成系统是一个为 2D 社交 MMO 游戏设计的跨平台聊天解决方案。该系统实现了游戏内外的无缝互通,让不玩游戏的 Zulip 社群成员也能与游戏内玩家实时交流。
|
||||
|
||||
### 核心设计理念
|
||||
|
||||
系统采用 **统一网关 (Unified Gateway)** 架构,利用 Zulip 的 Stream-Topic 线程模型与游戏世界的空间概念进行映射:
|
||||
|
||||
| 游戏概念 | Zulip 概念 | 示例 |
|
||||
|---------|-----------|------|
|
||||
| Game World / Map | Stream | #Novice_Village |
|
||||
| Interactive Object / Event | Topic | Notice Board, Tavern Gossip |
|
||||
| Whisper / Party | Private Message | 私聊消息 |
|
||||
|
||||
### 架构优势
|
||||
|
||||
1. **客户端极度简化**: Godot 客户端无需处理 HTTP 请求、Long Polling 或复杂 JSON 解析
|
||||
2. **安全性**: Zulip API Key 永不下发到客户端,位置欺诈完全消除
|
||||
3. **协议统一**: 单一 WebSocket 协议,网络层代码减半
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌─────────────────────────────────────┐
|
||||
│ Godot Client │◄──────────────────►│ NestJS 中间件服务器 │
|
||||
│ (Game Client) │ Game Protocol │ ┌─────────────────────────────────┐│
|
||||
└─────────────────┘ │ │ WebSocket Gateway ││
|
||||
│ │ ├─ Session Manager ││
|
||||
│ │ ├─ Message Filter ││
|
||||
│ │ └─ Zulip Client Pool ││
|
||||
│ └─────────────────────────────────┘│
|
||||
└──────────────┬──────────────────────┘
|
||||
│ REST API / Long Polling
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Zulip Server │
|
||||
│ ├─ REST API │
|
||||
│ └─ Event Queue │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. WebSocket Gateway (`zulip-websocket.gateway.ts`)
|
||||
|
||||
统一网关,处理所有 Godot 客户端连接,实现游戏协议到 Zulip 协议的转换。
|
||||
|
||||
**主要功能:**
|
||||
- 连接认证和会话管理
|
||||
- 消息路由和协议转换
|
||||
- 权限控制和上下文注入
|
||||
|
||||
**支持的消息类型:**
|
||||
- `login`: 玩家登录
|
||||
- `chat`: 发送聊天消息
|
||||
- `position_update`: 位置更新
|
||||
- `logout`: 玩家登出
|
||||
|
||||
### 2. Session Manager (`session-manager.service.ts`)
|
||||
|
||||
会话管理器,维护 Socket_ID 与 Zulip_Queue_ID 的绑定关系。
|
||||
|
||||
**主要功能:**
|
||||
- 会话创建/销毁
|
||||
- 玩家位置跟踪
|
||||
- 上下文注入(根据位置确定 Stream/Topic)
|
||||
- 空间过滤(获取指定地图的所有 Socket)
|
||||
|
||||
### 3. Zulip Client Pool (`zulip-client-pool.service.ts`)
|
||||
|
||||
Zulip 客户端池,为每个用户维护专用的 Zulip 客户端实例。
|
||||
|
||||
**主要功能:**
|
||||
- API Key 管理
|
||||
- 事件队列注册
|
||||
- 消息发送/接收
|
||||
- 客户端生命周期管理
|
||||
|
||||
### 4. Message Filter (`message-filter.service.ts`)
|
||||
|
||||
消息过滤器,实施内容审核和频率控制。
|
||||
|
||||
**主要功能:**
|
||||
- 敏感词过滤
|
||||
- 频率限制(默认 10 条/分钟)
|
||||
- 消息长度限制(默认 1000 字符)
|
||||
- 重复内容检测
|
||||
- 权限验证
|
||||
|
||||
### 5. Config Manager (`config-manager.service.ts`)
|
||||
|
||||
配置管理器,管理地图映射配置和系统参数。
|
||||
|
||||
**主要功能:**
|
||||
- 地图到 Stream 的映射
|
||||
- 交互对象到 Topic 的映射
|
||||
- 配置热重载
|
||||
- 配置验证
|
||||
|
||||
### 6. Stream Initializer Service (`stream-initializer.service.ts`)
|
||||
|
||||
Stream 初始化服务,在系统启动时自动检查并创建缺失的 Zulip Streams。
|
||||
|
||||
**主要功能:**
|
||||
- 启动时自动检查所有地图对应的 Streams
|
||||
- 自动创建缺失的 Streams
|
||||
- 使用 Bot API Key 或管理员账号创建 Streams
|
||||
- 记录初始化结果和错误
|
||||
|
||||
**权限说明:**
|
||||
- Bot 账号可能缺少创建 Stream 的权限
|
||||
- 建议使用管理员账号手动创建 Streams
|
||||
- 或在 Zulip 服务器中为 Bot 授予相应权限
|
||||
|
||||
### 7. Monitoring Service (`monitoring.service.ts`)
|
||||
|
||||
监控服务,提供系统健康检查和指标收集。
|
||||
|
||||
**主要功能:**
|
||||
- 连接指标监控
|
||||
- 消息指标监控
|
||||
- 系统健康检查
|
||||
- 告警通知
|
||||
|
||||
## 消息协议
|
||||
|
||||
### 客户端发送格式
|
||||
|
||||
#### 登录消息
|
||||
```json
|
||||
{
|
||||
"type": "login",
|
||||
"token": "user_game_token"
|
||||
}
|
||||
```
|
||||
|
||||
#### 聊天消息
|
||||
```json
|
||||
{
|
||||
"t": "chat",
|
||||
"content": "Hello",
|
||||
"scope": "local"
|
||||
}
|
||||
```
|
||||
|
||||
#### 位置更新
|
||||
```json
|
||||
{
|
||||
"t": "position",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"mapId": "novice_village"
|
||||
}
|
||||
```
|
||||
|
||||
### 客户端接收格式
|
||||
|
||||
#### 聊天渲染消息
|
||||
```json
|
||||
{
|
||||
"t": "chat_render",
|
||||
"from": "User_B",
|
||||
"txt": "Hi",
|
||||
"bubble": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 登录确认
|
||||
```json
|
||||
{
|
||||
"t": "login_success",
|
||||
"sessionId": "session_123",
|
||||
"currentMap": "novice_village"
|
||||
}
|
||||
```
|
||||
|
||||
#### 错误消息
|
||||
```json
|
||||
{
|
||||
"t": "error",
|
||||
"code": "RATE_LIMIT",
|
||||
"message": "消息发送过于频繁,请稍后再试"
|
||||
}
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```bash
|
||||
# Zulip 服务器配置
|
||||
ZULIP_SERVER_URL=https://your-zulip-server.com
|
||||
ZULIP_BOT_EMAIL=bot@your-zulip-server.com
|
||||
ZULIP_BOT_API_KEY=your-bot-api-key
|
||||
|
||||
# WebSocket 配置
|
||||
WEBSOCKET_PORT=3001
|
||||
WEBSOCKET_NAMESPACE=/game
|
||||
|
||||
# 消息配置
|
||||
MESSAGE_RATE_LIMIT=10 # 消息频率限制(条/分钟)
|
||||
MESSAGE_MAX_LENGTH=1000 # 消息最大长度
|
||||
|
||||
# 会话配置
|
||||
SESSION_TIMEOUT=30 # 会话超时时间(分钟)
|
||||
CLEANUP_INTERVAL=5 # 清理间隔(分钟)
|
||||
```
|
||||
|
||||
### Stream 初始化
|
||||
|
||||
系统在启动时会自动检查并尝试创建缺失的 Zulip Streams。
|
||||
|
||||
**注意事项:**
|
||||
|
||||
- Bot 账号可能缺少创建 Stream 的权限
|
||||
- 建议使用管理员账号预先创建所有 Streams
|
||||
- 或在 Zulip 服务器中为 Bot 授予 "Create streams" 权限
|
||||
|
||||
**手动创建 Streams:**
|
||||
|
||||
```bash
|
||||
# 使用测试脚本创建所有地图区域的 Streams
|
||||
node test-stream-initialization.js
|
||||
```
|
||||
|
||||
详细配置说明请参考 [配置管理指南](./configuration.md)。
|
||||
|
||||
### 地图映射配置
|
||||
|
||||
配置文件位置: `config/zulip/map-config.json`
|
||||
|
||||
系统支持 9 个地图区域,每个区域对应一个 Zulip Stream:
|
||||
|
||||
1. **鲸之港 (Whale Port)** - 中心城区,默认出生点
|
||||
2. **南瓜谷 (Pumpkin Valley)** - 新手学习区
|
||||
3. **Offer 城 (Offer City)** - 职业发展区
|
||||
4. **模型工厂 (Model Factory)** - AI/代码构建区
|
||||
5. **内核岛 (Kernel Island)** - 核心技术研究区
|
||||
6. **摸鱼海滩 (Moyu Beach)** - 休闲娱乐区
|
||||
7. **天梯峰 (Ladder Peak)** - 挑战竞赛区
|
||||
8. **星河湾 (Galaxy Bay)** - 创意设计区
|
||||
9. **数据遗迹 (Data Ruins)** - 数据库归档区
|
||||
|
||||
配置示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"description": "基于像素大地图的 Zulip 映射配置",
|
||||
"maps": [
|
||||
{
|
||||
"mapId": "whale_port",
|
||||
"mapName": "鲸之港",
|
||||
"zulipStream": "Whale Port",
|
||||
"description": "中心城区,交通枢纽与主要聚会点",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "whale_statue",
|
||||
"objectName": "鲸鱼雕像",
|
||||
"zulipTopic": "Announcements",
|
||||
"position": { "x": 600, "y": 400 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 数据流程
|
||||
|
||||
### 发送消息流程 (游戏 → Zulip)
|
||||
|
||||
1. 玩家在游戏中输入消息
|
||||
2. Godot 客户端通过 WebSocket 发送 `chat` 消息
|
||||
3. WebSocket Gateway 接收消息
|
||||
4. Session Manager 获取玩家当前位置
|
||||
5. 上下文注入:根据位置确定目标 Stream/Topic
|
||||
6. Message Filter 进行内容过滤和频率检查
|
||||
7. Zulip Client Pool 使用用户的 API Key 发送消息到 Zulip
|
||||
8. 返回发送确认给客户端
|
||||
|
||||
### 接收消息流程 (Zulip → 游戏)
|
||||
|
||||
1. Zulip 服务器推送消息事件到 Event Queue
|
||||
2. Zulip Event Processor 接收并处理事件
|
||||
3. Session Manager 进行空间过滤,确定目标玩家
|
||||
4. 消息转换为游戏协议格式
|
||||
5. WebSocket Gateway 推送 `chat_render` 消息给目标客户端
|
||||
6. Godot 客户端显示聊天气泡
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 错误码说明
|
||||
|
||||
| 错误码 | 说明 | 处理建议 |
|
||||
|-------|------|---------|
|
||||
| `AUTH_FAILED` | 认证失败 | 检查 Token 有效性 |
|
||||
| `RATE_LIMIT` | 频率限制 | 等待后重试 |
|
||||
| `CONTENT_FILTERED` | 内容被过滤 | 修改消息内容 |
|
||||
| `PERMISSION_DENIED` | 权限不足 | 检查用户权限 |
|
||||
| `ZULIP_ERROR` | Zulip 服务错误 | 系统自动重试 |
|
||||
| `SESSION_EXPIRED` | 会话过期 | 重新登录 |
|
||||
|
||||
### 降级策略
|
||||
|
||||
当 Zulip 服务不可用时,系统会自动切换到本地聊天模式:
|
||||
- 消息仅在游戏内传播
|
||||
- 不同步到 Zulip
|
||||
- 服务恢复后自动切换回正常模式
|
||||
|
||||
## 安全机制
|
||||
|
||||
### API Key 安全
|
||||
|
||||
- API Key 加密存储在数据库中
|
||||
- 永不下发到客户端
|
||||
- 支持强制刷新机制
|
||||
|
||||
### 消息安全
|
||||
|
||||
- 敏感词过滤
|
||||
- 频率限制防刷屏
|
||||
- 位置验证防欺诈
|
||||
- 消息长度限制
|
||||
|
||||
### 连接安全
|
||||
|
||||
- Token 验证
|
||||
- 会话超时自动断开
|
||||
- 异常连接检测和拒绝
|
||||
|
||||
## 监控指标
|
||||
|
||||
### 连接指标
|
||||
- `zulip.connections.active`: 活跃连接数
|
||||
- `zulip.connections.total`: 总连接数
|
||||
- `zulip.connections.errors`: 连接错误数
|
||||
|
||||
### 消息指标
|
||||
- `zulip.messages.sent`: 发送消息数
|
||||
- `zulip.messages.received`: 接收消息数
|
||||
- `zulip.messages.filtered`: 被过滤消息数
|
||||
- `zulip.messages.latency`: 消息延迟
|
||||
|
||||
### 系统指标
|
||||
- `zulip.sessions.active`: 活跃会话数
|
||||
- `zulip.zulip_clients.active`: 活跃 Zulip 客户端数
|
||||
- `zulip.event_queues.active`: 活跃事件队列数
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [zulip-js 库使用指南](./zulip-js.md)
|
||||
- [API 接口文档](./api.md)
|
||||
- [WebSocket 协议详解](./websocket-protocol.md)
|
||||
- [配置管理指南](./configuration.md)
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2.0.0 (2025-12-25)
|
||||
- 更新地图配置为 9 区域系统
|
||||
- 添加 Stream Initializer Service 自动初始化服务
|
||||
- 更新默认出生点为鲸之港 (Whale Port)
|
||||
- 添加地图区域描述字段
|
||||
- 修复上下文注入使用 ConfigManager
|
||||
- 改进错误处理和日志记录
|
||||
|
||||
### v1.0.0 (2025-12-25)
|
||||
- 初始版本发布
|
||||
- 实现 WebSocket Gateway 统一网关
|
||||
- 实现 Session Manager 会话管理
|
||||
- 实现 Zulip Client Pool 客户端池
|
||||
- 实现 Message Filter 消息过滤
|
||||
- 实现 Config Manager 配置管理
|
||||
- 实现 Monitoring Service 监控服务
|
||||
- 完成集成测试覆盖
|
||||
254
docs/systems/zulip/ZULIP_INTEGRATION_SUMMARY.md
Normal file
254
docs/systems/zulip/ZULIP_INTEGRATION_SUMMARY.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Zulip集成系统测试总结
|
||||
|
||||
## 测试日期
|
||||
2025-12-25
|
||||
|
||||
## 测试环境
|
||||
|
||||
### Zulip服务器配置
|
||||
|
||||
- **服务器URL**: <https://zulip.xinghangee.icu/>
|
||||
- **Bot邮箱**: <cbot-bot@zulip.xinghangee.icu>
|
||||
- **Bot API Key**: 3k61GqxVkc...x3F3STksF (已配置在.env)
|
||||
|
||||
### 测试用户配置
|
||||
|
||||
- **用户API Key**: W2KhXaQxJ...0c9nPXaalh5
|
||||
- **Zulip用户邮箱**: <user8@zulip.xinghangee.icu>
|
||||
- **用户全名**: ANGJustinl
|
||||
- **用户ID**: 8
|
||||
- **权限**: 管理员
|
||||
|
||||
## 测试结果
|
||||
|
||||
### ✅ 1. API Key验证测试
|
||||
|
||||
**测试脚本**: `test-api-key-validation.js`
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- API Key验证成功
|
||||
- 用户信息获取正常
|
||||
- 用户邮箱: <user8@zulip.xinghangee.icu>
|
||||
- 用户全名: ANGJustinl
|
||||
|
||||
### ✅ 2. Stream管理测试
|
||||
|
||||
**测试脚本**: `test-list-subscriptions.js`, `test-subscribe-stream.js`
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- 成功列出用户订阅的Streams (Zulip, general, 沙箱)
|
||||
- 成功创建"Novice Village" Stream
|
||||
- 成功订阅新创建的Stream
|
||||
- 测试消息发送成功 (Message ID: 17, 19)
|
||||
|
||||
### ✅ 3. Zulip客户端创建测试
|
||||
|
||||
**测试方法**: 服务器日志验证
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- Zulip客户端创建成功
|
||||
- 事件队列注册成功 (Queue ID: 9b7c31ed-29a5-4419-b482-2fe549e26cc4)
|
||||
- 客户端生命周期管理正常
|
||||
- 客户端销毁和清理正常
|
||||
|
||||
### ✅ 4. 端到端集成测试
|
||||
|
||||
**测试脚本**: `test-user-api-key.js`
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- WebSocket连接成功
|
||||
- 登录流程正常
|
||||
- 会话ID生成正常
|
||||
- 用户ID: user_W2KhXaQx
|
||||
- 用户名: Player_W2KhX
|
||||
- 当前地图: whale_port (更新后)
|
||||
- 消息发送成功
|
||||
- Message ID: 20-25, 51-52
|
||||
- 所有消息成功发送到Zulip服务器
|
||||
- 支持多地图消息路由 (Whale Port, Pumpkin Valley)
|
||||
- 目标Topic: General
|
||||
|
||||
### ✅ 5. 单元测试和集成测试
|
||||
|
||||
**测试套件**: `src/business/zulip/zulip-integration.e2e.spec.ts`
|
||||
|
||||
**结果**: 22/22 通过
|
||||
|
||||
- WebSocket连接和会话管理 ✓
|
||||
- Zulip客户端生命周期管理 ✓
|
||||
- 消息路由和权限验证 ✓
|
||||
- 消息格式转换完整性 ✓
|
||||
- 消息接收和分发 ✓
|
||||
- 会话状态一致性 ✓
|
||||
- 内容安全和频率控制 ✓
|
||||
- API Key安全存储 ✓
|
||||
- 错误处理和服务降级 ✓
|
||||
- 操作确认和日志记录 ✓
|
||||
- 系统监控和告警 ✓
|
||||
- 配置验证 ✓
|
||||
|
||||
### ✅ 6. Stream初始化测试
|
||||
|
||||
**测试脚本**: `test-stream-initialization.js`
|
||||
|
||||
**结果**: 部分通过
|
||||
|
||||
- Stream 初始化服务正常启动
|
||||
- 成功检测缺失的 Streams
|
||||
- Bot 账号权限不足,无法自动创建 Streams
|
||||
- 使用管理员账号手动创建 Streams 成功
|
||||
- 所有 9 个地图区域的 Streams 已创建
|
||||
|
||||
### ✅ 7. 多地图消息路由测试
|
||||
|
||||
**测试脚本**: `test-user-api-key.js` (更新版)
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- 成功在 Whale Port 发送消息 (Message ID: 51)
|
||||
- 成功切换到 Pumpkin Valley
|
||||
- 成功在 Pumpkin Valley 发送消息 (Message ID: 52)
|
||||
- 上下文注入正确使用 ConfigManager
|
||||
- 消息路由到正确的 Stream
|
||||
|
||||
## 关键发现
|
||||
|
||||
### 1. API Key和用户邮箱映射
|
||||
|
||||
- 用户API Key对应的Zulip邮箱是 `user8@zulip.xinghangee.icu`
|
||||
- 不是 `cbot-bot@zulip.xinghangee.icu`
|
||||
- 已在代码中修正 (`src/business/zulip/zulip.service.ts`)
|
||||
|
||||
### 2. Stream创建和权限
|
||||
|
||||
- Bot 账号 (cbot-bot) 缺少创建 Stream 的权限
|
||||
- 需要使用管理员账号手动创建 Streams
|
||||
- 或在 Zulip 服务器中为 Bot 授予 Stream 创建权限
|
||||
- 已使用管理员账号成功创建所有 9 个地图区域的 Streams
|
||||
|
||||
### 3. 地图配置更新
|
||||
|
||||
- 系统从 2 个地图区域扩展到 9 个地图区域
|
||||
- 默认出生点从 `novice_village` 更改为 `whale_port`
|
||||
- 添加了地图区域描述字段 (`description`)
|
||||
- 配置版本从 1.0.0 升级到 2.0.0
|
||||
|
||||
### 4. 消息路由改进
|
||||
|
||||
- 修复了 SessionManager 使用硬编码 Stream 映射的问题
|
||||
- 现在使用 ConfigManager 动态获取 Stream 映射
|
||||
- 支持多地图消息路由,消息自动发送到玩家当前地图对应的 Stream
|
||||
- 已验证 Whale Port 和 Pumpkin Valley 的消息路由正常
|
||||
|
||||
### 5. 消息发送验证
|
||||
|
||||
- 所有消息都成功发送到Zulip服务器
|
||||
- 返回真实的Message ID (20-25, 51-52)
|
||||
- 可以在Zulip网页界面查看消息
|
||||
- 支持跨地图消息发送
|
||||
|
||||
## 系统状态
|
||||
|
||||
### ✅ 核心功能
|
||||
|
||||
- [x] WebSocket连接管理
|
||||
- [x] 用户登录和会话管理
|
||||
- [x] Zulip客户端创建和管理
|
||||
- [x] 事件队列注册和管理
|
||||
- [x] 消息发送到Zulip
|
||||
- [x] 消息格式转换
|
||||
- [x] 多地图消息路由
|
||||
- [x] Stream 自动初始化检查
|
||||
- [x] 错误处理和降级
|
||||
- [x] 日志记录和监控
|
||||
|
||||
### ✅ 配置管理
|
||||
|
||||
- [x] 环境变量配置
|
||||
- [x] 9 区域地图映射配置
|
||||
- [x] API Key安全存储
|
||||
- [x] 配置验证
|
||||
- [x] 动态 Stream 映射
|
||||
|
||||
### ✅ 测试覆盖
|
||||
|
||||
- [x] 单元测试 (22个测试用例)
|
||||
- [x] 集成测试 (端到端流程)
|
||||
- [x] 真实Zulip服务器测试
|
||||
- [x] 多地图消息路由测试
|
||||
- [x] Stream 初始化测试
|
||||
- [x] 错误场景测试
|
||||
|
||||
# !!!stream-initializer.service.ts - 404行处仍有todo需要完成, 现在没前端我搞不清楚咋做:(
|
||||
|
||||
## 下一步建议
|
||||
|
||||
### 1. Stream 权限配置
|
||||
|
||||
- [ ] 在 Zulip 服务器中为 Bot 账号授予创建 Stream 的权限
|
||||
- [ ] 或使用管理员账号预先创建所有 Streams
|
||||
- [ ] 验证所有 9 个地图区域的 Streams 已创建
|
||||
|
||||
### 2. 生产环境准备
|
||||
|
||||
- [ ] 配置生产环境的Zulip服务器
|
||||
- [ ] 设置API Key加密密钥 (ZULIP_API_KEY_ENCRYPTION_KEY)
|
||||
- [ ] 配置邮件服务用于通知
|
||||
- [ ] 设置监控和告警
|
||||
- [ ] 配置所有地图区域的 Streams
|
||||
|
||||
### 3. 功能增强
|
||||
|
||||
- [ ] 实现从Zulip接收消息的事件轮询
|
||||
- [ ] 实现双向消息同步
|
||||
- [ ] 实现用户权限管理
|
||||
- [ ] 添加地图切换动画和提示
|
||||
- [ ] 实现跨地图私聊功能
|
||||
|
||||
### 4. 性能优化
|
||||
|
||||
- [ ] 优化客户端池管理
|
||||
- [ ] 实现消息批量发送
|
||||
- [ ] 添加消息缓存机制
|
||||
- [ ] 优化事件队列轮询频率
|
||||
- [ ] 实现 Stream 订阅缓存
|
||||
|
||||
### 5. 文档完善
|
||||
|
||||
- [x] 系统架构文档
|
||||
- [x] API文档
|
||||
- [x] WebSocket协议文档
|
||||
- [x] 配置文档 (已更新 9 区域配置)
|
||||
- [x] Stream 初始化文档
|
||||
- [ ] 部署文档
|
||||
- [ ] 运维手册
|
||||
|
||||
## 结论
|
||||
|
||||
Zulip集成系统已成功完成开发和测试,所有核心功能正常工作。系统已通过:
|
||||
|
||||
- 22个单元测试和集成测试
|
||||
- 真实Zulip服务器的端到端测试
|
||||
- 多地图消息路由验证
|
||||
- Stream 初始化服务测试
|
||||
- 消息发送和接收验证
|
||||
|
||||
**最新更新 (v2.0.0):**
|
||||
|
||||
- 地图配置从 2 个区域扩展到 9 个区域
|
||||
- 实现 Stream 自动初始化检查服务
|
||||
- 修复上下文注入使用动态配置
|
||||
- 改进错误处理和日志记录
|
||||
- 更新默认出生点为鲸之港
|
||||
|
||||
系统已准备好进入下一阶段的开发和部署。建议优先配置 Stream 创建权限或手动创建所有地图区域的 Streams。
|
||||
|
||||
---
|
||||
|
||||
**测试人员**: ANGJustinl
|
||||
**审核状态**: 待确认
|
||||
**文档版本**: 1.0.0
|
||||
285
docs/systems/zulip/api.md
Normal file
285
docs/systems/zulip/api.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Zulip 集成系统 API 文档
|
||||
|
||||
## WebSocket 连接
|
||||
|
||||
### 连接地址
|
||||
|
||||
```
|
||||
ws://localhost:3000/game
|
||||
```
|
||||
|
||||
### 连接参数
|
||||
|
||||
连接时无需额外参数,认证通过 `login` 消息完成。
|
||||
|
||||
## 消息类型
|
||||
|
||||
### 1. 登录 (login)
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"type": "login",
|
||||
"token": "user_game_token"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
```json
|
||||
{
|
||||
"t": "login_success",
|
||||
"sessionId": "session_abc123",
|
||||
"currentMap": "novice_village",
|
||||
"username": "player_name"
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应:**
|
||||
```json
|
||||
{
|
||||
"t": "error",
|
||||
"code": "AUTH_FAILED",
|
||||
"message": "Token 验证失败"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 发送聊天消息 (chat)
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"t": "chat",
|
||||
"content": "Hello, world!",
|
||||
"scope": "local"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| t | string | 是 | 固定值 "chat" |
|
||||
| content | string | 是 | 消息内容,最大 1000 字符 |
|
||||
| scope | string | 是 | 消息范围: "local" 或具体 topic 名称 |
|
||||
|
||||
**成功响应:**
|
||||
```json
|
||||
{
|
||||
"t": "chat_sent",
|
||||
"messageId": "msg_123",
|
||||
"timestamp": 1703500800000
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应:**
|
||||
```json
|
||||
{
|
||||
"t": "error",
|
||||
"code": "RATE_LIMIT",
|
||||
"message": "消息发送过于频繁,请稍后再试"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 接收聊天消息 (chat_render)
|
||||
|
||||
**服务器推送:**
|
||||
```json
|
||||
{
|
||||
"t": "chat_render",
|
||||
"from": "other_player",
|
||||
"txt": "Hi there!",
|
||||
"bubble": true,
|
||||
"timestamp": 1703500800000
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
| 参数 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "chat_render" |
|
||||
| from | string | 发送者名称 |
|
||||
| txt | string | 消息内容 |
|
||||
| bubble | boolean | 是否显示气泡 |
|
||||
| timestamp | number | 消息时间戳 |
|
||||
|
||||
### 4. 位置更新 (position_update)
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"t": "position",
|
||||
"x": 150,
|
||||
"y": 200,
|
||||
"mapId": "novice_village"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| t | string | 是 | 固定值 "position" |
|
||||
| x | number | 是 | X 坐标 |
|
||||
| y | number | 是 | Y 坐标 |
|
||||
| mapId | string | 是 | 地图 ID |
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"t": "position_updated",
|
||||
"stream": "Novice Village",
|
||||
"topic": "General"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 登出 (logout)
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"type": "logout"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"t": "logout_success"
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
|
||||
| 错误码 | HTTP 等效 | 说明 | 处理建议 |
|
||||
|-------|----------|------|---------|
|
||||
| `AUTH_FAILED` | 401 | 认证失败,Token 无效或过期 | 重新获取 Token 并登录 |
|
||||
| `RATE_LIMIT` | 429 | 消息发送频率超限 | 等待 60 秒后重试 |
|
||||
| `CONTENT_FILTERED` | 400 | 消息内容被过滤 | 修改消息内容后重试 |
|
||||
| `CONTENT_TOO_LONG` | 400 | 消息内容超长 | 缩短消息长度 |
|
||||
| `PERMISSION_DENIED` | 403 | 权限不足 | 检查用户权配置 |
|
||||
| `SESSION_EXPIRED` | 401 | 会话已过期 | 重新登录 |
|
||||
| `SESSION_NOT_FOUND` | 404 | 会话不存在 | 重新登录 |
|
||||
| `ZULIP_ERROR` | 502 | Zulip 服务错误 | 系统自动重试,无需处理 |
|
||||
| `INTERNAL_ERROR` | 500 | 内部服务器错误 | 联系管理员 |
|
||||
|
||||
## 频率限制
|
||||
|
||||
### 消息发送限制
|
||||
|
||||
- 默认限制: 10 条/分钟
|
||||
- 超限后返回 `RATE_LIMIT` 错误
|
||||
- 限制窗口: 滑动窗口,60 秒
|
||||
|
||||
### 连接限制
|
||||
|
||||
- 单用户最大连接数: 3
|
||||
- 超限后新连接被拒绝
|
||||
|
||||
## 消息过滤规则
|
||||
|
||||
### 内容过滤
|
||||
|
||||
1. **敏感词过滤**: 包含敏感词的消息将被拒绝
|
||||
2. **长度限制**: 消息最大 1000 字符
|
||||
3. **重复检测**: 连续发送相同内容将被拒绝
|
||||
|
||||
### 权限验证
|
||||
|
||||
1. **位置验证**: 只能向当前所在地图对应的 Stream 发送消息
|
||||
2. **Stream 权限**: 只能访问配置中允许的 Stream
|
||||
|
||||
## 示例代码
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
// 连接 WebSocket
|
||||
const socket = new WebSocket('ws://localhost:3000/game');
|
||||
|
||||
// 连接成功
|
||||
socket.onopen = () => {
|
||||
// 发送登录消息
|
||||
socket.send(JSON.stringify({
|
||||
type: 'login',
|
||||
token: 'your_game_token'
|
||||
}));
|
||||
};
|
||||
|
||||
// 接收消息
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.t) {
|
||||
case 'login_success':
|
||||
console.log('登录成功:', data.sessionId);
|
||||
break;
|
||||
case 'chat_render':
|
||||
console.log(`${data.from}: ${data.txt}`);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(`错误 [${data.code}]: ${data.message}`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 发送聊天消息
|
||||
function sendChat(content: string) {
|
||||
socket.send(JSON.stringify({
|
||||
t: 'chat',
|
||||
content: content,
|
||||
scope: 'local'
|
||||
}));
|
||||
}
|
||||
|
||||
// 更新位置
|
||||
function updatePosition(x: number, y: number, mapId: string) {
|
||||
socket.send(JSON.stringify({
|
||||
t: 'position',
|
||||
x: x,
|
||||
y: y,
|
||||
mapId: mapId
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## 健康检查接口
|
||||
|
||||
### GET /health
|
||||
|
||||
检查系统健康状态。
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"components": {
|
||||
"websocket": "healthy",
|
||||
"zulip": "healthy",
|
||||
"redis": "healthy"
|
||||
},
|
||||
"metrics": {
|
||||
"activeConnections": 42,
|
||||
"activeSessions": 40,
|
||||
"messagesSentLastMinute": 156
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /metrics
|
||||
|
||||
获取系统指标(Prometheus 格式)。
|
||||
|
||||
**响应:**
|
||||
```
|
||||
# HELP zulip_connections_active Active WebSocket connections
|
||||
# TYPE zulip_connections_active gauge
|
||||
zulip_connections_active 42
|
||||
|
||||
# HELP zulip_messages_sent_total Total messages sent
|
||||
# TYPE zulip_messages_sent_total counter
|
||||
zulip_messages_sent_total 15678
|
||||
|
||||
# HELP zulip_message_latency_seconds Message processing latency
|
||||
# TYPE zulip_message_latency_seconds histogram
|
||||
zulip_message_latency_seconds_bucket{le="0.1"} 14500
|
||||
zulip_message_latency_seconds_bucket{le="0.5"} 15600
|
||||
zulip_message_latency_seconds_bucket{le="1"} 15678
|
||||
```
|
||||
516
docs/systems/zulip/configuration.md
Normal file
516
docs/systems/zulip/configuration.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# 配置管理指南
|
||||
|
||||
## 概述
|
||||
|
||||
Zulip 集成系统支持多种配置方式,包括环境变量、配置文件和运行时配置。本文档详细说明各配置项的用途和设置方法。
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
### Zulip 服务器配置
|
||||
|
||||
```bash
|
||||
# Zulip 服务器 URL
|
||||
ZULIP_SERVER_URL=https://zulip.xinghangee.icu/
|
||||
|
||||
# Zulip Bot 邮箱
|
||||
ZULIP_BOT_EMAIL=cbot-bot@zulip.xinghangee.icu
|
||||
|
||||
# Zulip Bot API Key
|
||||
ZULIP_BOT_API_KEY=your-bot-api-key
|
||||
|
||||
# Zulip Realm (可选,默认从 URL 推断)
|
||||
ZULIP_REALM=your-realm
|
||||
```
|
||||
|
||||
### WebSocket 配置
|
||||
|
||||
```bash
|
||||
# WebSocket 端口
|
||||
WEBSOCKET_PORT=3000
|
||||
|
||||
# WebSocket 命名空间
|
||||
WEBSOCKET_NAMESPACE=/game
|
||||
|
||||
# 最大连接数
|
||||
WEBSOCKET_MAX_CONNECTIONS=100
|
||||
|
||||
# 连接超时时间 (毫秒)
|
||||
WEBSOCKET_TIMEOUT=60000
|
||||
```
|
||||
|
||||
### 消息配置
|
||||
|
||||
```bash
|
||||
# 消息频率限制 (条/分钟)
|
||||
MESSAGE_RATE_LIMIT=10
|
||||
|
||||
# 消息最大长度 (字符)
|
||||
MESSAGE_MAX_LENGTH=1000
|
||||
|
||||
# 是否启用内容过滤
|
||||
ENABLE_CONTENT_FILTER=true
|
||||
|
||||
# 是否启用重复检测
|
||||
ENABLE_DUPLICATE_DETECTION=true
|
||||
```
|
||||
|
||||
### 会话配置
|
||||
|
||||
```bash
|
||||
# 会话超时时间 (分钟)
|
||||
SESSION_TIMEOUT=30
|
||||
|
||||
# 会话清理间隔 (分钟)
|
||||
SESSION_CLEANUP_INTERVAL=5
|
||||
|
||||
# 最大会话数
|
||||
MAX_SESSIONS=5000
|
||||
```
|
||||
|
||||
### Redis 配置
|
||||
|
||||
```bash
|
||||
# Redis 主机
|
||||
REDIS_HOST=localhost
|
||||
|
||||
# Redis 端口
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Redis 密码 (可选)
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Redis 数据库索引
|
||||
REDIS_DB=0
|
||||
|
||||
# Redis 键前缀
|
||||
REDIS_KEY_PREFIX=zulip:
|
||||
```
|
||||
|
||||
### 日志配置
|
||||
|
||||
```bash
|
||||
# 日志级别 (debug, info, warn, error)
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 是否启用结构化日志
|
||||
LOG_STRUCTURED=true
|
||||
|
||||
# 日志文件路径 (可选)
|
||||
LOG_FILE_PATH=logs/zulip.log
|
||||
```
|
||||
|
||||
## 配置文件
|
||||
|
||||
### 地图映射配置
|
||||
|
||||
文件位置: `config/zulip/map-config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"lastModified": "2025-12-25T20:00:00.000Z",
|
||||
"description": "基于像素大地图的 Zulip 映射配置",
|
||||
"maps": [
|
||||
{
|
||||
"mapId": "whale_port",
|
||||
"mapName": "鲸之港",
|
||||
"zulipStream": "Whale Port",
|
||||
"description": "中心城区,交通枢纽与主要聚会点",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "whale_statue",
|
||||
"objectName": "鲸鱼雕像",
|
||||
"zulipTopic": "Announcements",
|
||||
"position": { "x": 600, "y": 400 }
|
||||
},
|
||||
{
|
||||
"objectId": "clock_tower",
|
||||
"objectName": "大本钟",
|
||||
"zulipTopic": "General Chat",
|
||||
"position": { "x": 550, "y": 350 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "pumpkin_valley",
|
||||
"mapName": "南瓜谷",
|
||||
"zulipStream": "Pumpkin Valley",
|
||||
"description": "新手成长、基础资源与学习社区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "pumpkin_patch",
|
||||
"objectName": "南瓜田",
|
||||
"zulipTopic": "Tutorials",
|
||||
"position": { "x": 150, "y": 400 }
|
||||
},
|
||||
{
|
||||
"objectId": "farm_house",
|
||||
"objectName": "农舍",
|
||||
"zulipTopic": "Study Group",
|
||||
"position": { "x": 200, "y": 450 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "offer_city",
|
||||
"mapName": "Offer 城",
|
||||
"zulipStream": "Offer City",
|
||||
"description": "职业发展、面试与商务区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "skyscrapers",
|
||||
"objectName": "摩天大楼",
|
||||
"zulipTopic": "Career Talk",
|
||||
"position": { "x": 350, "y": 650 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "model_factory",
|
||||
"mapName": "模型工厂",
|
||||
"zulipStream": "Model Factory",
|
||||
"description": "AI模型训练、代码构建与工业区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "assembly_line",
|
||||
"objectName": "流水线",
|
||||
"zulipTopic": "Code Review",
|
||||
"position": { "x": 400, "y": 200 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
系统现在支持 9 个地图区域:
|
||||
|
||||
1. **鲸之港 (Whale Port)** - 中心城区,交通枢纽与主要聚会点
|
||||
2. **南瓜谷 (Pumpkin Valley)** - 新手成长、基础资源与学习社区
|
||||
3. **Offer 城 (Offer City)** - 职业发展、面试与商务区
|
||||
4. **模型工厂 (Model Factory)** - AI模型训练、代码构建与工业区
|
||||
5. **内核岛 (Kernel Island)** - 核心技术研究、底层原理与算法
|
||||
6. **摸鱼海滩 (Moyu Beach)** - 休闲娱乐、水贴与非技术话题
|
||||
7. **天梯峰 (Ladder Peak)** - 挑战、竞赛与排行榜
|
||||
8. **星河湾 (Galaxy Bay)** - 创意、设计与灵感
|
||||
9. **数据遗迹 (Data Ruins)** - 数据库、归档与历史记录
|
||||
|
||||
### 配置字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| version | string | 是 | 配置版本号 |
|
||||
| lastModified | string | 否 | 最后修改时间 (ISO 8601) |
|
||||
| description | string | 否 | 配置文件描述 |
|
||||
| maps | array | 是 | 地图配置数组 |
|
||||
| maps[].mapId | string | 是 | 地图唯一标识 |
|
||||
| maps[].mapName | string | 是 | 地图显示名称 |
|
||||
| maps[].zulipStream | string | 是 | 对应的 Zulip Stream |
|
||||
| maps[].description | string | 否 | 地图区域描述 |
|
||||
| maps[].defaultTopic | string | 否 | 默认 Topic,默认 "General" |
|
||||
| maps[].interactionObjects | array | 否 | 交互对象配置 |
|
||||
| interactionObjects[].objectId | string | 是 | 对象唯一标识 |
|
||||
| interactionObjects[].objectName | string | 是 | 对象显示名称 |
|
||||
| interactionObjects[].zulipTopic | string | 是 | 对应的 Zulip Topic |
|
||||
| interactionObjects[].position | object | 是 | 对象位置坐标 |
|
||||
| interactionObjects[].radius | number | 否 | 交互半径,默认 50 |
|
||||
|
||||
### 敏感词配置
|
||||
|
||||
文件位置: `config/zulip/sensitive-words.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"words": [
|
||||
"敏感词1",
|
||||
"敏感词2"
|
||||
],
|
||||
"patterns": [
|
||||
"正则表达式1",
|
||||
"正则表达式2"
|
||||
],
|
||||
"replacements": {
|
||||
"原词": "替换词"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 允许的 Stream 配置
|
||||
|
||||
文件位置: `config/zulip/allowed-streams.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"streams": [
|
||||
"Novice Village",
|
||||
"Market Square",
|
||||
"Guild Hall",
|
||||
"Arena"
|
||||
],
|
||||
"privateStreams": [
|
||||
"Admin",
|
||||
"Moderators"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 运行时配置
|
||||
|
||||
### 通过 API 更新配置
|
||||
|
||||
```typescript
|
||||
// 更新消息频率限制
|
||||
await configManager.updateConfig('messageRateLimit', 20);
|
||||
|
||||
// 更新会话超时时间
|
||||
await configManager.updateConfig('sessionTimeout', 60);
|
||||
|
||||
// 重新加载地图配置
|
||||
await configManager.reloadMapConfig();
|
||||
```
|
||||
|
||||
### 配置热重载
|
||||
|
||||
系统支持配置热重载,无需重启服务:
|
||||
|
||||
```bash
|
||||
# 发送 SIGHUP 信号触发配置重载
|
||||
kill -HUP <pid>
|
||||
```
|
||||
|
||||
或通过 API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/admin/config/reload \
|
||||
-H "Authorization: Bearer <admin_token>"
|
||||
```
|
||||
|
||||
## 配置验证
|
||||
|
||||
### 启动时验证
|
||||
|
||||
系统在启动时会验证所有配置的有效性:
|
||||
|
||||
```typescript
|
||||
// 配置验证示例
|
||||
const configValidator = new ConfigValidator();
|
||||
|
||||
// 验证环境变量
|
||||
configValidator.validateEnv({
|
||||
ZULIP_SERVER_URL: { required: true, type: 'url' },
|
||||
ZULIP_BOT_EMAIL: { required: true, type: 'email' },
|
||||
ZULIP_BOT_API_KEY: { required: true, type: 'string' },
|
||||
MESSAGE_RATE_LIMIT: { required: false, type: 'number', default: 10 },
|
||||
});
|
||||
|
||||
// 验证地图配置
|
||||
configValidator.validateMapConfig(mapConfig);
|
||||
```
|
||||
|
||||
### 验证错误处理
|
||||
|
||||
配置验证失败时,系统会:
|
||||
|
||||
1. 记录详细的错误日志
|
||||
2. 输出错误信息到控制台
|
||||
3. 阻止服务启动(严重错误)或使用默认值(非严重错误)
|
||||
|
||||
```
|
||||
[ERROR] 配置验证失败:
|
||||
- ZULIP_SERVER_URL: 必填项未设置
|
||||
- MESSAGE_RATE_LIMIT: 值必须大于 0
|
||||
- map-config.json: maps[0].zulipStream 不能为空
|
||||
```
|
||||
|
||||
## Stream 初始化
|
||||
|
||||
### 自动初始化服务
|
||||
|
||||
系统在启动时会自动检查所有地图配置中定义的 Zulip Streams 是否存在。如果发现缺失的 Streams,会尝试自动创建。
|
||||
|
||||
**服务配置:**
|
||||
|
||||
```typescript
|
||||
// Stream 初始化服务会在系统启动 5 秒后自动运行
|
||||
// 位置: src/business/zulip/services/stream-initializer.service.ts
|
||||
|
||||
@Injectable()
|
||||
export class StreamInitializerService implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
// 延迟 5 秒启动,确保其他服务已就绪
|
||||
setTimeout(() => {
|
||||
this.initializeStreams();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 权限要求
|
||||
|
||||
创建 Zulip Streams 需要特定权限:
|
||||
|
||||
- **Bot 账号**: 默认情况下可能缺少创建 Stream 的权限
|
||||
- **管理员账号**: 拥有完整的 Stream 创建权限
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **方案一**: 在 Zulip 服务器中为 Bot 账号授予创建 Stream 的权限
|
||||
- 登录 Zulip 管理后台
|
||||
- 找到 Bot 账号设置
|
||||
- 授予 "Create streams" 权限
|
||||
|
||||
2. **方案二**: 使用管理员账号手动创建 Streams
|
||||
- 使用提供的测试脚本 `test-stream-initialization.js`
|
||||
- 配置管理员 API Key
|
||||
- 运行脚本自动创建所有 Streams
|
||||
|
||||
3. **方案三**: 在 Zulip 网页界面手动创建
|
||||
- 登录 Zulip 网页界面
|
||||
- 创建对应的 Streams (参考 `config/zulip/map-config.json`)
|
||||
|
||||
### 手动创建 Streams
|
||||
|
||||
使用测试脚本创建所有地图区域的 Streams:
|
||||
|
||||
```bash
|
||||
# 编辑 test-stream-initialization.js,配置管理员 API Key
|
||||
# 然后运行脚本
|
||||
node test-stream-initialization.js
|
||||
```
|
||||
|
||||
脚本会自动创建以下 Streams:
|
||||
|
||||
- Whale Port (鲸之港)
|
||||
- Pumpkin Valley (南瓜谷)
|
||||
- Offer City (Offer 城)
|
||||
- Model Factory (模型工厂)
|
||||
- Kernel Island (内核岛)
|
||||
- Moyu Beach (摸鱼海滩)
|
||||
- Ladder Peak (天梯峰)
|
||||
- Galaxy Bay (星河湾)
|
||||
- Data Ruins (数据遗迹)
|
||||
|
||||
### 初始化日志
|
||||
|
||||
系统会记录 Stream 初始化的详细日志:
|
||||
|
||||
```
|
||||
[INFO] 开始初始化 Zulip Streams...
|
||||
[INFO] 检查 Stream: Whale Port
|
||||
[INFO] Stream 已存在: Whale Port
|
||||
[WARN] Stream 不存在,尝试创建: Pumpkin Valley
|
||||
[INFO] Stream 创建成功: Pumpkin Valley
|
||||
[ERROR] Stream 创建失败: Offer City - Insufficient permission
|
||||
```
|
||||
|
||||
## 配置最佳实践
|
||||
|
||||
### 1. 使用环境变量管理敏感信息
|
||||
|
||||
```bash
|
||||
# 不要在代码中硬编码敏感信息
|
||||
# 使用环境变量或密钥管理服务
|
||||
|
||||
# 开发环境
|
||||
export ZULIP_BOT_API_KEY=dev-api-key
|
||||
|
||||
# 生产环境 (使用密钥管理服务)
|
||||
export ZULIP_BOT_API_KEY=$(aws secretsmanager get-secret-value --secret-id zulip-api-key --query SecretString --output text)
|
||||
```
|
||||
|
||||
### 2. 分环境配置
|
||||
|
||||
```
|
||||
config/
|
||||
├── zulip/
|
||||
│ ├── map-config.json # 默认配置
|
||||
│ ├── map-config.dev.json # 开发环境
|
||||
│ ├── map-config.staging.json # 预发布环境
|
||||
│ └── map-config.prod.json # 生产环境
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 根据环境加载配置
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const configPath = `config/zulip/map-config.${env}.json`;
|
||||
```
|
||||
|
||||
### 3. 配置版本控制
|
||||
|
||||
- 将配置文件纳入版本控制
|
||||
- 使用 `.env.example` 提供配置模板
|
||||
- 敏感配置使用 `.gitignore` 排除
|
||||
|
||||
### 4. 配置文档化
|
||||
|
||||
为每个配置项提供清晰的文档说明:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 消息频率限制配置
|
||||
*
|
||||
* @description 限制用户每分钟可发送的消息数量
|
||||
* @default 10
|
||||
* @range 1-100
|
||||
* @env MESSAGE_RATE_LIMIT
|
||||
*/
|
||||
messageRateLimit: number;
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见配置问题
|
||||
|
||||
#### 1. Zulip 连接失败
|
||||
|
||||
```
|
||||
错误: ZULIP_CONNECTION_FAILED
|
||||
原因: 无法连接到 Zulip 服务器
|
||||
```
|
||||
|
||||
检查项:
|
||||
- `ZULIP_SERVER_URL` 是否正确
|
||||
- 网络是否可达
|
||||
- API Key 是否有效
|
||||
|
||||
#### 2. 地图配置加载失败
|
||||
|
||||
```
|
||||
错误: MAP_CONFIG_LOAD_FAILED
|
||||
原因: 地图配置文件格式错误
|
||||
```
|
||||
|
||||
检查项:
|
||||
- JSON 格式是否正确
|
||||
- 必填字段是否完整
|
||||
- 字段类型是否正确
|
||||
|
||||
#### 3. Redis 连接失败
|
||||
|
||||
```
|
||||
错误: REDIS_CONNECTION_FAILED
|
||||
原因: 无法连接到 Redis 服务器
|
||||
```
|
||||
|
||||
检查项:
|
||||
- `REDIS_HOST` 和 `REDIS_PORT` 是否正确
|
||||
- Redis 服务是否运行
|
||||
- 密码是否正确
|
||||
|
||||
### 配置诊断命令
|
||||
|
||||
```bash
|
||||
# 检查配置有效性
|
||||
npm run config:validate
|
||||
|
||||
# 显示当前配置
|
||||
npm run config:show
|
||||
|
||||
# 测试 Zulip 连接
|
||||
npm run config:test-zulip
|
||||
|
||||
# 测试 Redis 连接
|
||||
npm run config:test-redis
|
||||
```
|
||||
71
docs/systems/zulip/guide.md
Normal file
71
docs/systems/zulip/guide.md
Normal file
@@ -0,0 +1,71 @@
|
||||
游戏属性: 2d社交属性, 无战斗的社群mmo游戏
|
||||
核心目的(游戏内外无缝互通): 不玩这个游戏但是在zulip的社群成员, 也可以跨平台和游戏内的成员聊天
|
||||
|
||||
核心设计理念:
|
||||
Stream (流) -> Topic (话题) 线程模型,天然契合 MMO 中的 Zone (区域) -> Context (情境) 逻辑
|
||||
我们需要解决的核心问题是:如何将2D 空间位置(Game State)映射到Zulip 的信息组织形式(Message State),同时利用 Zulip 的 API Key 机制完成无缝认证
|
||||
|
||||
---
|
||||
1. 核心逻辑架构 (The Core Logic)
|
||||
在设计 API 之前,我们需要定义 mappings(映射关系):
|
||||
- Game World / Map ←→ Zulip Stream (e.g., #Novice_Village)
|
||||
- Interactive Object / Event ←→ Zulip Topic (e.g., Notice Board, Tavern Gossip)
|
||||
- Whisper / Party ←→ Zulip Private Message
|
||||
|
||||
---
|
||||
架构图示:
|
||||
Client (Game) $$\xrightarrow{\text{Game Token}}$$ Game Middleware API $$\xrightarrow{\text{Zulip API Key}}$$ Zulip Server
|
||||
$$Client (Godot) \xleftrightarrow{\text{WebSocket}} Node.js Server \xleftrightarrow{\text{REST/Long-Poll}} Zulip Server$$
|
||||
设计理由:不建议让客户端直接直连 Zulip。我们需要一层中间件(Middleware)来控制权限、注入游戏数据(如玩家坐标、当前的动作状态),并防止用户在该 API Key 下进行非游戏允许的 Zulip 操作(如随意创建 Stream)。
|
||||
|
||||
---
|
||||
2. 设计思路一: "统一网关"(Unified Gateway)
|
||||
2.1 详细数据流设计 (Data Flow)
|
||||
我们需要在 Node.js 中维护一个 Session Manager。
|
||||
A. 登录与握手 (Initialization)
|
||||
1. Godot: 发送登录包 {"type": "login", "token": "user_game_token"}。
|
||||
2. Node.js:
|
||||
- 验证游戏 Token。
|
||||
- 查找该用户的 Zulip API Key(通常存储在数据库中,或者首次登录时让用户提供)。
|
||||
- 关键步骤: Node.js 服务器为该特定用户实例化一个 Zulip Client,并向 Zulip 申请注册一个 Event Queue。
|
||||
- 将 Socket_ID 与 Zulip_Queue_ID 绑定。
|
||||
B. 发送消息 (Upstream: Godot -> Node -> Zulip)
|
||||
1. Godot: 玩家输入 "Hello",Godot 通过 WebSocket 发送简化的包:
|
||||
2. JSON
|
||||
{
|
||||
"t": "chat",
|
||||
"content": "Hello",
|
||||
"scope": "local" // 或者 "topic_name"
|
||||
}
|
||||
1. Node.js:
|
||||
- 收到包,解析出这是聊天请求。
|
||||
- 上下文注入: Node 知道玩家当前在 Map_101 (对应 Zulip Stream #Tavern)。
|
||||
- API 调用: Node 使用该用户的 Zulip Client,调用 Zulip API 发送消息到 #Tavern。
|
||||
- 优势: 这里可以做风控(比如禁止发脏话、频率限制),Godot 端根本无法绕过。
|
||||
C. 接收消息 (Downstream: Zulip -> Node -> Godot)
|
||||
1. Node.js:
|
||||
- 服务器内部有一个循环(或者异步监听器),轮询 Zulip 的事件队列。
|
||||
- 收到 Zulip 的 message 事件:User_B 在 #Tavern 说了 "Hi"。
|
||||
- 空间过滤: Node 检查当前连接的所有 WebSocket,找出所有位于 Map_101 的玩家。
|
||||
- 广播: 将消息打包成游戏协议,通过 WebSocket 推送给这些玩家:
|
||||
2. JSON
|
||||
{
|
||||
"t": "chat_render",
|
||||
"from": "User_B",
|
||||
"txt": "Hi",
|
||||
"bubble": true
|
||||
}
|
||||
1. Godot: 收到包,直接调用 show_bubble()。
|
||||
|
||||
---
|
||||
2.3 这个方案的权衡分析 (Trade-off Analysis)
|
||||
这种改变带来的本质变化:
|
||||
优势 (The Wins)
|
||||
1. 客户端极度简化 (Thin Client):
|
||||
- Godot 里不需要写 HTTP Request,不需要处理 Long Polling 的异常断连,不需要解析复杂的 JSON 结构。
|
||||
- Godot 只需要处理 on_websocket_packet_received。
|
||||
2. 安全性 (Security):
|
||||
- Zulip API Key 永不下发: 用户的 Zulip 凭证永远只保存在服务器端。如果客户端直接拿 Key,黑客可以通过解包 Godot 拿到 Key 然后去 Zulip 乱发消息。
|
||||
- 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。
|
||||
3. 协议统一:
|
||||
- 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。
|
||||
85
docs/systems/zulip/quick_tests/test-list-subscriptions.js
Normal file
85
docs/systems/zulip/quick_tests/test-list-subscriptions.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const zulip = require('zulip-js');
|
||||
|
||||
async function listSubscriptions() {
|
||||
console.log('🔧 检查用户订阅的 Streams...');
|
||||
|
||||
const config = {
|
||||
username: 'angjustinl@mail.angforever.top',
|
||||
apiKey: 'lCPWC...pqNfGF8',
|
||||
realm: 'https://zulip.xinghangee.icu/'
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await zulip(config);
|
||||
|
||||
// 获取用户信息
|
||||
console.log('\n👤 获取用户信息...');
|
||||
const profile = await client.users.me.getProfile();
|
||||
console.log('用户:', profile.full_name, `(${profile.email})`);
|
||||
console.log('是否管理员:', profile.is_admin);
|
||||
|
||||
// 获取用户订阅的 Streams
|
||||
console.log('\n📋 获取用户订阅的 Streams...');
|
||||
const subscriptions = await client.streams.subscriptions.retrieve();
|
||||
|
||||
if (subscriptions.result === 'success') {
|
||||
console.log(`\n✅ 找到 ${subscriptions.subscriptions.length} 个订阅的 Streams:`);
|
||||
subscriptions.subscriptions.forEach(sub => {
|
||||
console.log(` - ${sub.name} (ID: ${sub.stream_id})`);
|
||||
});
|
||||
|
||||
// 检查是否有 "Novice Village"
|
||||
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Novice Village');
|
||||
if (noviceVillage) {
|
||||
console.log('\n✅ "Novice Village" Stream 已存在!');
|
||||
|
||||
// 测试发送消息
|
||||
console.log('\n📤 测试发送消息...');
|
||||
const result = await client.messages.send({
|
||||
type: 'stream',
|
||||
to: 'Novice Village',
|
||||
subject: 'General',
|
||||
content: '测试消息:系统集成测试成功 🎮'
|
||||
});
|
||||
|
||||
if (result.result === 'success') {
|
||||
console.log('✅ 消息发送成功! Message ID:', result.id);
|
||||
} else {
|
||||
console.log('❌ 消息发送失败:', result.msg);
|
||||
}
|
||||
} else {
|
||||
console.log('\n⚠️ "Novice Village" Stream 不存在');
|
||||
console.log('💡 请在 Zulip 网页界面手动创建该 Stream,或使用管理员账号创建');
|
||||
|
||||
// 尝试发送到第一个可用的 Stream
|
||||
if (subscriptions.subscriptions.length > 0) {
|
||||
const firstStream = subscriptions.subscriptions[0];
|
||||
console.log(`\n📤 尝试发送消息到 "${firstStream.name}"...`);
|
||||
const result = await client.messages.send({
|
||||
type: 'stream',
|
||||
to: firstStream.name,
|
||||
subject: 'Test',
|
||||
content: '测试消息:验证系统可以发送消息 🎮'
|
||||
});
|
||||
|
||||
if (result.result === 'success') {
|
||||
console.log('✅ 消息发送成功! Message ID:', result.id);
|
||||
console.log(`💡 系统工作正常,只需创建 "Novice Village" Stream 即可`);
|
||||
} else {
|
||||
console.log('❌ 消息发送失败:', result.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('❌ 获取订阅失败:', subscriptions.msg);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ 操作失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error('响应数据:', error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listSubscriptions();
|
||||
127
docs/systems/zulip/quick_tests/test-user-api-key.js
Normal file
127
docs/systems/zulip/quick_tests/test-user-api-key.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const io = require('socket.io-client');
|
||||
|
||||
// 使用用户 API Key 测试 Zulip 集成
|
||||
async function testWithUserApiKey() {
|
||||
console.log('🚀 使用用户 API Key 测试 Zulip 集成...');
|
||||
console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8');
|
||||
console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/');
|
||||
console.log('📡 游戏服务器: http://localhost:3000/game');
|
||||
|
||||
const socket = io('http://localhost:3000/game', {
|
||||
transports: ['websocket'],
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
let testStep = 0;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
testStep = 1;
|
||||
|
||||
// 使用包含用户 API Key 的 token
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: 'lCPWCPfGh7...fGF8_user_token'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 1: 发送登录消息(使用用户 API Key)');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('✅ 步骤 1 完成: 登录成功');
|
||||
console.log(' 会话ID:', data.sessionId);
|
||||
console.log(' 用户ID:', data.userId);
|
||||
console.log(' 用户名:', data.username);
|
||||
console.log(' 当前地图:', data.currentMap);
|
||||
testStep = 2;
|
||||
|
||||
// 等待 Zulip 客户端初始化
|
||||
console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||
setTimeout(() => {
|
||||
const chatMessage = {
|
||||
t: 'chat',
|
||||
content: '🎮 【用户API Key测试】来自游戏的消息!\\n' +
|
||||
'时间: ' + new Date().toLocaleString() + '\\n' +
|
||||
'使用用户 API Key 发送此消息。',
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 2: 发送消息到 Zulip(使用用户 API Key)');
|
||||
console.log(' 目标 Stream: Whale Port');
|
||||
socket.emit('chat', chatMessage);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
socket.on('chat_sent', (data) => {
|
||||
console.log('✅ 步骤 2 完成: 消息发送成功');
|
||||
console.log(' 响应:', JSON.stringify(data, null, 2));
|
||||
|
||||
// 只在第一次收到 chat_sent 时发送第二条消息
|
||||
if (testStep === 2) {
|
||||
testStep = 3;
|
||||
|
||||
setTimeout(() => {
|
||||
// 先切换到 Pumpkin Valley 地图
|
||||
console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图');
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
};
|
||||
socket.emit('position_update', positionUpdate);
|
||||
|
||||
// 等待位置更新后发送消息
|
||||
setTimeout(() => {
|
||||
const chatMessage2 = {
|
||||
t: 'chat',
|
||||
content: '🎃 在南瓜谷发送的测试消息!',
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息');
|
||||
socket.emit('chat', chatMessage2);
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('chat_render', (data) => {
|
||||
console.log('📨 收到来自 Zulip 的消息:');
|
||||
console.log(' 发送者:', data.from);
|
||||
console.log(' 内容:', data.txt);
|
||||
console.log(' Stream:', data.stream || '未知');
|
||||
console.log(' Topic:', data.topic || '未知');
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 WebSocket 连接已关闭');
|
||||
console.log('');
|
||||
console.log('📊 测试结果:');
|
||||
console.log(' 完成步骤:', testStep, '/ 4');
|
||||
if (testStep >= 3) {
|
||||
console.log(' ✅ 核心功能正常!');
|
||||
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接错误:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 20秒后自动关闭(给足够时间完成测试)
|
||||
setTimeout(() => {
|
||||
console.log('⏰ 测试时间到,关闭连接');
|
||||
socket.disconnect();
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
console.log('🔧 准备测试环境...');
|
||||
testWithUserApiKey().catch(console.error);
|
||||
431
docs/systems/zulip/websocket-protocol.md
Normal file
431
docs/systems/zulip/websocket-protocol.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# WebSocket 协议详解
|
||||
|
||||
## 协议概述
|
||||
|
||||
Zulip 集成系统使用 WebSocket 协议实现游戏客户端与服务器之间的实时双向通信。所有消息采用 JSON 格式编码。
|
||||
|
||||
## 连接生命周期
|
||||
|
||||
### 1. 建立连接
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- WebSocket Connect --------->|
|
||||
| |
|
||||
|<------- Connection Accepted --------|
|
||||
| |
|
||||
```
|
||||
|
||||
### 2. 认证握手
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- login message ------------->|
|
||||
| |
|
||||
| [验证 Token] |
|
||||
| [创建 Zulip Client] |
|
||||
| [注册 Event Queue] |
|
||||
| [创建 Session] |
|
||||
| |
|
||||
|<------- login_success --------------|
|
||||
| |
|
||||
```
|
||||
|
||||
### 3. 消息交换
|
||||
|
||||
```
|
||||
Client Server Zulip
|
||||
| | |
|
||||
|-------- chat message -------------->| |
|
||||
| |-------- POST /messages ---------->|
|
||||
| |<------- 200 OK -------------------|
|
||||
|<------- chat_sent ------------------| |
|
||||
| | |
|
||||
| |<------- Event Queue Message ------|
|
||||
|<------- chat_render ----------------| |
|
||||
| | |
|
||||
```
|
||||
|
||||
### 4. 断开连接
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- logout message ------------>|
|
||||
| |
|
||||
| [清理 Session] |
|
||||
| [注销 Event Queue] |
|
||||
| [销毁 Zulip Client] |
|
||||
| |
|
||||
|<------- logout_success -------------|
|
||||
| |
|
||||
|-------- WebSocket Close ----------->|
|
||||
| |
|
||||
```
|
||||
|
||||
## 消息格式规范
|
||||
|
||||
### 消息结构
|
||||
|
||||
所有消息都是 JSON 对象,包含以下基本字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| `type` 或 `t` | string | 消息类型标识 |
|
||||
| 其他字段 | any | 根据消息类型不同而变化 |
|
||||
|
||||
### 消息类型标识
|
||||
|
||||
- 客户端发送的消息使用 `type` 或 `t` 字段
|
||||
- 服务器响应的消息统一使用 `t` 字段
|
||||
|
||||
## 客户端消息
|
||||
|
||||
### LOGIN - 登录认证
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "login",
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| type | string | 是 | 固定值 "login" |
|
||||
| token | string | 是 | 游戏认证 Token |
|
||||
|
||||
### CHAT - 发送聊天消息
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "chat",
|
||||
"content": "Hello, everyone!",
|
||||
"scope": "local"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| t | string | 是 | 固定值 "chat" |
|
||||
| content | string | 是 | 消息内容 (1-1000 字符) |
|
||||
| scope | string | 是 | 消息范围 |
|
||||
|
||||
**scope 取值:**
|
||||
- `"local"`: 当前地图的默认 Topic
|
||||
- `"topic_name"`: 指定的 Topic 名称
|
||||
|
||||
### POSITION - 位置更新
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "position",
|
||||
"x": 150.5,
|
||||
"y": 200.3,
|
||||
"mapId": "novice_village"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| t | string | 是 | 固定值 "position" |
|
||||
| x | number | 是 | X 坐标 |
|
||||
| y | number | 是 | Y 坐标 |
|
||||
| mapId | string | 是 | 地图 ID |
|
||||
|
||||
### LOGOUT - 登出
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "logout"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| type | string | 是 | 固定值 "logout" |
|
||||
|
||||
## 服务器消息
|
||||
|
||||
### LOGIN_SUCCESS - 登录成功
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "login_success",
|
||||
"sessionId": "sess_abc123def456",
|
||||
"currentMap": "novice_village",
|
||||
"username": "player_name",
|
||||
"stream": "Novice Village"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "login_success" |
|
||||
| sessionId | string | 会话 ID |
|
||||
| currentMap | string | 当前地图 ID |
|
||||
| username | string | 用户名 |
|
||||
| stream | string | 当前 Zulip Stream |
|
||||
|
||||
### CHAT_SENT - 消息发送确认
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "chat_sent",
|
||||
"messageId": "msg_789xyz",
|
||||
"timestamp": 1703500800000
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "chat_sent" |
|
||||
| messageId | string | Zulip 消息 ID |
|
||||
| timestamp | number | 发送时间戳 (毫秒) |
|
||||
|
||||
### CHAT_RENDER - 接收聊天消息
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "chat_render",
|
||||
"from": "other_player",
|
||||
"txt": "Hi there!",
|
||||
"bubble": true,
|
||||
"timestamp": 1703500800000,
|
||||
"stream": "Novice Village",
|
||||
"topic": "General"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "chat_render" |
|
||||
| from | string | 发送者名称 |
|
||||
| txt | string | 消息内容 |
|
||||
| bubble | boolean | 是否显示气泡 |
|
||||
| timestamp | number | 消息时间戳 |
|
||||
| stream | string | 来源 Stream |
|
||||
| topic | string | 来源 Topic |
|
||||
|
||||
### POSITION_UPDATED - 位置更新确认
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "position_updated",
|
||||
"stream": "Novice Village",
|
||||
"topic": "General"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "position_updated" |
|
||||
| stream | string | 新的 Zulip Stream |
|
||||
| topic | string | 新的 Zulip Topic |
|
||||
|
||||
### LOGOUT_SUCCESS - 登出成功
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "logout_success"
|
||||
}
|
||||
```
|
||||
|
||||
### ERROR - 错误消息
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "error",
|
||||
"code": "RATE_LIMIT",
|
||||
"message": "消息发送过于频繁,请稍后再试",
|
||||
"details": {
|
||||
"retryAfter": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "error" |
|
||||
| code | string | 错误码 |
|
||||
| message | string | 错误描述 |
|
||||
| details | object | 可选,额外错误信息 |
|
||||
|
||||
## 心跳机制
|
||||
|
||||
### 客户端心跳
|
||||
|
||||
客户端应每 30 秒发送一次心跳消息:
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "ping"
|
||||
}
|
||||
```
|
||||
|
||||
### 服务器响应
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "pong",
|
||||
"timestamp": 1703500800000
|
||||
}
|
||||
```
|
||||
|
||||
### 超时处理
|
||||
|
||||
- 服务器在 60 秒内未收到任何消息将断开连接
|
||||
- 客户端应在连接断开后自动重连
|
||||
|
||||
## 重连策略
|
||||
|
||||
### 指数退避算法
|
||||
|
||||
```
|
||||
重试间隔 = min(baseDelay * 2^attempt, maxDelay)
|
||||
|
||||
baseDelay = 1000ms
|
||||
maxDelay = 30000ms
|
||||
```
|
||||
|
||||
### 重连流程
|
||||
|
||||
1. 检测到连接断开
|
||||
2. 等待重试间隔
|
||||
3. 尝试重新连接
|
||||
4. 连接成功后重新发送 login 消息
|
||||
5. 恢复会话状态
|
||||
|
||||
### 示例代码
|
||||
|
||||
```typescript
|
||||
class ReconnectingWebSocket {
|
||||
private baseDelay = 1000;
|
||||
private maxDelay = 30000;
|
||||
private attempt = 0;
|
||||
|
||||
private getDelay(): number {
|
||||
const delay = Math.min(
|
||||
this.baseDelay * Math.pow(2, this.attempt),
|
||||
this.maxDelay
|
||||
);
|
||||
this.attempt++;
|
||||
return delay;
|
||||
}
|
||||
|
||||
private resetDelay(): void {
|
||||
this.attempt = 0;
|
||||
}
|
||||
|
||||
async reconnect(): Promise<void> {
|
||||
const delay = this.getDelay();
|
||||
console.log(`等待 ${delay}ms 后重连...`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
try {
|
||||
await this.connect();
|
||||
this.resetDelay();
|
||||
} catch (error) {
|
||||
await this.reconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 消息序列化
|
||||
|
||||
### 发送消息
|
||||
|
||||
```typescript
|
||||
function sendMessage(socket: WebSocket, message: object): void {
|
||||
const json = JSON.stringify(message);
|
||||
socket.send(json);
|
||||
}
|
||||
```
|
||||
|
||||
### 接收消息
|
||||
|
||||
```typescript
|
||||
socket.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('消息解析失败:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 并发处理
|
||||
|
||||
### 消息顺序
|
||||
|
||||
- 同一客户端的消息按发送顺序处理
|
||||
- 不同客户端的消息可能并发处理
|
||||
- 服务器响应顺序可能与请求顺序不同
|
||||
|
||||
### 消息确认
|
||||
|
||||
对于需要确认的操作(如发送聊天消息),客户端应:
|
||||
|
||||
1. 生成唯一的请求 ID
|
||||
2. 等待对应的响应
|
||||
3. 设置超时处理
|
||||
|
||||
```typescript
|
||||
async function sendChatWithConfirmation(
|
||||
socket: WebSocket,
|
||||
content: string,
|
||||
timeout: number = 5000
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('发送超时'));
|
||||
}, timeout);
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.t === 'chat_sent') {
|
||||
clearTimeout(timer);
|
||||
socket.removeEventListener('message', handler);
|
||||
resolve();
|
||||
} else if (message.t === 'error') {
|
||||
clearTimeout(timer);
|
||||
socket.removeEventListener('message', handler);
|
||||
reject(new Error(message.message));
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener('message', handler);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
t: 'chat',
|
||||
content: content,
|
||||
scope: 'local'
|
||||
}));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### Token 安全
|
||||
|
||||
- Token 仅在 login 消息中传输一次
|
||||
- 服务器验证后不再需要 Token
|
||||
- Token 应有合理的过期时间
|
||||
|
||||
### 消息验证
|
||||
|
||||
- 服务器验证所有消息格式
|
||||
- 拒绝格式错误的消息
|
||||
- 记录异常消息日志
|
||||
|
||||
### 防重放攻击
|
||||
|
||||
- 使用时间戳验证消息新鲜度
|
||||
- 拒绝过期的消息
|
||||
- 检测重复的消息 ID
|
||||
175
docs/systems/zulip/zulip-js.md
Normal file
175
docs/systems/zulip/zulip-js.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# zulip-js 
|
||||
|
||||
Javascript library to access the Zulip API
|
||||
|
||||
# Usage
|
||||
|
||||
## Initialization
|
||||
|
||||
### With API Key
|
||||
|
||||
```js
|
||||
const zulipInit = require('zulip-js');
|
||||
const config = {
|
||||
username: process.env.ZULIP_USERNAME,
|
||||
apiKey: process.env.ZULIP_API_KEY,
|
||||
realm: process.env.ZULIP_REALM,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const zulip = await zulipInit(config);
|
||||
// The zulip object now initialized with config
|
||||
console.log(await zulip.streams.subscriptions.retrieve());
|
||||
})();
|
||||
```
|
||||
|
||||
### With Username & Password
|
||||
|
||||
You will need to first retrieve the API key by calling `await zulipInit(config)`.
|
||||
|
||||
```js
|
||||
const zulipInit = require('zulip-js');
|
||||
const config = {
|
||||
username: process.env.ZULIP_USERNAME,
|
||||
password: process.env.ZULIP_PASSWORD,
|
||||
realm: process.env.ZULIP_REALM,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
// Fetch API Key
|
||||
const zulip = await zulipInit(config);
|
||||
// The zulip object now contains the API Key
|
||||
console.log(await zulip.streams.subscriptions.retrieve());
|
||||
})();
|
||||
```
|
||||
|
||||
### With zuliprc
|
||||
|
||||
Create a file called `zuliprc` (in the same directory as your code) which looks like:
|
||||
|
||||
```
|
||||
[api]
|
||||
email=cordelia@zulip.com
|
||||
key=wlueAg7cQXqKpUgIaPP3dmF4vibZXal7
|
||||
site=http://localhost:9991
|
||||
```
|
||||
|
||||
Please remember to add this file to your `.gitignore`! Calling `await zulipInit({ zuliprc: 'zuliprc' })` will read this file.
|
||||
|
||||
```js
|
||||
const zulipInit = require('zulip-js');
|
||||
const path = require('path');
|
||||
const zuliprc = path.resolve(__dirname, 'zuliprc');
|
||||
(async () => {
|
||||
const zulip = await zulipInit({ zuliprc });
|
||||
// The zulip object now contains the config from the zuliprc file
|
||||
console.log(await zulip.streams.subscriptions.retrieve());
|
||||
})();
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Please see some examples in [the examples directory](https://github.com/zulip/zulip-js/tree/main/examples).
|
||||
|
||||
Also, to easily test an API endpoint while developing, you can run:
|
||||
|
||||
```
|
||||
$ npm run build
|
||||
$ npm run call <method> <endpoint> [optional: json_params] [optional: path to zuliprc file]
|
||||
$ # For example:
|
||||
$ npm run call GET /users/me
|
||||
$ npm run call GET /users/me '' ~/path/to/my/zuliprc
|
||||
```
|
||||
|
||||
## Supported endpoints
|
||||
|
||||
We support the following endpoints and are striving to have complete coverage of the API. If you want to use some endpoint we do not support presently, you can directly call it as follows:
|
||||
|
||||
```js
|
||||
const params = {
|
||||
to: 'bot testing',
|
||||
type: 'stream',
|
||||
subject: 'Testing zulip-js',
|
||||
content: 'Something is horribly wrong....',
|
||||
};
|
||||
|
||||
await zulip.callEndpoint('/messages', 'POST', params);
|
||||
```
|
||||
|
||||
| Function to call | API Endpoint | Documentation |
|
||||
| --- | --- | --- |
|
||||
| `zulip.accounts.retrieve()` | POST `/fetch_api_key` | returns a promise that you can use to retrieve your `API key`. |
|
||||
| `zulip.emojis.retrieve()` | GET `/realm/emoji` | retrieves the list of realm specific emojis. |
|
||||
| `zulip.events.retrieve()` | GET `/events` | retrieves events from a queue. You can pass it a params object with the id of the queue you are interested in, the last event id that you have received and wish to acknowledge. You can also specify whether the server should not block on this request until there is a new event (the default is to block). |
|
||||
| `zulip.messages.send()` | POST `/messages` | returns a promise that can be used to send a message. |
|
||||
| `zulip.messages.retrieve()` | GET `/messages` | returns a promise that can be used to retrieve messages from a stream. You need to specify the id of the message to be used as an anchor. Use `1000000000` to retrieve the most recent message, or [`zulip.users.me.pointer.retrieve()`](#fetching-a-pointer-for-a-user) to get the id of the last message the user read. |
|
||||
| `zulip.messages.render()` | POST `/messages/render` | returns a promise that can be used to get rendered HTML for a message text. |
|
||||
| `zulip.messages.update()` | PATCH `/messages/<msg_id>` | updates the content or topic of the message with the given `msg_id`. |
|
||||
| `zulip.messages.flags.add()` | POST `/messages/flags` | add a flag to a list of messages. Its params are `flag` which is one of `[read, starred, mentioned, wildcard_mentioned, has_alert_word, historical]` and `messages` which is a list of messageIDs. |
|
||||
| `zulip.messages.flags.remove()` | POST `/messages/flags` | remove a flag from a list of messages. Its params are `flag` which is one of `[read, starred, mentioned, wildcard_mentioned, has_alert_word, historical]` and `messages` which is a list of messageIDs. |
|
||||
| `zulip.messages.getById()` | GET `/messages/<msg_id>` | returns a message by its id. |
|
||||
| `zulip.messages.getHistoryById()` | GET `/messages/<msg_id>/history` | return the history of a message |
|
||||
| `zulip.messages.deleteReactionById()` | DELETE `/messages/<msg_id>/reactions` | deletes reactions on a message by message id |
|
||||
| `zulip.messages.deleteById()` | DELETE `/messages/<msg_id>` | delete the message with the provided message id if the user has permission to do so. |
|
||||
| `zulip.queues.register()` | POST `/register` | registers a new queue. You can pass it a params object with the types of events you are interested in and whether you want to receive raw text or html (using markdown). |
|
||||
| `zulip.queues.deregister()` | DELETE `/events` | deletes a previously registered queue. |
|
||||
| `zulip.reactions.add()` | POST `/reactions` | add a reaction to a message. Accepts a params object with `message_id`, `emoji_name`, `emoji_code` and `reaction_type` (default is `unicode_emoji`). |
|
||||
| `zulip.reactions.remove()` | DELETE `/reactions` | remove a reaction from a message. Accepts a params object with `message_id` and `emoji_code` and `reaction_type` (default is `unicode_emoji`). |
|
||||
| `zulip.streams.retrieve()` | GET `/streams` | returns a promise that can be used to retrieve all streams. |
|
||||
| `zulip.streams.getStreamId()` | GET `/get_stream_id` | returns a promise that can be used to retrieve a stream's id. |
|
||||
| `zulip.streams.subscriptions.retrieve()` | GET `/users/me/subscriptions` | returns a promise that can be used to retrieve the user's subscriptions. |
|
||||
| `zulip.streams.deleteById()` | DELETE `/streams/<stream_id>` | delete the stream with the provided stream id if the user has permission to do so. |
|
||||
| `zulip.streams.topics.retrieve()` | GET `/users/me/<stream_id>/topics` | retrieves all the topics in a specific stream. |
|
||||
| `zulip.typing.send()` | POST `/typing` | can be used to send a typing notification. The parameters required are `to` (either a username or a list of usernames) and `op` (either `start` or `stop`). |
|
||||
| `zulip.users.retrieve()` | GET `/users` | retrieves all users for this realm. |
|
||||
| `zulip.users.me.pointer.retrieve()` | GET `/users/me/pointer` | retrieves a pointer for a user. The pointer is the id of the last message the user read. This can then be used as an anchor message id for subsequent API calls. |
|
||||
| `zulip.users.me.getProfile()` | GET `/users/me` | retrieves the profile of the user/bot. |
|
||||
| `zulip.users.me.subscriptions()` | POST `/users/me/subscriptions` | subscribes a user to a stream/streams. |
|
||||
| `zulip.users.create()` | POST `/users` | create a new user. |
|
||||
| `zulip.users.me.alertWords.retrieve()` | GET `/users/me/alert_words` | get array of a user's alert words. |
|
||||
| `zulip.users.me.subscriptions.remove()` | DELETE `/users/me/subscriptions` | remove subscriptions. |
|
||||
| `zulip.users.me.pointer.update()` | POST `users/me/pointer` | updates the pointer for the user, for moving the home view. Accepts a message id. This has the side effect of marking some messages as read. Will not return success if the message id is invalid. Will always succeed if the id is less than the current value of the pointer (the id of the last message read). |
|
||||
| `zulip.server.settings()` | GET `/server_settings` | returns a dictionary of server settings. |
|
||||
| `zulip.filters.retrieve()` | GET `realm/filters` | return a list of filters in a realm |
|
||||
|
||||
# Testing
|
||||
|
||||
Use `npm test` to run the tests.
|
||||
|
||||
## Writing Tests
|
||||
|
||||
Currently, we have a simple testing framework which stubs our network requests and also allows us to test the input passed to it. This is what a sample test for an API endpoint looks like:
|
||||
|
||||
```js
|
||||
const chai = require('chai');
|
||||
const users = require('../../lib/resources/users'); // File to test.
|
||||
const common = require('../common'); // Common functions for tests.
|
||||
|
||||
chai.should();
|
||||
|
||||
describe('Users', () => {
|
||||
it('should fetch users', async () => {
|
||||
const params = {
|
||||
subject: 'test',
|
||||
content: 'sample test',
|
||||
};
|
||||
const validator = (url, options) => {
|
||||
// Function to test the network request parameters.
|
||||
url.should.equal(`${common.config.apiURL}/users`);
|
||||
Object.keys(options.body.data).length.should.equal(4);
|
||||
options.body.data.subject.should.equal(params.subject);
|
||||
options.body.data.content.should.equal(params.content);
|
||||
};
|
||||
const output = {
|
||||
// The data returned by the API in JSON format.
|
||||
already_subscribed: {},
|
||||
result: 'success',
|
||||
};
|
||||
common.stubNetwork(validator, output); // Stub the network modules.
|
||||
const data = await users(common.config).retrieve(params);
|
||||
data.should.have.property('result', 'success'); // Function call.
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Each pull request should contain relevant tests as well as example usage.
|
||||
Reference in New Issue
Block a user