424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { createClient, RedisClientType } from 'redis';
|
|
import { logError, logStartup, logWarning } from './Logger';
|
|
|
|
export interface ActiveChatData {
|
|
chatId: string;
|
|
participants: string[];
|
|
lastActivity: Date;
|
|
messageCount: number;
|
|
chatType: 'direct' | 'group' | 'game';
|
|
gameId?: string;
|
|
name?: string;
|
|
}
|
|
|
|
export interface ActiveUserData {
|
|
userId: string;
|
|
activeChatIds: string[];
|
|
lastActivity: Date;
|
|
isOnline: boolean;
|
|
}
|
|
|
|
export class RedisService {
|
|
private static instance: RedisService;
|
|
private client: RedisClientType;
|
|
private isConnected: boolean = false;
|
|
|
|
private constructor() {
|
|
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
|
this.client = createClient({
|
|
url: redisUrl,
|
|
socket: {
|
|
reconnectStrategy: (retries) => Math.min(retries * 50, 500)
|
|
}
|
|
});
|
|
|
|
this.client.on('error', (err) => {
|
|
logError('Redis connection error', err);
|
|
this.isConnected = false;
|
|
});
|
|
|
|
this.client.on('connect', () => {
|
|
logStartup('Redis client connected successfully');
|
|
this.isConnected = true;
|
|
});
|
|
|
|
this.client.on('disconnect', () => {
|
|
logWarning('Redis client disconnected');
|
|
this.isConnected = false;
|
|
});
|
|
}
|
|
|
|
public static getInstance(): RedisService {
|
|
if (!RedisService.instance) {
|
|
RedisService.instance = new RedisService();
|
|
}
|
|
return RedisService.instance;
|
|
}
|
|
|
|
public async connect(): Promise<void> {
|
|
try {
|
|
if (!this.isConnected) {
|
|
await this.client.connect();
|
|
}
|
|
} catch (error) {
|
|
logError('Failed to connect to Redis', error as Error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
public async disconnect(): Promise<void> {
|
|
try {
|
|
if (this.isConnected) {
|
|
await this.client.disconnect();
|
|
}
|
|
} catch (error) {
|
|
logError('Failed to disconnect from Redis', error as Error);
|
|
}
|
|
}
|
|
|
|
public async setActiveChat(chatId: string, chatData: ActiveChatData): Promise<void> {
|
|
try {
|
|
const key = `active_chat:${chatId}`;
|
|
await this.client.hSet(key, {
|
|
chatId: chatData.chatId,
|
|
participants: JSON.stringify(chatData.participants),
|
|
lastActivity: chatData.lastActivity.toISOString(),
|
|
messageCount: chatData.messageCount.toString(),
|
|
chatType: chatData.chatType,
|
|
gameId: chatData.gameId || '',
|
|
name: chatData.name || ''
|
|
});
|
|
|
|
// Set expiration for 1 hour of inactivity
|
|
await this.client.expire(key, 3600);
|
|
} catch (error) {
|
|
logError(`Failed to set active chat ${chatId}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async getActiveChat(chatId: string): Promise<ActiveChatData | null> {
|
|
try {
|
|
const key = `active_chat:${chatId}`;
|
|
const data = await this.client.hGetAll(key);
|
|
|
|
if (!data.chatId) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
chatId: data.chatId,
|
|
participants: JSON.parse(data.participants),
|
|
lastActivity: new Date(data.lastActivity),
|
|
messageCount: parseInt(data.messageCount, 10),
|
|
chatType: data.chatType as 'direct' | 'group' | 'game',
|
|
gameId: data.gameId || undefined,
|
|
name: data.name || undefined
|
|
};
|
|
} catch (error) {
|
|
logError(`Failed to get active chat ${chatId}`, error as Error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async removeActiveChat(chatId: string): Promise<void> {
|
|
try {
|
|
const key = `active_chat:${chatId}`;
|
|
await this.client.del(key);
|
|
} catch (error) {
|
|
logError(`Failed to remove active chat ${chatId}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async getAllActiveChats(): Promise<ActiveChatData[]> {
|
|
try {
|
|
const pattern = 'active_chat:*';
|
|
const keys = await this.client.keys(pattern);
|
|
const chats: ActiveChatData[] = [];
|
|
|
|
for (const key of keys) {
|
|
const data = await this.client.hGetAll(key);
|
|
if (data.chatId) {
|
|
chats.push({
|
|
chatId: data.chatId,
|
|
participants: JSON.parse(data.participants),
|
|
lastActivity: new Date(data.lastActivity),
|
|
messageCount: parseInt(data.messageCount, 10),
|
|
chatType: data.chatType as 'direct' | 'group' | 'game',
|
|
gameId: data.gameId || undefined,
|
|
name: data.name || undefined
|
|
});
|
|
}
|
|
}
|
|
|
|
return chats;
|
|
} catch (error) {
|
|
logError('Failed to get all active chats', error as Error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public async setActiveUser(userId: string, userData: ActiveUserData): Promise<void> {
|
|
try {
|
|
const key = `active_user:${userId}`;
|
|
await this.client.hSet(key, {
|
|
userId: userData.userId,
|
|
activeChatIds: JSON.stringify(userData.activeChatIds),
|
|
lastActivity: userData.lastActivity.toISOString(),
|
|
isOnline: userData.isOnline.toString()
|
|
});
|
|
|
|
// Set expiration for 2 hours
|
|
await this.client.expire(key, 7200);
|
|
} catch (error) {
|
|
logError(`Failed to set active user ${userId}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async getActiveUser(userId: string): Promise<ActiveUserData | null> {
|
|
try {
|
|
const key = `active_user:${userId}`;
|
|
const data = await this.client.hGetAll(key);
|
|
|
|
if (!data.userId) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
userId: data.userId,
|
|
activeChatIds: JSON.parse(data.activeChatIds),
|
|
lastActivity: new Date(data.lastActivity),
|
|
isOnline: data.isOnline === 'true'
|
|
};
|
|
} catch (error) {
|
|
logError(`Failed to get active user ${userId}`, error as Error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async removeActiveUser(userId: string): Promise<void> {
|
|
try {
|
|
const key = `active_user:${userId}`;
|
|
await this.client.del(key);
|
|
} catch (error) {
|
|
logError(`Failed to remove active user ${userId}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async addUserToChat(userId: string, chatId: string): Promise<void> {
|
|
try {
|
|
const userData = await this.getActiveUser(userId) || {
|
|
userId,
|
|
activeChatIds: [],
|
|
lastActivity: new Date(),
|
|
isOnline: true
|
|
};
|
|
|
|
if (!userData.activeChatIds.includes(chatId)) {
|
|
userData.activeChatIds.push(chatId);
|
|
userData.lastActivity = new Date();
|
|
await this.setActiveUser(userId, userData);
|
|
}
|
|
} catch (error) {
|
|
logError(`Failed to add user ${userId} to chat ${chatId}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async removeUserFromChat(userId: string, chatId: string): Promise<void> {
|
|
try {
|
|
const userData = await this.getActiveUser(userId);
|
|
if (userData) {
|
|
userData.activeChatIds = userData.activeChatIds.filter(id => id !== chatId);
|
|
userData.lastActivity = new Date();
|
|
await this.setActiveUser(userId, userData);
|
|
}
|
|
} catch (error) {
|
|
logError(`Failed to remove user ${userId} from chat ${chatId}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async getUserActiveChats(userId: string): Promise<string[]> {
|
|
try {
|
|
const userData = await this.getActiveUser(userId);
|
|
return userData?.activeChatIds || [];
|
|
} catch (error) {
|
|
logError(`Failed to get active chats for user ${userId}`, error as Error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public async updateChatActivity(chatId: string, messageCount?: number): Promise<void> {
|
|
try {
|
|
const chatData = await this.getActiveChat(chatId);
|
|
if (chatData) {
|
|
chatData.lastActivity = new Date();
|
|
if (messageCount !== undefined) {
|
|
chatData.messageCount = messageCount;
|
|
}
|
|
await this.setActiveChat(chatId, chatData);
|
|
}
|
|
} catch (error) {
|
|
logError(`Failed to update chat activity ${chatId}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async getInactiveChats(inactivityMinutes: number): Promise<string[]> {
|
|
try {
|
|
const cutoffTime = new Date(Date.now() - inactivityMinutes * 60 * 1000);
|
|
const allChats = await this.getAllActiveChats();
|
|
|
|
return allChats
|
|
.filter(chat => chat.lastActivity < cutoffTime)
|
|
.map(chat => chat.chatId);
|
|
} catch (error) {
|
|
logError('Failed to get inactive chats', error as Error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public async cleanupInactiveChats(inactivityMinutes: number): Promise<string[]> {
|
|
try {
|
|
const inactiveChats = await this.getInactiveChats(inactivityMinutes);
|
|
|
|
for (const chatId of inactiveChats) {
|
|
await this.removeActiveChat(chatId);
|
|
}
|
|
|
|
return inactiveChats;
|
|
} catch (error) {
|
|
logError('Failed to cleanup inactive chats', error as Error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public async ping(): Promise<boolean> {
|
|
try {
|
|
const result = await this.client.ping();
|
|
return result === 'PONG';
|
|
} catch (error) {
|
|
logError('Redis ping failed', error as Error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public isRedisConnected(): boolean {
|
|
return this.isConnected;
|
|
}
|
|
|
|
// Generic Redis methods for game data
|
|
public async get(key: string): Promise<string | null> {
|
|
try {
|
|
const value = await this.client.get(key);
|
|
// Refresh TTL on access for game-related keys
|
|
if (value && this.isGameRelatedKey(key)) {
|
|
await this.client.expire(key, 1800); // Reset to 30 minutes
|
|
}
|
|
return value;
|
|
} catch (error) {
|
|
logError(`Failed to get key ${key}`, error as Error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async set(key: string, value: string): Promise<void> {
|
|
try {
|
|
await this.client.set(key, value);
|
|
// Auto-expire game-related keys after 30 minutes
|
|
if (this.isGameRelatedKey(key)) {
|
|
await this.client.expire(key, 1800); // 30 minutes
|
|
}
|
|
} catch (error) {
|
|
logError(`Failed to set key ${key}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async setWithExpiry(key: string, value: string, expirySeconds: number): Promise<void> {
|
|
try {
|
|
await this.client.setEx(key, expirySeconds, value);
|
|
} catch (error) {
|
|
logError(`Failed to set key ${key} with expiry`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async del(key: string): Promise<void> {
|
|
try {
|
|
await this.client.del(key);
|
|
} catch (error) {
|
|
logError(`Failed to delete key ${key}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async setAdd(key: string, member: string): Promise<void> {
|
|
try {
|
|
await this.client.sAdd(key, member);
|
|
// Refresh TTL for game-related keys
|
|
if (this.isGameRelatedKey(key)) {
|
|
await this.client.expire(key, 1800); // Reset to 30 minutes
|
|
}
|
|
} catch (error) {
|
|
logError(`Failed to add member to set ${key}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async setRemove(key: string, member: string): Promise<void> {
|
|
try {
|
|
await this.client.sRem(key, member);
|
|
// Refresh TTL for game-related keys
|
|
if (this.isGameRelatedKey(key)) {
|
|
await this.client.expire(key, 1800); // Reset to 30 minutes
|
|
}
|
|
} catch (error) {
|
|
logError(`Failed to remove member from set ${key}`, error as Error);
|
|
}
|
|
}
|
|
|
|
public async setMembers(key: string): Promise<string[]> {
|
|
try {
|
|
const members = await this.client.sMembers(key);
|
|
// Refresh TTL on access for game-related keys
|
|
if (members.length > 0 && this.isGameRelatedKey(key)) {
|
|
await this.client.expire(key, 1800); // Reset to 30 minutes
|
|
}
|
|
return members;
|
|
} catch (error) {
|
|
logError(`Failed to get members of set ${key}`, error as Error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public async exists(key: string): Promise<boolean> {
|
|
try {
|
|
const result = await this.client.exists(key);
|
|
// Refresh TTL on access for game-related keys
|
|
if (result === 1 && this.isGameRelatedKey(key)) {
|
|
await this.client.expire(key, 1800); // Reset to 30 minutes
|
|
}
|
|
return result === 1;
|
|
} catch (error) {
|
|
logError(`Failed to check existence of key ${key}`, error as Error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a key is game-related and should have auto-expiration
|
|
* Game-related patterns: gameplay:*, game:*, game_*, board:*, game_pending_card:*, etc.
|
|
*/
|
|
private isGameRelatedKey(key: string): boolean {
|
|
const gamePatterns = [
|
|
'gameplay:',
|
|
'game:',
|
|
'game_',
|
|
'board:',
|
|
'game_pending_card:',
|
|
'game_pending_decision:',
|
|
'game_player_extra_turns:',
|
|
'game_player_turns_to_lose:',
|
|
'game_positions:',
|
|
'game_ready:',
|
|
'game_room:',
|
|
'active_game:'
|
|
];
|
|
return gamePatterns.some(pattern => key.startsWith(pattern));
|
|
}
|
|
}
|