forked from datawhale/whale-town-end
feat(login, zulip): 引入 JWT 验证并重构 API 密钥管理 #3
@@ -358,7 +358,16 @@ node test-stream-initialization.js
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2.0.0 (2025-12-25)
|
||||
### v1.1.0 (2026-01-06)
|
||||
- **修复 JWT Token 验证和 API Key 管理**
|
||||
- 修复 `LoginService.generateTokenPair()` 的 JWT 签名冲突问题
|
||||
- `ZulipService` 现在复用 `LoginService.verifyToken()` 进行 Token 验证
|
||||
- 修复消息发送时使用错误的硬编码 API Key 问题
|
||||
- 现在正确从 Redis 读取用户注册时存储的真实 API Key
|
||||
- 添加 `AuthModule` 到 `ZulipModule` 的依赖注入
|
||||
- 消息发送功能现已完全正常工作 ✅
|
||||
|
||||
### v1.0.1 (2025-12-25)
|
||||
- 更新地图配置为 9 区域系统
|
||||
- 添加 Stream Initializer Service 自动初始化服务
|
||||
- 更新默认出生点为鲸之港 (Whale Port)
|
||||
|
||||
@@ -68,4 +68,149 @@ C. 接收消息 (Downstream: Zulip -> Node -> Godot)
|
||||
- Zulip API Key 永不下发: 用户的 Zulip 凭证永远只保存在服务器端。如果客户端直接拿 Key,黑客可以通过解包 Godot 拿到 Key 然后去 Zulip 乱发消息。
|
||||
- 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。
|
||||
3. 协议统一:
|
||||
- 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。
|
||||
- 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. JWT Token 验证和 API Key 管理 (v1.1.0 更新)
|
||||
|
||||
### 3.1 用户注册和 API Key 生成流程
|
||||
|
||||
当用户注册游戏账号时,系统会自动创建对应的 Zulip 账号并获取 API Key:
|
||||
|
||||
```
|
||||
用户注册 (POST /auth/register)
|
||||
↓
|
||||
1. 创建游戏账号 (LoginService.register)
|
||||
↓
|
||||
2. 初始化 Zulip 管理员客户端
|
||||
↓
|
||||
3. 创建 Zulip 账号 (ZulipAccountService.createZulipAccount)
|
||||
- 使用相同的邮箱和密码
|
||||
- 调用 Zulip API: POST /api/v1/users
|
||||
↓
|
||||
4. 获取 API Key (ZulipAccountService.generateApiKeyForUser)
|
||||
- 使用 fetch_api_key 端点(固定的、基于密码的 Key)
|
||||
- 注意:不使用 regenerate_api_key(会生成新 Key)
|
||||
↓
|
||||
5. 加密存储 API Key (ApiKeySecurityService.storeApiKey)
|
||||
- 使用 AES-256-GCM 加密
|
||||
- 存储到 Redis: zulip:api_key:{userId}
|
||||
↓
|
||||
6. 创建账号关联记录 (ZulipAccountsRepository)
|
||||
- 存储 gameUserId ↔ zulipUserId 映射
|
||||
↓
|
||||
7. 生成 JWT Token (LoginService.generateTokenPair)
|
||||
- 包含用户信息:sub, username, email, role
|
||||
- 返回 access_token 和 refresh_token
|
||||
```
|
||||
|
||||
### 3.2 JWT Token 验证流程
|
||||
|
||||
当用户通过 WebSocket 登录游戏时,系统会验证 JWT Token 并获取 API Key:
|
||||
|
||||
```
|
||||
WebSocket 登录 (login 消息)
|
||||
↓
|
||||
1. ZulipService.validateGameToken(token)
|
||||
↓
|
||||
2. 调用 LoginService.verifyToken(token, 'access')
|
||||
- 验证签名、过期时间、载荷
|
||||
- 提取用户信息:userId, username, email
|
||||
↓
|
||||
3. 从 Redis 获取 API Key (ApiKeySecurityService.getApiKey)
|
||||
- 解密存储的 API Key
|
||||
- 更新访问计数和时间
|
||||
↓
|
||||
4. 创建 Zulip 客户端 (ZulipClientPoolService.createUserClient)
|
||||
- 使用真实的用户 API Key
|
||||
- 注册事件队列
|
||||
↓
|
||||
5. 创建游戏会话 (SessionManagerService.createSession)
|
||||
- 绑定 socketId ↔ zulipQueueId
|
||||
- 记录用户位置信息
|
||||
↓
|
||||
6. 返回登录成功
|
||||
```
|
||||
|
||||
### 3.3 消息发送流程(使用正确的 API Key)
|
||||
|
||||
```
|
||||
发送聊天消息 (chat 消息)
|
||||
↓
|
||||
1. ZulipService.sendChatMessage()
|
||||
↓
|
||||
2. 获取会话信息 (SessionManagerService.getSession)
|
||||
- 获取 userId 和当前位置
|
||||
↓
|
||||
3. 上下文注入 (SessionManagerService.injectContext)
|
||||
- 根据位置确定目标 Stream/Topic
|
||||
↓
|
||||
4. 消息验证 (MessageFilterService.validateMessage)
|
||||
- 内容过滤、频率限制
|
||||
↓
|
||||
5. 发送到 Zulip (ZulipClientPoolService.sendMessage)
|
||||
- 使用用户的真实 API Key
|
||||
- 调用 Zulip API: POST /api/v1/messages
|
||||
↓
|
||||
6. 返回发送结果
|
||||
```
|
||||
|
||||
### 3.4 关键修复说明
|
||||
|
||||
**问题 1: JWT Token 签名冲突**
|
||||
- **原因**: payload 中包含 `iss` 和 `aud`,但 `jwtService.signAsync()` 也通过 options 传递这些值
|
||||
- **修复**: 从 payload 中移除 `iss` 和 `aud`,只通过 options 传递
|
||||
- **文件**: `src/business/auth/services/login.service.ts`
|
||||
|
||||
**问题 2: 使用硬编码的旧 API Key**
|
||||
- **原因**: `ZulipService.validateGameToken()` 使用硬编码的测试 API Key
|
||||
- **修复**: 从 Redis 读取用户注册时存储的真实 API Key
|
||||
- **文件**: `src/business/zulip/zulip.service.ts`
|
||||
|
||||
**问题 3: 重复实现 JWT 验证逻辑**
|
||||
- **原因**: `ZulipService` 自己实现了 JWT 解析
|
||||
- **修复**: 复用 `LoginService.verifyToken()` 方法
|
||||
- **文件**: `src/business/zulip/zulip.service.ts`, `src/business/zulip/zulip.module.ts`
|
||||
|
||||
### 3.5 API Key 安全机制
|
||||
|
||||
**加密存储**:
|
||||
- 使用 AES-256-GCM 算法加密
|
||||
- 加密密钥从环境变量 `ZULIP_API_KEY_ENCRYPTION_KEY` 读取
|
||||
- 存储格式:`{encryptedKey, iv, authTag, createdAt, updatedAt, accessCount}`
|
||||
|
||||
**访问控制**:
|
||||
- 频率限制:每分钟最多 60 次访问
|
||||
- 访问日志:记录每次访问的时间和次数
|
||||
- 安全事件:记录所有关键操作(存储、访问、更新、删除)
|
||||
|
||||
**环境变量配置**:
|
||||
```bash
|
||||
# 生成 64 字符的十六进制密钥(32 字节 = 256 位)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
# 在 .env 文件中配置
|
||||
ZULIP_API_KEY_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
```
|
||||
|
||||
### 3.6 测试验证
|
||||
|
||||
使用测试脚本验证功能:
|
||||
|
||||
```bash
|
||||
# 测试注册用户的 Zulip 集成
|
||||
node docs/systems/zulip/quick_tests/test-registered-user.js
|
||||
|
||||
# 验证 API Key 一致性
|
||||
node docs/systems/zulip/quick_tests/verify-api-key.js
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- ✅ WebSocket 连接成功
|
||||
- ✅ JWT Token 验证通过
|
||||
- ✅ 从 Redis 获取正确的 API Key
|
||||
- ✅ 消息成功发送到 Zulip
|
||||
|
||||
---
|
||||
|
||||
260
docs/systems/zulip/quick_tests/test-get-messages.js
Normal file
260
docs/systems/zulip/quick_tests/test-get-messages.js
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 测试通过 WebSocket 接收 Zulip 消息
|
||||
*
|
||||
* 设计理念:
|
||||
* - Zulip API Key 永不下发到客户端
|
||||
* - 所有 Zulip 交互通过游戏服务器的 WebSocket 进行
|
||||
* - 客户端只接收 chat_render 消息,不直接调用 Zulip API
|
||||
*
|
||||
* 功能:
|
||||
* 1. 登录游戏服务器获取 JWT Token
|
||||
* 2. 通过 WebSocket 连接游戏服务器
|
||||
* 3. 在当前地图 (Whale Port) 接收消息
|
||||
* 4. 切换到 Pumpkin Valley 接收消息
|
||||
* 5. 统计接收到的消息数量
|
||||
*
|
||||
* 使用方法:
|
||||
* node docs/systems/zulip/quick_tests/test-get-messages.js
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const io = require('socket.io-client');
|
||||
|
||||
// 配置
|
||||
const GAME_SERVER = 'http://localhost:3000';
|
||||
const TEST_USER = {
|
||||
username: 'angtest123',
|
||||
password: 'angtest123',
|
||||
email: 'angjustinl@163.com'
|
||||
};
|
||||
|
||||
// 测试配置
|
||||
const TEST_CONFIG = {
|
||||
whalePortWaitTime: 10000, // 在 Whale Port 等待 10 秒
|
||||
pumpkinValleyWaitTime: 10000, // 在 Pumpkin Valley 等待 10 秒
|
||||
totalTimeout: 30000 // 总超时时间 30 秒
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录游戏服务器获取用户信息
|
||||
*/
|
||||
async function loginToGameServer() {
|
||||
console.log('📝 步骤 1: 登录游戏服务器');
|
||||
console.log(` 用户名: ${TEST_USER.username}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||
identifier: TEST_USER.username,
|
||||
password: TEST_USER.password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||
return {
|
||||
userId: response.data.data.user.id,
|
||||
username: response.data.data.user.username,
|
||||
email: response.data.data.user.email,
|
||||
token: response.data.data.access_token
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 通过 WebSocket 接收消息
|
||||
*/
|
||||
async function receiveMessagesViaWebSocket(userInfo) {
|
||||
console.log('\n📡 步骤 2: 通过 WebSocket 连接并接收消息');
|
||||
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = io(`${GAME_SERVER}/game`, {
|
||||
transports: ['websocket'],
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
const receivedMessages = {
|
||||
whalePort: [],
|
||||
pumpkinValley: []
|
||||
};
|
||||
|
||||
let currentMap = 'whale_port';
|
||||
let testPhase = 0; // 0: 连接中, 1: Whale Port, 2: Pumpkin Valley, 3: 完成
|
||||
|
||||
// 连接成功
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
|
||||
// 发送登录消息
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: userInfo.token
|
||||
};
|
||||
|
||||
console.log('📤 发送登录消息...');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
|
||||
// 登录成功
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 会话ID: ${data.sessionId}`);
|
||||
console.log(` 用户ID: ${data.userId}`);
|
||||
console.log(` 当前地图: ${data.currentMap}`);
|
||||
|
||||
testPhase = 1;
|
||||
currentMap = data.currentMap || 'whale_port';
|
||||
|
||||
console.log(`\n📬 步骤 3: 在 Whale Port 接收消息 (等待 ${TEST_CONFIG.whalePortWaitTime / 1000} 秒)`);
|
||||
console.log(' 💡 提示: 请在 Zulip 的 "Whale Port" Stream 发送测试消息');
|
||||
|
||||
// 在 Whale Port 等待一段时间
|
||||
setTimeout(() => {
|
||||
console.log(`\n📊 Whale Port 接收到 ${receivedMessages.whalePort.length} 条消息`);
|
||||
|
||||
// 切换到 Pumpkin Valley
|
||||
console.log(`\n📤 步骤 4: 切换到 Pumpkin Valley`);
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
};
|
||||
socket.emit('position_update', positionUpdate);
|
||||
|
||||
testPhase = 2;
|
||||
currentMap = 'pumpkin_valley';
|
||||
|
||||
console.log(`\n📬 步骤 5: 在 Pumpkin Valley 接收消息 (等待 ${TEST_CONFIG.pumpkinValleyWaitTime / 1000} 秒)`);
|
||||
console.log(' 💡 提示: 请在 Zulip 的 "Pumpkin Valley" Stream 发送测试消息');
|
||||
|
||||
// 在 Pumpkin Valley 等待一段时间
|
||||
setTimeout(() => {
|
||||
console.log(`\n📊 Pumpkin Valley 接收到 ${receivedMessages.pumpkinValley.length} 条消息`);
|
||||
|
||||
testPhase = 3;
|
||||
console.log('\n📊 测试完成,断开连接...');
|
||||
socket.disconnect();
|
||||
}, TEST_CONFIG.pumpkinValleyWaitTime);
|
||||
}, TEST_CONFIG.whalePortWaitTime);
|
||||
});
|
||||
|
||||
// 接收到消息 (chat_render)
|
||||
socket.on('chat_render', (data) => {
|
||||
const timestamp = new Date().toLocaleTimeString('zh-CN');
|
||||
|
||||
console.log(`\n📨 [${timestamp}] 收到消息:`);
|
||||
console.log(` ├─ 发送者: ${data.from}`);
|
||||
console.log(` ├─ 内容: ${data.txt}`);
|
||||
console.log(` ├─ Stream: ${data.stream || '未知'}`);
|
||||
console.log(` ├─ Topic: ${data.topic || '未知'}`);
|
||||
console.log(` └─ 当前地图: ${currentMap}`);
|
||||
|
||||
// 记录消息
|
||||
const message = {
|
||||
from: data.from,
|
||||
content: data.txt,
|
||||
stream: data.stream,
|
||||
topic: data.topic,
|
||||
timestamp: new Date(),
|
||||
map: currentMap
|
||||
};
|
||||
|
||||
if (testPhase === 1) {
|
||||
receivedMessages.whalePort.push(message);
|
||||
} else if (testPhase === 2) {
|
||||
receivedMessages.pumpkinValley.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
socket.on('error', (error) => {
|
||||
console.error('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||
});
|
||||
|
||||
// 连接断开
|
||||
socket.on('disconnect', () => {
|
||||
console.log('\n🔌 WebSocket 连接已关闭');
|
||||
resolve(receivedMessages);
|
||||
});
|
||||
|
||||
// 连接错误
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接错误:', error.message);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// 总超时保护
|
||||
setTimeout(() => {
|
||||
if (socket.connected) {
|
||||
console.log('\n⏰ 测试超时,关闭连接');
|
||||
socket.disconnect();
|
||||
}
|
||||
}, TEST_CONFIG.totalTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 主测试流程
|
||||
*/
|
||||
async function runTest() {
|
||||
console.log('🚀 开始测试通过 WebSocket 接收 Zulip 消息');
|
||||
console.log('='.repeat(60));
|
||||
console.log('📋 设计理念: Zulip API Key 永不下发到客户端');
|
||||
console.log('📋 所有消息通过游戏服务器的 WebSocket (chat_render) 接收');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
// 步骤1: 登录游戏服务器
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
// 步骤2-5: 通过 WebSocket 接收消息
|
||||
const receivedMessages = await receiveMessagesViaWebSocket(userInfo);
|
||||
|
||||
// 步骤6: 统计信息
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 测试结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`✅ Whale Port: ${receivedMessages.whalePort.length} 条消息`);
|
||||
console.log(`✅ Pumpkin Valley: ${receivedMessages.pumpkinValley.length} 条消息`);
|
||||
console.log(`📝 总计: ${receivedMessages.whalePort.length + receivedMessages.pumpkinValley.length} 条消息`);
|
||||
|
||||
// 显示详细消息列表
|
||||
if (receivedMessages.whalePort.length > 0) {
|
||||
console.log('\n📬 Whale Port 消息列表:');
|
||||
receivedMessages.whalePort.forEach((msg, index) => {
|
||||
console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (receivedMessages.pumpkinValley.length > 0) {
|
||||
console.log('\n📬 Pumpkin Valley 消息列表:');
|
||||
receivedMessages.pumpkinValley.forEach((msg, index) => {
|
||||
console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log('\n🎉 测试完成!');
|
||||
console.log('💡 提示: 客户端通过 WebSocket 接收消息,无需直接访问 Zulip API');
|
||||
console.log('💡 提示: 访问 https://zulip.xinghangee.icu 查看完整消息历史');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ 测试失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTest();
|
||||
@@ -1,15 +1,102 @@
|
||||
const zulip = require('zulip-js');
|
||||
const axios = require('axios');
|
||||
|
||||
async function listSubscriptions() {
|
||||
console.log('🔧 检查用户订阅的 Streams...');
|
||||
|
||||
const config = {
|
||||
username: 'angjustinl@mail.angforever.top',
|
||||
apiKey: 'lCPWC...pqNfGF8',
|
||||
realm: 'https://zulip.xinghangee.icu/'
|
||||
};
|
||||
// 配置
|
||||
const GAME_SERVER = 'http://localhost:3000';
|
||||
const TEST_USER = {
|
||||
username: 'angtest123',
|
||||
password: 'angtest123',
|
||||
email: 'angjustinl@163.com'
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录游戏服务器获取用户信息
|
||||
*/
|
||||
async function loginToGameServer() {
|
||||
console.log('📝 步骤 1: 登录游戏服务器');
|
||||
console.log(` 用户名: ${TEST_USER.username}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||
identifier: TEST_USER.username,
|
||||
password: TEST_USER.password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||
return {
|
||||
userId: response.data.data.user.id,
|
||||
username: response.data.data.user.username,
|
||||
email: response.data.data.user.email,
|
||||
token: response.data.data.access_token
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用密码获取 Zulip API Key
|
||||
*/
|
||||
async function getZulipApiKey(email, password) {
|
||||
console.log('\n📝 步骤 2: 获取 Zulip API Key');
|
||||
console.log(` 邮箱: ${email}`);
|
||||
|
||||
try {
|
||||
// Zulip API 使用 Basic Auth 和 form data
|
||||
const response = await axios.post(
|
||||
'https://zulip.xinghangee.icu/api/v1/fetch_api_key',
|
||||
`username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.result === 'success') {
|
||||
console.log('✅ 成功获取 API Key');
|
||||
console.log(` API Key: ${response.data.api_key.substring(0, 10)}...`);
|
||||
console.log(` 用户ID: ${response.data.user_id}`);
|
||||
return {
|
||||
apiKey: response.data.api_key,
|
||||
email: response.data.email,
|
||||
userId: response.data.user_id
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.msg || '获取 API Key 失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 获取 API Key 失败:', error.response?.data?.msg || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listSubscriptions() {
|
||||
console.log('🚀 开始测试用户订阅的 Streams');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
// 步骤1: 登录游戏服务器
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
// 步骤2: 获取 Zulip API Key
|
||||
const zulipAuth = await getZulipApiKey(userInfo.email, TEST_USER.password);
|
||||
|
||||
console.log('\n📝 步骤 3: 检查用户订阅的 Streams');
|
||||
|
||||
const config = {
|
||||
username: zulipAuth.email,
|
||||
apiKey: zulipAuth.apiKey,
|
||||
realm: 'https://zulip.xinghangee.icu/'
|
||||
};
|
||||
|
||||
const client = await zulip(config);
|
||||
|
||||
// 获取用户信息
|
||||
@@ -29,15 +116,15 @@ async function listSubscriptions() {
|
||||
});
|
||||
|
||||
// 检查是否有 "Novice Village"
|
||||
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Novice Village');
|
||||
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Pumpkin Valley');
|
||||
if (noviceVillage) {
|
||||
console.log('\n✅ "Novice Village" Stream 已存在!');
|
||||
console.log('\n✅ "Pumpkin Valley" Stream 已存在!');
|
||||
|
||||
// 测试发送消息
|
||||
console.log('\n📤 测试发送消息...');
|
||||
const result = await client.messages.send({
|
||||
type: 'stream',
|
||||
to: 'Novice Village',
|
||||
to: 'Pumpkin Valley',
|
||||
subject: 'General',
|
||||
content: '测试消息:系统集成测试成功 🎮'
|
||||
});
|
||||
@@ -48,7 +135,7 @@ async function listSubscriptions() {
|
||||
console.log('❌ 消息发送失败:', result.msg);
|
||||
}
|
||||
} else {
|
||||
console.log('\n⚠️ "Novice Village" Stream 不存在');
|
||||
console.log('\n⚠️ "Pumpkin Valley" Stream 不存在');
|
||||
console.log('💡 请在 Zulip 网页界面手动创建该 Stream,或使用管理员账号创建');
|
||||
|
||||
// 尝试发送到第一个可用的 Stream
|
||||
@@ -79,7 +166,9 @@ async function listSubscriptions() {
|
||||
if (error.response) {
|
||||
console.error('响应数据:', error.response.data);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
listSubscriptions();
|
||||
|
||||
@@ -1,127 +1,183 @@
|
||||
const io = require('socket.io-client');
|
||||
const axios = require('axios');
|
||||
|
||||
// 配置
|
||||
const GAME_SERVER = 'http://localhost:3000';
|
||||
const TEST_USER = {
|
||||
username: 'angtest123',
|
||||
password: 'angtest123',
|
||||
email: 'angjustinl@163.com'
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录游戏服务器获取token
|
||||
*/
|
||||
async function loginToGameServer() {
|
||||
console.log('📝 步骤 1: 登录游戏服务器');
|
||||
console.log(` 用户名: ${TEST_USER.username}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||
identifier: TEST_USER.username,
|
||||
password: TEST_USER.password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||
console.log(` 昵称: ${response.data.data.user.nickname}`);
|
||||
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||
console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`);
|
||||
return {
|
||||
userId: response.data.data.user.id,
|
||||
username: response.data.data.user.username,
|
||||
token: response.data.data.access_token
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用用户 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');
|
||||
console.log('🚀 开始测试用户 API Key 的 Zulip 集成');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const socket = io('http://localhost:3000/game', {
|
||||
transports: ['websocket'],
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
let testStep = 0;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
testStep = 1;
|
||||
try {
|
||||
// 登录获取 token
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
// 使用包含用户 API Key 的 token
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: 'lCPWCPfGh7...fGF8_user_token'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 1: 发送登录消息(使用用户 API Key)');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
console.log('\n📡 步骤 2: 连接 WebSocket 并测试 Zulip 集成');
|
||||
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||
|
||||
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;
|
||||
const socket = io(`${GAME_SERVER}/game`, {
|
||||
transports: ['websocket'],
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
// 等待 Zulip 客户端初始化
|
||||
console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||
setTimeout(() => {
|
||||
const chatMessage = {
|
||||
t: 'chat',
|
||||
content: '🎮 【用户API Key测试】来自游戏的消息!\\n' +
|
||||
'时间: ' + new Date().toLocaleString() + '\\n' +
|
||||
'使用用户 API Key 发送此消息。',
|
||||
scope: 'local'
|
||||
let testStep = 0;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
testStep = 1;
|
||||
|
||||
// 使用真实的 JWT token
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: userInfo.token
|
||||
};
|
||||
|
||||
console.log('📤 步骤 2: 发送消息到 Zulip(使用用户 API Key)');
|
||||
console.log(' 目标 Stream: Whale Port');
|
||||
socket.emit('chat', chatMessage);
|
||||
}, 3000);
|
||||
});
|
||||
console.log('📤 步骤 3: 发送登录消息(使用 JWT Token)');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
|
||||
socket.on('chat_sent', (data) => {
|
||||
console.log('✅ 步骤 2 完成: 消息发送成功');
|
||||
console.log(' 响应:', JSON.stringify(data, null, 2));
|
||||
|
||||
// 只在第一次收到 chat_sent 时发送第二条消息
|
||||
if (testStep === 2) {
|
||||
testStep = 3;
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('✅ 步骤 3 完成: 登录成功');
|
||||
console.log(' 会话ID:', data.sessionId);
|
||||
console.log(' 用户ID:', data.userId);
|
||||
console.log(' 用户名:', data.username);
|
||||
console.log(' 当前地图:', data.currentMap);
|
||||
testStep = 2;
|
||||
|
||||
// 等待 Zulip 客户端初始化
|
||||
console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||
setTimeout(() => {
|
||||
// 先切换到 Pumpkin Valley 地图
|
||||
console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图');
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
const chatMessage = {
|
||||
t: 'chat',
|
||||
content: `🎮 【用户API Key测试】来自 ${userInfo.username} 的消息!\n` +
|
||||
`时间: ${new Date().toLocaleString()}\n` +
|
||||
`使用真实 API Key 发送此消息。`,
|
||||
scope: 'local'
|
||||
};
|
||||
socket.emit('position_update', positionUpdate);
|
||||
|
||||
// 等待位置更新后发送消息
|
||||
console.log('📤 步骤 4: 发送消息到 Zulip(使用真实 API Key)');
|
||||
console.log(' 目标 Stream: Whale Port');
|
||||
socket.emit('chat', chatMessage);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
socket.on('chat_sent', (data) => {
|
||||
console.log('✅ 步骤 4 完成: 消息发送成功');
|
||||
console.log(' 响应:', JSON.stringify(data, null, 2));
|
||||
|
||||
// 只在第一次收到 chat_sent 时发送第二条消息
|
||||
if (testStep === 2) {
|
||||
testStep = 3;
|
||||
|
||||
setTimeout(() => {
|
||||
const chatMessage2 = {
|
||||
t: 'chat',
|
||||
content: '🎃 在南瓜谷发送的测试消息!',
|
||||
scope: 'local'
|
||||
// 先切换到 Pumpkin Valley 地图
|
||||
console.log('\n📤 步骤 5: 切换到 Pumpkin Valley 地图');
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
};
|
||||
socket.emit('position_update', positionUpdate);
|
||||
|
||||
console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息');
|
||||
socket.emit('chat', chatMessage2);
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
// 等待位置更新后发送消息
|
||||
setTimeout(() => {
|
||||
const chatMessage2 = {
|
||||
t: 'chat',
|
||||
content: '🎃 在南瓜谷发送的测试消息!',
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 6: 在 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);
|
||||
socket.on('chat_render', (data) => {
|
||||
console.log('\n📨 收到来自 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('\n🔌 WebSocket 连接已关闭');
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 测试结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
console.log(' 完成步骤:', testStep, '/ 3');
|
||||
if (testStep >= 3) {
|
||||
console.log(' ✅ 核心功能正常!');
|
||||
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
|
||||
} else {
|
||||
console.log(' ⚠️ 部分测试未完成');
|
||||
}
|
||||
console.log('='.repeat(60));
|
||||
process.exit(testStep >= 3 ? 0 : 1);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接错误:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 20秒后自动关闭(给足够时间完成测试)
|
||||
setTimeout(() => {
|
||||
console.log('\n⏰ 测试时间到,关闭连接');
|
||||
socket.disconnect();
|
||||
}, 20000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ 测试失败:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 20秒后自动关闭(给足够时间完成测试)
|
||||
setTimeout(() => {
|
||||
console.log('⏰ 测试时间到,关闭连接');
|
||||
socket.disconnect();
|
||||
}, 20000);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔧 准备测试环境...');
|
||||
testWithUserApiKey().catch(console.error);
|
||||
// 运行测试
|
||||
testWithUserApiKey();
|
||||
@@ -663,35 +663,34 @@ export class LoginService {
|
||||
throw new Error('JWT_SECRET未配置');
|
||||
}
|
||||
|
||||
// 1. 创建访问令牌载荷
|
||||
const accessPayload: JwtPayload = {
|
||||
// 1. 创建访问令牌载荷(不包含iss和aud,这些通过options传递)
|
||||
const accessPayload: Omit<JwtPayload, 'iss' | 'aud' | 'iat' | 'exp'> = {
|
||||
sub: user.id.toString(),
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
email: user.email,
|
||||
type: 'access',
|
||||
iat: currentTime,
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
};
|
||||
|
||||
// 2. 创建刷新令牌载荷(有效期更长)
|
||||
const refreshPayload: JwtPayload = {
|
||||
const refreshPayload: Omit<JwtPayload, 'iss' | 'aud' | 'iat' | 'exp'> = {
|
||||
sub: user.id.toString(),
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
type: 'refresh',
|
||||
iat: currentTime,
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
};
|
||||
|
||||
// 3. 生成访问令牌(使用NestJS JwtService)
|
||||
const accessToken = await this.jwtService.signAsync(accessPayload);
|
||||
// 3. 生成访问令牌(使用NestJS JwtService,通过options传递iss和aud)
|
||||
const accessToken = await this.jwtService.signAsync(accessPayload, {
|
||||
issuer: 'whale-town',
|
||||
audience: 'whale-town-users',
|
||||
});
|
||||
|
||||
// 4. 生成刷新令牌(有效期30天)
|
||||
const refreshToken = jwt.sign(refreshPayload, jwtSecret, {
|
||||
expiresIn: '30d',
|
||||
issuer: 'whale-town',
|
||||
audience: 'whale-town-users',
|
||||
});
|
||||
|
||||
// 5. 计算过期时间(秒)
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
* - 业务规则驱动的消息过滤和权限控制
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 2.0.0
|
||||
* @since 2025-12-31
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-06
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
@@ -53,6 +53,7 @@ import { ZulipCoreModule } from '../../core/zulip/zulip-core.module';
|
||||
import { RedisModule } from '../../core/redis/redis.module';
|
||||
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -64,6 +65,8 @@ import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
LoggerModule,
|
||||
// 登录模块 - 提供用户认证和Token验证
|
||||
LoginCoreModule,
|
||||
// 认证模块 - 提供JWT验证和用户认证服务
|
||||
AuthModule,
|
||||
],
|
||||
providers: [
|
||||
// 主协调服务 - 整合各子服务,提供统一业务接口
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* - 消息格式转换和过滤
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-06
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
IZulipConfigService,
|
||||
} from '../../core/zulip/interfaces/zulip-core.interfaces';
|
||||
import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service';
|
||||
import { LoginService } from '../auth/services/login.service';
|
||||
|
||||
/**
|
||||
* 玩家登录请求接口
|
||||
@@ -116,6 +117,7 @@ export class ZulipService {
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configManager: IZulipConfigService,
|
||||
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||
private readonly loginService: LoginService,
|
||||
) {
|
||||
this.logger.log('ZulipService初始化完成');
|
||||
}
|
||||
@@ -172,9 +174,7 @@ export class ZulipService {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 验证游戏Token并获取用户信息
|
||||
// TODO: 实际项目中应该调用认证服务验证Token
|
||||
// 这里暂时使用模拟数据
|
||||
// 2. 验证游戏Token并获取用户信息 调用认证服务验证Token
|
||||
const userInfo = await this.validateGameToken(request.token);
|
||||
if (!userInfo) {
|
||||
this.logger.warn('登录失败:Token验证失败', {
|
||||
@@ -288,7 +288,7 @@ export class ZulipService {
|
||||
* 功能描述:
|
||||
* 验证游戏Token的有效性,返回用户信息
|
||||
*
|
||||
* @param token 游戏Token
|
||||
* @param token 游戏Token (JWT)
|
||||
* @returns Promise<UserInfo | null> 用户信息,验证失败返回null
|
||||
* @private
|
||||
*/
|
||||
@@ -299,69 +299,84 @@ export class ZulipService {
|
||||
zulipEmail?: string;
|
||||
zulipApiKey?: string;
|
||||
} | null> {
|
||||
// TODO: 实际项目中应该调用认证服务验证Token (登录godot所获取的JWT token)
|
||||
// 这里暂时使用模拟数据进行开发测试
|
||||
|
||||
this.logger.debug('验证游戏Token', {
|
||||
operation: 'validateGameToken',
|
||||
tokenLength: token.length,
|
||||
});
|
||||
|
||||
// 模拟Token验证
|
||||
// 实际实现应该:
|
||||
// 1. 调用LoginService验证Token
|
||||
// 2. 从数据库获取用户的Zulip API Key
|
||||
// 3. 返回完整的用户信息
|
||||
|
||||
if (token.startsWith('invalid')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 从Token中提取用户ID(模拟)
|
||||
const userId = `user_${token.substring(0, 8)}`;
|
||||
|
||||
// 从ApiKeySecurityService获取真实的Zulip API Key
|
||||
let zulipApiKey = undefined;
|
||||
let zulipEmail = undefined;
|
||||
|
||||
try {
|
||||
// 尝试从Redis获取存储的API Key
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId);
|
||||
|
||||
if (apiKeyResult.success && apiKeyResult.apiKey) {
|
||||
zulipApiKey = apiKeyResult.apiKey;
|
||||
// TODO: 从数据库获取用户的Zulip邮箱
|
||||
// 暂时使用模拟数据
|
||||
zulipEmail = 'angjustinl@163.com';
|
||||
|
||||
this.logger.log('从存储获取到Zulip API Key', {
|
||||
// 1. 使用LoginService验证JWT token
|
||||
const payload = await this.loginService.verifyToken(token, 'access');
|
||||
|
||||
if (!payload || !payload.sub) {
|
||||
this.logger.warn('Token载荷无效', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
hasApiKey: true,
|
||||
zulipEmail,
|
||||
});
|
||||
} else {
|
||||
this.logger.debug('用户没有存储的Zulip API Key', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn('获取Zulip API Key失败', {
|
||||
|
||||
const userId = payload.sub;
|
||||
const username = payload.username || `user_${userId}`;
|
||||
const email = payload.email || `${userId}@example.com`;
|
||||
|
||||
this.logger.debug('Token解析成功', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
});
|
||||
|
||||
// 2. 从ApiKeySecurityService获取真实的Zulip API Key
|
||||
let zulipApiKey = undefined;
|
||||
let zulipEmail = undefined;
|
||||
|
||||
try {
|
||||
// 尝试从Redis获取存储的API Key
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId);
|
||||
|
||||
if (apiKeyResult.success && apiKeyResult.apiKey) {
|
||||
zulipApiKey = apiKeyResult.apiKey;
|
||||
// 使用游戏账号的邮箱
|
||||
zulipEmail = email;
|
||||
|
||||
this.logger.log('从存储获取到Zulip API Key', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
hasApiKey: true,
|
||||
apiKeyLength: zulipApiKey.length,
|
||||
});
|
||||
} else {
|
||||
this.logger.debug('用户没有存储的Zulip API Key', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
reason: apiKeyResult.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn('获取Zulip API Key失败', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
zulipEmail,
|
||||
zulipApiKey,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn('Token验证失败', {
|
||||
operation: 'validateGameToken',
|
||||
error: err.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
username: `Player_${userId.substring(5, 10)}`,
|
||||
email: `${userId}@example.com`,
|
||||
zulipEmail,
|
||||
zulipApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user