forked from datawhale/whale-town-end
- Standardize author attribution across 27 files in the Zulip integration module - Maintain consistent code documentation and authorship tracking
705 lines
18 KiB
TypeScript
705 lines
18 KiB
TypeScript
/**
|
||
* Zulip客户端核心服务
|
||
*
|
||
* 功能描述:
|
||
* - 封装Zulip REST API调用
|
||
* - 实现API Key验证和错误处理
|
||
* - 提供消息发送、事件队列管理等核心功能
|
||
*
|
||
* 主要方法:
|
||
* - initialize(): 初始化Zulip客户端并验证API Key
|
||
* - sendMessage(): 发送消息到指定Stream/Topic
|
||
* - registerQueue(): 注册事件队列
|
||
* - deregisterQueue(): 注销事件队列
|
||
* - getEvents(): 获取事件队列中的事件
|
||
*
|
||
* 使用场景:
|
||
* - 用户登录时创建和验证Zulip客户端
|
||
* - 消息发送和接收
|
||
* - 事件队列管理
|
||
*
|
||
* @author angjustinl
|
||
* @version 1.0.0
|
||
* @since 2025-12-25
|
||
*/
|
||
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { ZulipAPI, Internal, Enums } from '../interfaces/zulip.interfaces';
|
||
|
||
/**
|
||
* Zulip客户端配置接口
|
||
*/
|
||
export interface ZulipClientConfig {
|
||
username: string;
|
||
apiKey: string;
|
||
realm: string;
|
||
}
|
||
|
||
/**
|
||
* Zulip客户端实例接口
|
||
*/
|
||
export interface ZulipClientInstance {
|
||
userId: string;
|
||
config: ZulipClientConfig;
|
||
client: any; // zulip-js客户端实例
|
||
queueId?: string;
|
||
lastEventId: number;
|
||
createdAt: Date;
|
||
lastActivity: Date;
|
||
isValid: boolean;
|
||
}
|
||
|
||
/**
|
||
* 发送消息结果接口
|
||
*/
|
||
export interface SendMessageResult {
|
||
success: boolean;
|
||
messageId?: number;
|
||
error?: string;
|
||
}
|
||
|
||
/**
|
||
* 事件队列注册结果接口
|
||
*/
|
||
export interface RegisterQueueResult {
|
||
success: boolean;
|
||
queueId?: string;
|
||
lastEventId?: number;
|
||
error?: string;
|
||
}
|
||
|
||
/**
|
||
* 获取事件结果接口
|
||
*/
|
||
export interface GetEventsResult {
|
||
success: boolean;
|
||
events?: ZulipAPI.Event[];
|
||
error?: string;
|
||
}
|
||
|
||
@Injectable()
|
||
export class ZulipClientService {
|
||
private readonly logger = new Logger(ZulipClientService.name);
|
||
|
||
constructor() {
|
||
this.logger.log('ZulipClientService初始化完成');
|
||
}
|
||
|
||
/**
|
||
* 创建并初始化Zulip客户端
|
||
*
|
||
* 功能描述:
|
||
* 使用提供的配置创建zulip-js客户端实例,并验证API Key的有效性
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 验证配置参数的完整性
|
||
* 2. 创建zulip-js客户端实例
|
||
* 3. 调用API验证凭证有效性
|
||
* 4. 返回初始化后的客户端实例
|
||
*
|
||
* @param userId 用户ID
|
||
* @param config Zulip客户端配置
|
||
* @returns Promise<ZulipClientInstance> 初始化后的客户端实例
|
||
*
|
||
* @throws Error 当配置无效或API Key验证失败时
|
||
*/
|
||
async createClient(userId: string, config: ZulipClientConfig): Promise<ZulipClientInstance> {
|
||
const startTime = Date.now();
|
||
|
||
this.logger.log('开始创建Zulip客户端', {
|
||
operation: 'createClient',
|
||
userId,
|
||
realm: config.realm,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 1. 验证配置参数
|
||
this.validateConfig(config);
|
||
|
||
// 2. 动态导入zulip-js
|
||
const zulipInit = await this.loadZulipModule();
|
||
|
||
// 3. 创建zulip-js客户端实例
|
||
const client = await zulipInit({
|
||
username: config.username,
|
||
apiKey: config.apiKey,
|
||
realm: config.realm,
|
||
});
|
||
|
||
// 4. 验证API Key有效性 - 通过获取用户信息
|
||
const profile = await client.users.me.getProfile();
|
||
|
||
if (profile.result !== 'success') {
|
||
throw new Error(`API Key验证失败: ${profile.msg || '未知错误'}`);
|
||
}
|
||
|
||
const clientInstance: ZulipClientInstance = {
|
||
userId,
|
||
config,
|
||
client,
|
||
lastEventId: -1,
|
||
createdAt: new Date(),
|
||
lastActivity: new Date(),
|
||
isValid: true,
|
||
};
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.log('Zulip客户端创建成功', {
|
||
operation: 'createClient',
|
||
userId,
|
||
realm: config.realm,
|
||
userEmail: profile.email,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return clientInstance;
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('创建Zulip客户端失败', {
|
||
operation: 'createClient',
|
||
userId,
|
||
realm: config.realm,
|
||
error: err.message,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
throw new Error(`创建Zulip客户端失败: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证API Key有效性
|
||
*
|
||
* 功能描述:
|
||
* 通过调用Zulip API验证API Key是否有效
|
||
*
|
||
* @param clientInstance Zulip客户端实例
|
||
* @returns Promise<boolean> API Key是否有效
|
||
*/
|
||
async validateApiKey(clientInstance: ZulipClientInstance): Promise<boolean> {
|
||
this.logger.log('验证API Key有效性', {
|
||
operation: 'validateApiKey',
|
||
userId: clientInstance.userId,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
const profile = await clientInstance.client.users.me.getProfile();
|
||
const isValid = profile.result === 'success';
|
||
|
||
clientInstance.isValid = isValid;
|
||
clientInstance.lastActivity = new Date();
|
||
|
||
this.logger.log('API Key验证完成', {
|
||
operation: 'validateApiKey',
|
||
userId: clientInstance.userId,
|
||
isValid,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return isValid;
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
|
||
this.logger.error('API Key验证失败', {
|
||
operation: 'validateApiKey',
|
||
userId: clientInstance.userId,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
clientInstance.isValid = false;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送消息到指定Stream/Topic
|
||
*
|
||
* 功能描述:
|
||
* 使用Zulip客户端发送消息到指定的Stream和Topic
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 验证客户端实例有效性
|
||
* 2. 构建消息请求参数
|
||
* 3. 调用Zulip API发送消息
|
||
* 4. 处理响应并返回结果
|
||
*
|
||
* @param clientInstance Zulip客户端实例
|
||
* @param stream 目标Stream名称
|
||
* @param topic 目标Topic名称
|
||
* @param content 消息内容
|
||
* @returns Promise<SendMessageResult> 发送结果
|
||
*/
|
||
async sendMessage(
|
||
clientInstance: ZulipClientInstance,
|
||
stream: string,
|
||
topic: string,
|
||
content: string,
|
||
): Promise<SendMessageResult> {
|
||
const startTime = Date.now();
|
||
|
||
this.logger.log('发送消息到Zulip', {
|
||
operation: 'sendMessage',
|
||
userId: clientInstance.userId,
|
||
stream,
|
||
topic,
|
||
contentLength: content.length,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 1. 验证客户端有效性
|
||
if (!clientInstance.isValid) {
|
||
throw new Error('Zulip客户端无效');
|
||
}
|
||
|
||
// 2. 构建消息参数
|
||
const params = {
|
||
type: 'stream',
|
||
to: stream,
|
||
subject: topic,
|
||
content: content,
|
||
};
|
||
|
||
// 3. 发送消息
|
||
const response = await clientInstance.client.messages.send(params);
|
||
|
||
// 4. 更新最后活动时间
|
||
clientInstance.lastActivity = new Date();
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
if (response.result === 'success') {
|
||
this.logger.log('消息发送成功', {
|
||
operation: 'sendMessage',
|
||
userId: clientInstance.userId,
|
||
stream,
|
||
topic,
|
||
messageId: response.id,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
messageId: response.id,
|
||
};
|
||
} else {
|
||
this.logger.warn('消息发送失败', {
|
||
operation: 'sendMessage',
|
||
userId: clientInstance.userId,
|
||
stream,
|
||
topic,
|
||
error: response.msg,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: false,
|
||
error: response.msg || '消息发送失败',
|
||
};
|
||
}
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('发送消息异常', {
|
||
operation: 'sendMessage',
|
||
userId: clientInstance.userId,
|
||
stream,
|
||
topic,
|
||
error: err.message,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return {
|
||
success: false,
|
||
error: err.message,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注册事件队列
|
||
*
|
||
* 功能描述:
|
||
* 向Zulip服务器注册事件队列,用于接收消息通知
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 验证客户端实例有效性
|
||
* 2. 构建队列注册参数
|
||
* 3. 调用Zulip API注册队列
|
||
* 4. 保存队列ID到客户端实例
|
||
*
|
||
* @param clientInstance Zulip客户端实例
|
||
* @param eventTypes 要订阅的事件类型列表
|
||
* @returns Promise<RegisterQueueResult> 注册结果
|
||
*/
|
||
async registerQueue(
|
||
clientInstance: ZulipClientInstance,
|
||
eventTypes: string[] = ['message'],
|
||
): Promise<RegisterQueueResult> {
|
||
const startTime = Date.now();
|
||
|
||
this.logger.log('注册Zulip事件队列', {
|
||
operation: 'registerQueue',
|
||
userId: clientInstance.userId,
|
||
eventTypes,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 1. 验证客户端有效性
|
||
if (!clientInstance.isValid) {
|
||
throw new Error('Zulip客户端无效');
|
||
}
|
||
|
||
// 2. 构建注册参数
|
||
const params = {
|
||
event_types: eventTypes,
|
||
};
|
||
|
||
// 3. 注册队列
|
||
const response = await clientInstance.client.queues.register(params);
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
if (response.result === 'success') {
|
||
// 4. 保存队列信息
|
||
clientInstance.queueId = response.queue_id;
|
||
clientInstance.lastEventId = response.last_event_id;
|
||
clientInstance.lastActivity = new Date();
|
||
|
||
this.logger.log('事件队列注册成功', {
|
||
operation: 'registerQueue',
|
||
userId: clientInstance.userId,
|
||
queueId: response.queue_id,
|
||
lastEventId: response.last_event_id,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
queueId: response.queue_id,
|
||
lastEventId: response.last_event_id,
|
||
};
|
||
} else {
|
||
this.logger.warn('事件队列注册失败', {
|
||
operation: 'registerQueue',
|
||
userId: clientInstance.userId,
|
||
error: response.msg,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: false,
|
||
error: response.msg || '事件队列注册失败',
|
||
};
|
||
}
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('注册事件队列异常', {
|
||
operation: 'registerQueue',
|
||
userId: clientInstance.userId,
|
||
error: err.message,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return {
|
||
success: false,
|
||
error: err.message,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注销事件队列
|
||
*
|
||
* 功能描述:
|
||
* 注销已注册的Zulip事件队列
|
||
*
|
||
* @param clientInstance Zulip客户端实例
|
||
* @returns Promise<boolean> 是否成功注销
|
||
*/
|
||
async deregisterQueue(clientInstance: ZulipClientInstance): Promise<boolean> {
|
||
this.logger.log('注销Zulip事件队列', {
|
||
operation: 'deregisterQueue',
|
||
userId: clientInstance.userId,
|
||
queueId: clientInstance.queueId,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
if (!clientInstance.queueId) {
|
||
this.logger.log('无事件队列需要注销', {
|
||
operation: 'deregisterQueue',
|
||
userId: clientInstance.userId,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const response = await clientInstance.client.queues.deregister({
|
||
queue_id: clientInstance.queueId,
|
||
});
|
||
|
||
if (response.result === 'success') {
|
||
clientInstance.queueId = undefined;
|
||
clientInstance.lastEventId = -1;
|
||
|
||
this.logger.log('事件队列注销成功', {
|
||
operation: 'deregisterQueue',
|
||
userId: clientInstance.userId,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return true;
|
||
} else {
|
||
this.logger.warn('事件队列注销失败', {
|
||
operation: 'deregisterQueue',
|
||
userId: clientInstance.userId,
|
||
error: response.msg,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return false;
|
||
}
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
|
||
// 如果是JSON解析错误,说明队列可能已经过期或被删除,这是正常的
|
||
if (err.message.includes('invalid json response') || err.message.includes('Unexpected token')) {
|
||
this.logger.debug('事件队列可能已过期,跳过注销', {
|
||
operation: 'deregisterQueue',
|
||
userId: clientInstance.userId,
|
||
queueId: clientInstance.queueId,
|
||
});
|
||
|
||
// 清理本地状态
|
||
clientInstance.queueId = undefined;
|
||
clientInstance.lastEventId = -1;
|
||
return true;
|
||
}
|
||
|
||
this.logger.error('注销事件队列异常', {
|
||
operation: 'deregisterQueue',
|
||
userId: clientInstance.userId,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
// 即使注销失败,也清理本地状态
|
||
clientInstance.queueId = undefined;
|
||
clientInstance.lastEventId = -1;
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取事件队列中的事件
|
||
*
|
||
* 功能描述:
|
||
* 从Zulip事件队列中获取新事件
|
||
*
|
||
* @param clientInstance Zulip客户端实例
|
||
* @param dontBlock 是否不阻塞等待新事件
|
||
* @returns Promise<GetEventsResult> 获取结果
|
||
*/
|
||
async getEvents(
|
||
clientInstance: ZulipClientInstance,
|
||
dontBlock: boolean = false,
|
||
): Promise<GetEventsResult> {
|
||
this.logger.debug('获取Zulip事件', {
|
||
operation: 'getEvents',
|
||
userId: clientInstance.userId,
|
||
queueId: clientInstance.queueId,
|
||
lastEventId: clientInstance.lastEventId,
|
||
dontBlock,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
if (!clientInstance.queueId) {
|
||
throw new Error('事件队列未注册');
|
||
}
|
||
|
||
const params = {
|
||
queue_id: clientInstance.queueId,
|
||
last_event_id: clientInstance.lastEventId,
|
||
dont_block: dontBlock,
|
||
};
|
||
|
||
const response = await clientInstance.client.events.retrieve(params);
|
||
|
||
if (response.result === 'success') {
|
||
// 更新最后事件ID
|
||
if (response.events && response.events.length > 0) {
|
||
const lastEvent = response.events[response.events.length - 1];
|
||
clientInstance.lastEventId = lastEvent.id;
|
||
}
|
||
|
||
clientInstance.lastActivity = new Date();
|
||
|
||
this.logger.debug('获取事件成功', {
|
||
operation: 'getEvents',
|
||
userId: clientInstance.userId,
|
||
eventCount: response.events?.length || 0,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
events: response.events || [],
|
||
};
|
||
} else {
|
||
this.logger.warn('获取事件失败', {
|
||
operation: 'getEvents',
|
||
userId: clientInstance.userId,
|
||
error: response.msg,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: false,
|
||
error: response.msg || '获取事件失败',
|
||
};
|
||
}
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
|
||
this.logger.error('获取事件异常', {
|
||
operation: 'getEvents',
|
||
userId: clientInstance.userId,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return {
|
||
success: false,
|
||
error: err.message,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 销毁客户端实例
|
||
*
|
||
* 功能描述:
|
||
* 清理客户端资源,注销事件队列
|
||
*
|
||
* @param clientInstance Zulip客户端实例
|
||
* @returns Promise<void>
|
||
*/
|
||
async destroyClient(clientInstance: ZulipClientInstance): Promise<void> {
|
||
this.logger.log('销毁Zulip客户端', {
|
||
operation: 'destroyClient',
|
||
userId: clientInstance.userId,
|
||
queueId: clientInstance.queueId,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 注销事件队列
|
||
if (clientInstance.queueId) {
|
||
await this.deregisterQueue(clientInstance);
|
||
}
|
||
|
||
// 标记客户端为无效
|
||
clientInstance.isValid = false;
|
||
clientInstance.client = null;
|
||
|
||
this.logger.log('Zulip客户端销毁完成', {
|
||
operation: 'destroyClient',
|
||
userId: clientInstance.userId,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
|
||
this.logger.error('销毁Zulip客户端异常', {
|
||
operation: 'destroyClient',
|
||
userId: clientInstance.userId,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
// 即使出错也标记为无效
|
||
clientInstance.isValid = false;
|
||
clientInstance.client = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证配置参数
|
||
*
|
||
* @param config Zulip客户端配置
|
||
* @throws Error 当配置无效时
|
||
* @private
|
||
*/
|
||
private validateConfig(config: ZulipClientConfig): void {
|
||
if (!config.username || typeof config.username !== 'string') {
|
||
throw new Error('无效的username配置');
|
||
}
|
||
|
||
if (!config.apiKey || typeof config.apiKey !== 'string') {
|
||
throw new Error('无效的apiKey配置');
|
||
}
|
||
|
||
if (!config.realm || typeof config.realm !== 'string') {
|
||
throw new Error('无效的realm配置');
|
||
}
|
||
|
||
// 验证realm是否为有效URL
|
||
try {
|
||
new URL(config.realm);
|
||
} catch {
|
||
throw new Error('realm必须是有效的URL');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 动态加载zulip-js模块
|
||
*
|
||
* @returns Promise<any> zulip-js初始化函数
|
||
* @private
|
||
*/
|
||
private async loadZulipModule(): Promise<any> {
|
||
try {
|
||
// 使用动态导入加载zulip-js
|
||
const zulipModule = await import('zulip-js');
|
||
return zulipModule.default || zulipModule;
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
this.logger.error('加载zulip-js模块失败', {
|
||
operation: 'loadZulipModule',
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
throw new Error(`加载zulip-js模块失败: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
|