diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b604a973a..66c3a3022 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,5 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" } datasource db { @@ -31,6 +31,11 @@ type userBan { reason String } +type RecentLobbyData { + lobbyId String + timestamp Int +} + enum BlockWordAction { BLOCK_MESSAGE BLACKLIST @@ -171,7 +176,7 @@ model UserData { locale String? lastVoted DateTime? banMeta userBan? - mentionOnReply Boolean? @default(false) + mentionOnReply Boolean @default(true) acceptedRules Boolean @default(false) infractions UserInfraction[] } @@ -185,4 +190,21 @@ model LobbyChatHistory { date DateTime @default(now()) @@index([serverId, channelId, lobbyId]) -} \ No newline at end of file +} + +model ServerHistory { + id String @id @default(auto()) @map("_id") @db.ObjectId + serverId String @unique + recentLobbies RecentLobbyData[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model ServerPreference { + id String @id @default(auto()) @map("_id") @db.ObjectId + serverId String @unique + premiumStatus Boolean @default(false) + maxServersInLobby Int @default(3) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/commands/prefix/connect.ts b/src/commands/prefix/connect.ts index 6b9b01b55..e5667300f 100644 --- a/src/commands/prefix/connect.ts +++ b/src/commands/prefix/connect.ts @@ -1,5 +1,10 @@ import BasePrefixCommand, { CommandData } from '#main/core/BasePrefixCommand.js'; -import { msToReadable } from '#main/utils/Utils.js'; +import { LobbyManager } from '#main/managers/LobbyManager.js'; +import { MatchingService } from '#main/services/LobbyMatchingService.js'; +import { emojis } from '#main/utils/Constants.js'; +import db from '#main/utils/Db.js'; +import { t } from '#main/utils/Locale.js'; +import { getOrCreateWebhook } from '#main/utils/Utils.js'; import { stripIndents } from 'common-tags'; import { Message, PermissionsBitField } from 'discord.js'; @@ -8,48 +13,115 @@ export default class BlacklistPrefixCommand extends BasePrefixCommand { name: 'connect', description: 'Connect to a random lobby', category: 'Moderation', - usage: 'connect', - examples: ['c', 'call'], + usage: 'connect ` [minimum number of servers] `', + examples: ['c', 'call', 'call 3'], aliases: ['call', 'c', 'conn', 'joinlobby', 'jl'], - requiredBotPermissions: new PermissionsBitField(['SendMessages', 'EmbedLinks', 'ReadMessageHistory']), + requiredBotPermissions: new PermissionsBitField([ + 'SendMessages', + 'EmbedLinks', + 'ReadMessageHistory', + ]), dbPermission: false, requiredArgs: 0, }; - protected async run(message: Message) { - // TODO: uncomment this after final release - // const voteLimiter = new VoteLimitManager('editMsg', message.author.id, message.client.userManager); - - // if (await voteLimiter.hasExceededLimit()) { - // await message.reply({ - // content: `${emojis.topggSparkles} Daily limit for lobbies reached. [Vote on top.gg](${Constants.Links.Vote}) to chat non-stop for the next 12 hours! Or join a [permanent hub](https://interchat.fun/hubs) to chat without voting.`, - // }); - // return; - // } - - // check if already connected - const { lobbyService } = message.client; - const alreadyConnected = await lobbyService.getChannelLobby(message.channelId); - if (alreadyConnected) { - await message.reply('This server is already connected to a lobby!'); + private readonly lobbyManager = new LobbyManager(); + private readonly matching = new MatchingService(); + + protected async run(message: Message, args: string[]) { + const minServers = parseInt(args[0], 10) || 2; + + const alreadyInLobby = await this.lobbyManager.getLobbyByChannelId(message.channelId); + if (alreadyInLobby) { + await message.reply('You are already chatting in a lobby. Please leave it first.'); + return; + } + + const inWaitingPool = await this.lobbyManager.getChannelFromWaitingPool(message.guildId); + if (inWaitingPool) { + await message.reply( + stripIndents` + -# **💡 Did you know?** You can change the minimum number of servers required to find a match: \`c!call 2\`. It can be faster sometimes! + You are already in the waiting pool for a lobby. + -# You will be notified once a lobby is found. + `, + ); + return; + } + + const webhook = await getOrCreateWebhook( + message.channel, + 'https://i.imgur.com/80nqtSg.png', + 'InterChat Lobby', + ); + if (!webhook) { + await message.reply( + t('errors.missingPermissions', 'en', { emoji: emojis.no, permissions: 'Manage Webhooks' }), + ); return; } - const { queued, lobby } = await lobbyService.connectChannel(message.guildId, message.channelId); + const serverId = message.guildId; + const channelId = message.channelId; + + // TODO: a way to modify this + const serverPrefs = await db.serverPreference.findUnique({ + where: { serverId }, + }); + + const preferences = { + premiumStatus: serverPrefs?.premiumStatus ?? false, + maxServersInLobby: minServers ?? 2, + }; + + // Add server to waiting pool for matching + await this.lobbyManager.addToWaitingPool( + { serverId, channelId, webhookUrl: webhook.url }, + preferences, + ); - if (lobby) { - await message.reply(stripIndents` - Connected to a lobby with ${lobby.connections.length} server(s)! - Activity level: ${lobby.activityLevel} messages/5min - -# Messages in this channel will be shared with other servers. - `); + // Set timeout for 5 minutes + setTimeout( + async () => { + await this.lobbyManager.removeFromWaitingPool(serverId); + await message.reply('Please try again later. No lobbies were found for this server.'); + }, + 5 * 60 * 1000, // 5 minutes + ); + + const match = await this.matching.findMatch(serverId, preferences); + if (match) { + const lobbyId = await this.lobbyManager.createLobby([ + { serverId, channelId, webhookUrl: webhook.url }, + match, + ]); + + // Update server histories + await this.updateServerHistory(serverId, lobbyId); + await this.updateServerHistory(match.serverId, lobbyId); } - else if (queued) { - const info = await lobbyService.getPoolInfo(message.channelId); - await message.channel.send(stripIndents` + else { + await message.reply( + stripIndents` + -# **💡 Did you know?** You can change the minimum number of servers required to find a match: \`c!call 2\`. It can be faster sometimes! Finding a lobby for this server... Hang tight! - -# You will be notified once a lobby is found. Estimated wait time: ${info.estimatedWaitTime ? msToReadable(info.estimatedWaitTime) : 'now'}. - `); + -# You will be notified once a lobby is found. + `, + ); } } + private async updateServerHistory(serverId: string, lobbyId: string): Promise { + await db.serverHistory.upsert({ + where: { serverId }, + update: { + recentLobbies: { + push: { lobbyId, timestamp: Date.now() }, + }, + }, + create: { + serverId, + recentLobbies: [{ lobbyId, timestamp: Date.now() }], + }, + }); + } } diff --git a/src/commands/prefix/disconnect.ts b/src/commands/prefix/disconnect.ts index 02b273275..7744bb17c 100644 --- a/src/commands/prefix/disconnect.ts +++ b/src/commands/prefix/disconnect.ts @@ -1,4 +1,5 @@ import BasePrefixCommand, { CommandData } from '#main/core/BasePrefixCommand.js'; +import { LobbyManager } from '#main/managers/LobbyManager.js'; import { emojis } from '#main/utils/Constants.js'; import { Message, PermissionsBitField } from 'discord.js'; @@ -15,24 +16,20 @@ export default class BlacklistPrefixCommand extends BasePrefixCommand { requiredArgs: 0, }; + private readonly lobbyManager = new LobbyManager(); + protected async run(message: Message) { - const { lobbyService } = message.client; - const alreadyConnected = await lobbyService.getChannelLobby(message.channelId); + const alreadyConnected = await this.lobbyManager.getLobbyByChannelId(message.channelId); if (alreadyConnected) { - await lobbyService.disconnectChannel(message.channelId); - + await this.lobbyManager.removeServerFromLobby(alreadyConnected.id, message.guildId); } else { - const poolInfo = await lobbyService.getPoolInfo(message.channelId); - if (!poolInfo.position) { - await message.reply(`${emojis.no} This channel is not connected to a lobby!`); - return; - } - - await lobbyService.removeFromPoolByChannelId(message.channelId); + await this.lobbyManager.removeFromWaitingPool(message.guildId); + await message.reply(`${emojis.disconnect} Not connected to any lobby. Removed from waiting pool if exists.`); + return; } - await message.reply(`${emojis.disconnect} You have disconnected from the lobby.`); + await message.reply(`${emojis.disconnect} You have left the lobby.`); } } diff --git a/src/commands/slash/Information/about.ts b/src/commands/slash/Information/about.ts index 9b9d7e91e..07b1f070d 100644 --- a/src/commands/slash/Information/about.ts +++ b/src/commands/slash/Information/about.ts @@ -36,7 +36,7 @@ export default class About extends BaseCommand { `, ) .setFooter({ - text: ` InterChat v${Constants.ProjectVersion} • Made with ❤️ by the InterChat Team`, + text: ` InterChat v${interaction.client.version} • Made with ❤️ by the InterChat Team`, }); const linkButtons = new ActionRowBuilder().addComponents( @@ -106,7 +106,7 @@ export default class About extends BaseCommand { `, ) .setFooter({ - text: ` InterChat v${Constants.ProjectVersion} • Made with ❤️ by the InterChat Team`, + text: ` InterChat v${interaction.client.version} • Made with ❤️ by the InterChat Team`, }); await interaction.editReply({ embeds: [creditsEmbed] }); diff --git a/src/commands/slash/Main/setup.ts b/src/commands/slash/Main/setup.ts index 6606a9af6..0bf198b4a 100644 --- a/src/commands/slash/Main/setup.ts +++ b/src/commands/slash/Main/setup.ts @@ -50,8 +50,8 @@ export default class SetupCommand extends BaseCommand { public async execute(interaction: ChatInputCommandInteraction) { const subcommand = interaction.options.getSubcommand(true); - if (subcommand === 'lobby') return this.setupLobby(interaction); - else if (subcommand === 'interchat') return this.setupHub(interaction); + if (subcommand === 'lobby') await this.setupLobby(interaction); + else if (subcommand === 'interchat') await this.setupHub(interaction); } private async setupLobby(interaction: ChatInputCommandInteraction): Promise { diff --git a/src/core/BaseClient.ts b/src/core/BaseClient.ts index 11399f35b..da1a4c4b5 100644 --- a/src/core/BaseClient.ts +++ b/src/core/BaseClient.ts @@ -4,8 +4,6 @@ import type { InteractionFunction } from '#main/decorators/RegisterInteractionHa import AntiSpamManager from '#main/managers/AntiSpamManager.js'; import UserDbManager from '#main/managers/UserDbManager.js'; import EventLoader from '#main/modules/Loaders/EventLoader.js'; -import LobbyNotifier from '#main/modules/LobbyNotifier.js'; -import ChatLobbyService from '#main/services/ChatLobbyService.js'; import CooldownService from '#main/services/CooldownService.js'; import Scheduler from '#main/services/SchedulerService.js'; import type { RemoveMethods } from '#types/CustomClientProps.d.ts'; @@ -17,11 +15,11 @@ import { ClusterClient, getInfo } from 'discord-hybrid-sharding'; import { type Guild, type Snowflake, - ActivityType, Client, Collection, GatewayIntentBits, Options, + Sweepers, } from 'discord.js'; export default class InterChatClient extends Client { @@ -29,22 +27,19 @@ export default class InterChatClient extends Client { private readonly scheduler = new Scheduler(); - public readonly description = 'The only cross-server chatting bot you\'ll ever need.'; - public readonly version = Constants.ProjectVersion; - - public readonly reactionCooldowns = new Collection(); public readonly commands = new Collection(); public readonly interactions = new Collection(); public readonly prefixCommands = new Collection(); + public readonly version = Constants.ProjectVersion; + public readonly reactionCooldowns = new Collection(); public readonly userManager = new UserDbManager(); public readonly cluster = new ClusterClient(this); public readonly eventLoader = new EventLoader(this); public readonly commandCooldowns = new CooldownService(); - public readonly lobbyService = new ChatLobbyService(new LobbyNotifier(this.cluster)); public readonly antiSpamManager = new AntiSpamManager({ spamThreshold: 4, - timeWindow: 5000, + timeWindow: 3000, spamCountExpirySecs: 60, }); @@ -53,10 +48,36 @@ export default class InterChatClient extends Client { shards: getInfo().SHARD_LIST, // An array of shards that will get spawned shardCount: getInfo().TOTAL_SHARDS, // Total number of shards makeCache: Options.cacheWithLimits({ - MessageManager: 200, - PresenceManager: 0, + ThreadManager: { + maxSize: 1000, + }, ReactionManager: 200, + PresenceManager: 0, + AutoModerationRuleManager: 0, + VoiceStateManager: 0, + GuildScheduledEventManager: 0, + ApplicationCommandManager: 0, + BaseGuildEmojiManager: 0, + StageInstanceManager: 0, + GuildStickerManager: 0, + ThreadMemberManager: 0, + GuildInviteManager: 0, + GuildEmojiManager: 0, + GuildBanManager: 0, }), + sweepers: { + messages: { + interval: 60, + filter: Sweepers.filterByLifetime({ + lifetime: 150, + getComparisonTimestamp: (message) => message.createdTimestamp, + }), + }, + threads: { + interval: 60, + filter: () => (thread) => Boolean(thread.archived), + }, + }, intents: [ GatewayIntentBits.MessageContent, GatewayIntentBits.Guilds, @@ -65,15 +86,8 @@ export default class InterChatClient extends Client { GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.GuildWebhooks, ], - presence: { - status: 'online', - activities: [ - { - state: '🔗 Watching over 700+ cross-server chats', - name: 'custom', - type: ActivityType.Custom, - }, - ], + allowedMentions: { + repliedUser: false, }, }); } diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index cac20a4bc..69b4a463e 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,5 +1,6 @@ import BaseEventListener from '#main/core/BaseEventListener.js'; import { showRulesScreening } from '#main/interactions/RulesScreening.js'; +import { LobbyManager } from '#main/managers/LobbyManager.js'; import { ConnectionService } from '#main/services/ConnectionService.js'; import { MessageProcessor } from '#main/services/MessageProcessor.js'; import Constants from '#main/utils/Constants.js'; @@ -70,8 +71,8 @@ export default class MessageCreate extends BaseEventListener<'messageCreate'> { private async handleChatMessage(message: Message) { // Handle lobby messages - const { lobbyService } = message.client; - const lobby = await lobbyService.getChannelLobby(message.channelId); + const lobbyManager = new LobbyManager(); + const lobby = await lobbyManager.getLobbyByChannelId(message.channelId); if (lobby) { await this.messageProcessor.processLobbyMessage(message, lobby); diff --git a/src/managers/LobbyManager.ts b/src/managers/LobbyManager.ts index 520dcd5e3..5538408d2 100644 --- a/src/managers/LobbyManager.ts +++ b/src/managers/LobbyManager.ts @@ -1,45 +1,149 @@ -import type { ChannelPreferences, ChatLobby } from '#types/ChatLobby.d.ts'; -import { RedisKeys } from '#main/utils/Constants.js'; +import { EncryptionService } from '#main/services/EncryptionService.js'; +import LobbyNotifier from '#main/services/LobbyNotifierService.js'; import getRedis from '#main/utils/Redis.js'; +import type { LobbyData, LobbyServer, QueuedChannel, ServerPreferences } from '#types/ChatLobby.d.ts'; +import crypto from 'crypto'; import { Redis } from 'ioredis'; -export default class LobbyManager { +export class LobbyManager { private readonly redis: Redis; - private readonly CHAT_LOBBY_KEY = RedisKeys.ChatLobby; - private readonly CHANNEL_MAP_KEY = RedisKeys.ChannelMap; - private readonly PREFS_KEY = RedisKeys.ChannelPrefs; + private readonly encryption: EncryptionService; + private readonly notifier: LobbyNotifier; + constructor() { + this.redis = getRedis(); + this.notifier = new LobbyNotifier(this); + this.encryption = new EncryptionService(); + } + + async addToWaitingPool( + server: Omit, + preferences: ServerPreferences, + ): Promise { + const data = JSON.stringify({ ...server, preferences, timestamp: Date.now() }); + await this.redis.zadd('waiting_pool', Date.now(), data); + } - constructor(redis = getRedis()) { - this.redis = redis; + async getChannelFromWaitingPool(serverId: string): Promise { + const members = await this.redis.zrange('waiting_pool', 0, -1); + for (const member of members) { + const data: QueuedChannel = JSON.parse(member); + if (data.serverId === serverId) { + return data; + } + } + return null; } - async getChatLobby(): Promise { - const groups = await this.redis.get(this.CHAT_LOBBY_KEY); - return groups ? JSON.parse(groups) : []; + async removeFromWaitingPool(serverId: string): Promise { + const members = await this.redis.zrange('waiting_pool', 0, -1); + for (const member of members) { + const data: QueuedChannel = JSON.parse(member); + if (data.serverId === serverId) { + await this.redis.zrem('waiting_pool', member); + break; + } + } } - async setChatLobbies(lobbies: ChatLobby[]): Promise { - await this.redis.set(this.CHAT_LOBBY_KEY, JSON.stringify(lobbies)); + async storeLobbyMessage(lobbyId: string, serverId: string, message: string): Promise { + const messageData = { + serverId, + content: message, + timestamp: Date.now(), + }; + const encrypted = this.encryption.encrypt(JSON.stringify(messageData)); + await this.redis.rpush(`lobby:${lobbyId}:messages`, encrypted); + await this.redis.expire(`lobby:${lobbyId}:messages`, 86400); // 24 hours retention } - async getChannelLobbyId(channelId: string): Promise { - return await this.redis.hget(this.CHANNEL_MAP_KEY, channelId); + async updateLastMessageTimestamp(lobbyId: string, serverId: string): Promise { + const lobby = await this.getLobby(lobbyId); + if (lobby) { + const serverIndex = lobby.servers.findIndex((s) => s.serverId === serverId); + if (serverIndex !== -1) { + lobby.servers[serverIndex].lastMessageTimestamp = Date.now(); + await this.redis.set(`lobby:${lobbyId}`, JSON.stringify(lobby)); + } + } } - async setChannelLobby(channelId: string, groupId: string): Promise { - await this.redis.hset(this.CHANNEL_MAP_KEY, channelId, groupId); + async getLobby(lobbyId: string): Promise { + const data = await this.redis.get(`lobby:${lobbyId}`); + return data ? JSON.parse(data) : null; } - async removeChannelFromLobby(channelId: string): Promise { - await this.redis.hdel(this.CHANNEL_MAP_KEY, channelId); + async createLobby(servers: Omit[]): Promise { + const lobbyId = crypto.randomBytes(16).toString('hex'); + const lobbyData: LobbyData = { + id: lobbyId, + servers: servers.map((s) => ({ ...s, lastMessageTimestamp: Date.now() })), + createdAt: Date.now(), + }; + + // Store the lobby data + await this.redis.set(`lobby:${lobbyId}`, JSON.stringify(lobbyData)); + + // Create channel to lobby mapping for each channel + for (const server of servers) { + await this.redis.set(`channel:${server.channelId}:lobby`, lobbyId); + this.notifier.notifyLobbyCreate(server.channelId, lobbyData); + } + + return lobbyId; } - async getServerPreferences(channelId: string): Promise { - const prefs = await this.redis.hget(this.PREFS_KEY, channelId); - return prefs ? JSON.parse(prefs) : {}; + async getLobbyByChannelId(channelId: string): Promise { + // Get the lobby ID associated with this channel + const lobbyId = await this.redis.get(`channel:${channelId}:lobby`); + if (!lobbyId) return null; + + // Get the actual lobby data + return this.getLobby(lobbyId); } - async setChannelPreferences(channelId: string, prefs: ChannelPreferences): Promise { - await this.redis.hset(this.PREFS_KEY, channelId, JSON.stringify(prefs)); + async removeLobby(lobbyId: string): Promise { + const lobby = await this.getLobby(lobbyId); + if (lobby) { + // Remove channel to lobby mappings + for (const server of lobby.servers) { + await this.redis.del(`channel:${server.channelId}:lobby`); + this.notifier.notifyLobbyDelete(server.channelId); + } + + // Remove the lobby itself + await this.redis.del(`lobby:${lobbyId}`); + // Remove lobby messages + // TODO: This should be done in a background job + await this.redis.del(`lobby:${lobbyId}:messages`); + } + } + + async removeServerFromLobby(lobbyId: string, serverId: string): Promise { + const lobby = await this.getLobby(lobbyId); + if (!lobby) return; + + const serverToRemove = lobby.servers.find((s) => s.serverId === serverId); + if (!serverToRemove) return; + + // Remove the channel to lobby mapping + await this.redis.del(`channel:${serverToRemove.channelId}:lobby`); + + // Update the lobby with remaining servers + const remainingServers = lobby.servers.filter((s) => s.serverId !== serverId); + + if (remainingServers.length <= 1) { + // If only one or no servers remain, remove the entire lobby + await this.removeLobby(lobbyId); + } + else { + // Update the lobby with remaining servers + lobby.servers = remainingServers; + await this.redis.set(`lobby:${lobbyId}`, JSON.stringify(lobby)); + + // Notify other servers in the lobby + remainingServers.forEach((server) => { + this.notifier.notifyChannelDisconnect(lobby, server.channelId); + }); + } } } diff --git a/src/modules/LobbyNotifier.ts b/src/modules/LobbyNotifier.ts index 2e9fed25a..5ab3e82dc 100644 --- a/src/modules/LobbyNotifier.ts +++ b/src/modules/LobbyNotifier.ts @@ -39,7 +39,7 @@ export default class LobbyNotifier { Logger.info(`Channel ${channelId} connected to lobby.`); } - public async notifychannelDisconnect(lobby: ChatLobby, channelId: string) { + public notifychannelDisconnect(lobby: ChatLobby, channelId: string) { lobby.connections.forEach(async (connection) => { if (connection.channelId === channelId) return; await this.sendToChannel(connection.channelId, `-# ${emojis.info} A server disconnected from lobby.`); diff --git a/src/services/ChatLobbyService.ts b/src/services/ChatLobbyService.ts deleted file mode 100644 index 57e4bd5a2..000000000 --- a/src/services/ChatLobbyService.ts +++ /dev/null @@ -1,631 +0,0 @@ -import LobbyManager from '#main/managers/LobbyManager.js'; -import Logger from '#main/utils/Logger.js'; -import type { ChatLobby, ChannelPreferences } from '#main/types/ChatLobby.js'; -import { v4 as uuidv4 } from 'uuid'; -import { Redis } from 'ioredis'; -import getRedis from '#main/utils/Redis.js'; -import db from '#main/utils/Db.js'; -import { Snowflake } from 'discord.js'; -import { RedisKeys } from '#main/utils/Constants.js'; -import LobbyNotifier from '#main/modules/LobbyNotifier.js'; - -export const IDLE_TIMEOUT = 5 * 60 * 1000; -export const ACTIVITY_CHECK_INTERVAL = 60 * 1000; -export const QUEUE_CHECK_INTERVAL = 5 * 1000; // Reduced to 5 seconds for faster matching -export const MAX_LOBBY_SIZE = 3; - -interface QueuedChannel { - serverId: string; - channelId: string; - preferences: ChannelPreferences; - timestamp: number; - priority: number; // New: Priority level for weighted matching -} - -interface MatchingPool { - high: QueuedChannel[]; - medium: QueuedChannel[]; - low: QueuedChannel[]; -} - -export default class ChatLobbyService { - private readonly manager: LobbyManager; - private readonly redis: Redis; - private readonly POOL_KEY = `${RedisKeys.MatchingPool}:`; - private readonly lobbyNotifier: LobbyNotifier; - private isProcessing = false; - - constructor(lobbyNotifier: LobbyNotifier) { - this.redis = getRedis(); - this.manager = new LobbyManager(); - this.lobbyNotifier = lobbyNotifier; - - setInterval(() => this.checkIdleLobbies().catch(Logger.error), ACTIVITY_CHECK_INTERVAL); - setInterval(() => this.processMatchingPool().catch(Logger.error), QUEUE_CHECK_INTERVAL); - } - - private getPoolKey(tier: keyof MatchingPool): string { - return `${this.POOL_KEY}${tier}`; - } - - private async addToPool(tier: keyof MatchingPool, channel: QueuedChannel): Promise { - const key = this.getPoolKey(tier); - await this.redis.zadd(key, channel.timestamp, JSON.stringify(channel)); - } - - private async removeFromPool(tier: keyof MatchingPool, channelId: string): Promise { - const key = this.getPoolKey(tier); - const multi = this.redis.multi(); - - const members = await this.redis.zrange(key, 0, -1); - - for (const member of members) { - const channel = JSON.parse(member) as QueuedChannel; - if (channel.channelId === channelId) { - multi.zrem(key, member); - const results = await multi.exec(); - return results !== null && results[0][1] === 1; - } - } - return false; - } - - private async getPoolMembers(tier: keyof MatchingPool): Promise { - const key = this.getPoolKey(tier); - const members = await this.redis.zrange(key, 0, -1); - return members.map((m) => JSON.parse(m) as QueuedChannel); - } - - async connectChannel( - serverId: string, - channelId: string, - preferences: ChannelPreferences = {}, - ): Promise<{ queued: boolean; lobby?: ChatLobby }> { - const existingLobbyId = await this.manager.getChannelLobbyId(channelId); - if (existingLobbyId) { - throw new Error(`Channel ${channelId} is already connected to a lobby`); - } - - await this.manager.setChannelPreferences(channelId, preferences); - - // Try immediate matching first - const result = await this.tryImmediateMatch(serverId, channelId, preferences); - if (result) { - return { queued: false, lobby: result }; - } - - // Add to appropriate pool based on priority - const priority = this.calculatePriority(preferences); - const queuedChannel: QueuedChannel = { - serverId, - channelId, - preferences, - timestamp: Date.now(), - priority, - }; - - // eslint-disable-next-line no-nested-ternary - const tier = priority >= 8 ? 'high' : priority >= 4 ? 'medium' : 'low'; - await this.addToPool(tier, queuedChannel); - - return { queued: true }; - } - - private calculatePriority(preferences: ChannelPreferences): number { - let priority = 5; // Base priority - - // Adjust based on preferences - if (preferences.premium) priority += 3; - if (preferences.minActivityLevel && preferences.minActivityLevel > 5) priority += 2; - if (preferences.maxWaitTime && preferences.maxWaitTime < 60000) priority += 2; - - return priority; - } - - private async tryImmediateMatch( - serverId: string, - channelId: string, - preferences: ChannelPreferences, - ): Promise { - const lobbies = await this.manager.getChatLobby(); - const currentTime = Date.now(); - - // Find best matching lobby with weighted scoring - const matchingLobby = - lobbies - .filter((lobby) => { - const isActive = currentTime - lobby.lastActivity < IDLE_TIMEOUT; - const hasSpace = lobby.connections.length < (preferences.maxServers || MAX_LOBBY_SIZE); - const meetsActivity = lobby.activityLevel >= (preferences.minActivityLevel || 0); - const serverNotInLobby = !lobby.connections.some((s) => s.serverId === serverId); - - return isActive && hasSpace && meetsActivity && serverNotInLobby; - }) - .map((lobby) => ({ - lobby, - score: this.calculateMatchScore(lobby, preferences), - })) - .sort((a, b) => b.score - a.score) - .find((match) => match.score > 0.7)?.lobby || null; - - if (matchingLobby) { - matchingLobby.connections.push({ - serverId, - channelId, - lastActivity: currentTime, - }); - - await this.manager.setChatLobbies(lobbies); - await this.manager.setChannelLobby(channelId, matchingLobby.id); - - this.lobbyNotifier.notifychannelConnect(channelId, matchingLobby); - return matchingLobby; - } - - return null; - } - - private calculateMatchScore(lobby: ChatLobby, preferences: ChannelPreferences): number { - let score = 0; - - // Size match (0-0.4) - const idealSize = preferences.idealLobbySize || 3; - const sizeDiff = Math.abs(lobby.connections.length + 1 - idealSize); - score += 0.4 * (1 - sizeDiff / MAX_LOBBY_SIZE); - - // Activity match (0-0.3) - const activityMatch = lobby.activityLevel >= (preferences.minActivityLevel || 0); - score += activityMatch ? 0.3 : 0; - - // Recency match (0-0.3) - const recency = 1 - (Date.now() - lobby.lastActivity) / IDLE_TIMEOUT; - score += 0.3 * recency; - - return score; - } - - private async processMatchingPool(): Promise { - if (this.isProcessing) return; - - try { - this.isProcessing = true; - // Process high priority first - await this.processPoolTier('high'); - - // Check if high pool is empty before processing medium - const highCount = await this.redis.zcard(this.getPoolKey('high')); - if (highCount === 0) { - await this.processPoolTier('medium'); - } - - // Check if both pools are empty before processing low - const mediumCount = await this.redis.zcard(this.getPoolKey('medium')); - if (highCount === 0 && mediumCount === 0) { - await this.processPoolTier('low'); - } - } - catch (error) { - Logger.error('Error processing matching pool:', error); - } - finally { - this.isProcessing = false; - } - } - - private async processPoolTier(tier: keyof MatchingPool): Promise { - let pool = await this.getPoolMembers(tier); - if (pool.length < 2) return; - - // Process matches in batches - while (pool.length >= 2) { - const channel1 = pool[0]; - let bestMatch: { channel: QueuedChannel; score: number; index: number } | null = null; - - // Find best match for the first channel in pool - for (let j = 1; j < pool.length; j++) { - const channel2 = pool[j]; - const matchScore = this.calculateChannelMatchScore(channel1, channel2); - if (matchScore > (bestMatch?.score || 0.7)) { - bestMatch = { channel: channel2, score: matchScore, index: j }; - } - } - - if (bestMatch) { - // Create lobby and remove from Redis - await this.createLobbyFromMatch(channel1, bestMatch.channel); - await Promise.all([ - this.removeFromPool(tier, channel1.channelId), - this.removeFromPool(tier, bestMatch.channel.channelId), - ]); - - // Refresh pool data after successful match - pool = await this.getPoolMembers(tier); - } - else { - // If no match found for first channel, remove it from our working set - pool.shift(); - } - } - } - private calculateChannelMatchScore(channel1: QueuedChannel, channel2: QueuedChannel): number { - if (channel1.serverId === channel2.serverId) return 0; - - let score = 0.5; // Base score for valid match - - // Preference alignment (0-0.3) - const prefsMatch = this.preferencesAlign(channel1.preferences, channel2.preferences); - score += prefsMatch ? 0.3 : 0; - - // Wait time factor (0-0.2) - const maxWaitTime = Math.max( - channel1.preferences.maxWaitTime || 300000, - channel2.preferences.maxWaitTime || 300000, - ); - const longestWait = Math.max(Date.now() - channel1.timestamp, Date.now() - channel2.timestamp); - score += 0.2 * Math.min(longestWait / maxWaitTime, 1); - - return score; - } - - private preferencesAlign(prefs1: ChannelPreferences, prefs2: ChannelPreferences): boolean { - // Check basic compatibility - if ( - (prefs1.maxServers && prefs1.maxServers < 2) || - (prefs2.maxServers && prefs2.maxServers < 2) - ) { - return false; - } - - // Activity level compatibility - const activityMatch = - Math.abs((prefs1.minActivityLevel || 0) - (prefs2.minActivityLevel || 0)) <= 2; - - return activityMatch; - } - - private async createLobbyFromMatch( - channel1: QueuedChannel, - channel2: QueuedChannel, - ): Promise { - const currentTime = Date.now(); - const newLobby: ChatLobby = { - id: uuidv4(), - connections: [ - { - serverId: channel1.serverId, - channelId: channel1.channelId, - lastActivity: currentTime, - }, - { - serverId: channel2.serverId, - channelId: channel2.channelId, - lastActivity: currentTime, - }, - ], - lastActivity: currentTime, - activityLevel: 0, - }; - - const lobbys = await this.manager.getChatLobby(); - lobbys.push(newLobby); - - await this.manager.setChannelLobby(channel1.channelId, newLobby.id); - await this.manager.setChannelLobby(channel2.channelId, newLobby.id); - await this.manager.setChatLobbies(lobbys); - - // Emit events for both channels that were matched - this.lobbyNotifier.notifylobbyCreate(channel1.channelId, newLobby); - this.lobbyNotifier.notifylobbyCreate(channel2.channelId, newLobby); - } - - async storeChatHistory( - lobbyId: string, - serverId: Snowflake, - channelId: Snowflake, - users: string[], - ): Promise { - const exists = await db.lobbyChatHistory.findFirst({ - where: { serverId, lobbyId, channelId }, - }); - - if (exists) { - const newUsers = [...new Set([...exists.users, ...users])]; - await db.lobbyChatHistory.update({ - where: { id: exists.id }, - data: { users: { set: newUsers } }, - }); - } - else { - await db.lobbyChatHistory.create({ data: { serverId, channelId, lobbyId, users } }); - } - } - - async updateActivity(lobbyId: string, channelId: string): Promise { - const lobbys = await this.manager.getChatLobby(); - const lobby = lobbys.find((g) => g.id === lobbyId); - - if (!lobby) { - Logger.warn(`Attempted to update activity for non-existent lobby: ${lobbyId}`); - return; - } - - const currentTime = Date.now(); - lobby.lastActivity = currentTime; - - const connection = lobby.connections.find((s) => s.channelId === channelId); - if (connection) { - connection.lastActivity = currentTime; - // Increment activity level with a cap - lobby.activityLevel = Math.min((lobby.activityLevel || 0) + 1, 10); - } - else { - Logger.warn(`Channel ${channelId} not found in lobby ${lobbyId}`); - } - - await this.manager.setChatLobbies(lobbys); - } - - async checkIdleLobbies(): Promise { - const lobbys = await this.manager.getChatLobby(); - const currentTime = Date.now(); - let modified = false; - - for (const lobby of lobbys) { - const originalLength = lobby.connections.length; - const activeConnections = []; - const inactiveConnections = []; - - // Separate active and inactive connections - for (const connection of lobby.connections) { - if (currentTime - connection.lastActivity < IDLE_TIMEOUT) { - activeConnections.push(connection); - } - else { - inactiveConnections.push(connection); - await this.manager.removeChannelFromLobby(connection.channelId); - Logger.info(`Removed idle channel ${connection.channelId} from lobby ${lobby.id}`); - } - } - - // Update lobby connections - lobby.connections = activeConnections; - - if (activeConnections.length !== originalLength) { - modified = true; - } - - // Decay activity level - lobby.activityLevel = Math.max(0, lobby.activityLevel - 1); - - // Notify remaining members about disconnections - if (inactiveConnections.length > 0) { - inactiveConnections.forEach((c) => - this.lobbyNotifier.notifychannelDisconnect(lobby, c.channelId), - ); - } - } - - // Filter out empty lobbys - const activeLobbies = lobbys.filter((lobby) => lobby.connections.length > 0); - - if (modified || activeLobbies.length !== lobbys.length) { - await this.manager.setChatLobbies(activeLobbies); - } - } - - async disconnectChannel(channelId: string): Promise { - // Check if in any matching pool - const tiers: (keyof MatchingPool)[] = ['high', 'medium', 'low']; - for (const tier of tiers) { - if (await this.removeFromPool(tier, channelId)) { - Logger.info(`Removed channel ${channelId} from ${tier} priority pool`); - return; - } - } - - // If not in pools, check active lobbys - const lobbyId = await this.manager.getChannelLobbyId(channelId); - if (!lobbyId) { - throw new Error('Channel is not connected to any chat lobby'); - } - - const lobbys = await this.manager.getChatLobby(); - const lobbyIndex = lobbys.findIndex((g) => g.id === lobbyId); - - if (lobbyIndex === -1) { - throw new Error('Chat lobby not found'); - } - - const lobby = lobbys[lobbyIndex]; - const originalLength = lobby.connections.length; - - // Remove the channel from the lobby - lobby.connections = lobby.connections.filter((s) => s.channelId !== channelId); - - // If lobby is only left with one channel, remove the lobby - if (lobby.connections.length === 1) { - const lastConnection = lobby.connections[0]; - // Remove the last channel from the lobby - this.manager.removeChannelFromLobby(lastConnection.channelId); - lobbys.splice(lobbyIndex, 1); - - // Notify the remaining channel about disconnection - this.lobbyNotifier.notifyLobbyDelete(lastConnection.channelId); - Logger.info(`Removed empty lobby ${lobbyId}`); - } - else if (lobby.connections.length !== originalLength) { - // If channel was actually removed, update the lobby - lobbys[lobbyIndex] = lobby; - Logger.info(`Removed channel ${channelId} from lobby ${lobbyId}`); - } - - await this.manager.setChatLobbies(lobbys); - await this.manager.removeChannelFromLobby(channelId); - - // Notify other channels in the lobby - this.lobbyNotifier.notifychannelDisconnect(lobby, channelId); - } - - public async getChannelLobby(channelId: string): Promise { - const lobbyId = await this.manager.getChannelLobbyId(channelId); - if (!lobbyId) return null; - - const lobbys = await this.manager.getChatLobby(); - return lobbys.find((g) => g.id === lobbyId) || null; - } - - public async removeFromPoolByChannelId(channelId: string): Promise { - const tiers: (keyof MatchingPool)[] = ['high', 'medium', 'low']; - for (const tier of tiers) { - await this.removeFromPool(tier, channelId); - } - } - - public async getPoolInfo(channelId: string): Promise<{ - position: number | null; - estimatedWaitTime: number | null; - priority: string | null; - }> { - try { - // Check each priority tier - const tiers: (keyof MatchingPool)[] = ['high', 'medium', 'low']; - - for (const tier of tiers) { - const members = await this.getPoolMembers(tier); - const index = members.findIndex((ch) => ch.channelId === channelId); - - if (index !== -1) { - // Calculate estimated wait time based on position and historical data - const estimatedWaitTime = await this.calculateEstimatedWaitTime(index, tier); - - return { - position: index + 1, - estimatedWaitTime, - priority: tier, - }; - } - } - - // Channel not found in any queue - return { - position: null, - estimatedWaitTime: null, - priority: null, - }; - } - catch (error) { - Logger.error('Error getting queue info:', error); - throw error; - } - } - - private async calculateEstimatedWaitTime( - position: number, - tier: keyof MatchingPool, - ): Promise { - // Base wait times per tier (in milliseconds) - const baseWaitTimes = { - high: 15000, // 15 seconds - medium: 30000, // 30 seconds - low: 60000, // 1 minute - }; - try { - // Get current pool size from Redis - const poolSize = await this.redis.zcard(this.getPoolKey(tier)); - - // Factor in position (each position adds some time) - const positionFactor = Math.ceil(position / 2) * 5000; // 5 seconds per pair - - // Factor in current pool congestion - const poolSizeFactor = Math.max( - 0, - (poolSize - 2) * 2000, // 2 seconds per additional user in pool - ); - - // Get historical wait times for this tier from Redis - const historicalKey = `${this.POOL_KEY}${tier}:historical`; - const historicalWaitTimes = await this.redis.lrange(historicalKey, 0, 9); // Last 10 matches - - // Calculate historical adjustment - let historicalFactor = 0; - if (historicalWaitTimes.length > 0) { - const avgHistorical = - historicalWaitTimes.map(Number).reduce((sum, time) => sum + time, 0) / - historicalWaitTimes.length; - historicalFactor = Math.max(0, avgHistorical * 0.2); // 20% weight to historical data - } - - return baseWaitTimes[tier] + positionFactor + poolSizeFactor + historicalFactor; - } - catch (error) { - Logger.error('Error calculating wait time:', error); - // Return base estimate if Redis fails - return baseWaitTimes[tier] + position * 5000; - } - } - - // Helper method to record actual wait times - private async recordActualWaitTime(tier: keyof MatchingPool, waitTime: number): Promise { - try { - const historicalKey = `${this.POOL_KEY}${tier}:historical`; - await this.redis.lpush(historicalKey, waitTime); - await this.redis.ltrim(historicalKey, 0, 9); // Keep last 10 entries - } - catch (error) { - Logger.error('Error recording wait time:', error); - } - } - - public async getStats(): Promise<{ - activeLobbies: number; - queuedChannels: number; - averageWaitTime: number; - priorityDistribution: Record; - }> { - try { - // Get lobby count - const lobbies = await this.manager.getChatLobby(); - - // Get counts from Redis - const [highCount, mediumCount, lowCount] = await Promise.all([ - this.redis.zcard(this.getPoolKey('high')), - this.redis.zcard(this.getPoolKey('medium')), - this.redis.zcard(this.getPoolKey('low')), - ]); - - const queuedChannels = highCount + mediumCount + lowCount; - - // Calculate wait times from Redis - const currentTime = Date.now(); - const [highPool, mediumPool, lowPool] = await Promise.all([ - this.getPoolMembers('high'), - this.getPoolMembers('medium'), - this.getPoolMembers('low'), - ]); - - const allWaitTimes = [...highPool, ...mediumPool, ...lowPool].map( - (channel) => currentTime - channel.timestamp, - ); - - const averageWaitTime = - allWaitTimes.length > 0 - ? allWaitTimes.reduce((sum, time) => sum + time, 0) / allWaitTimes.length - : 0; - - return { - activeLobbies: lobbies.length, - queuedChannels, - averageWaitTime, - priorityDistribution: { - high: Number(highCount), - medium: Number(mediumCount), - low: Number(lowCount), - }, - }; - } - catch (error) { - Logger.error('Error getting stats:', error); - throw error; - } - } -} diff --git a/src/services/EncryptionService.ts b/src/services/EncryptionService.ts new file mode 100644 index 000000000..d59965593 --- /dev/null +++ b/src/services/EncryptionService.ts @@ -0,0 +1,55 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +export class EncryptionService { + private readonly algorithm = 'aes-256-gcm'; + private readonly ivLength = 12; + private readonly authTagLength = 16; + private encryptionKey: Buffer; + + constructor() { + // Load from environment variable - make sure this is set for all shards + const keyString = process.env.ENCRYPTION_KEY; + if (!keyString) { + throw new Error('ENCRYPTION_KEY environment variable must be set'); + } + this.encryptionKey = Buffer.from(keyString, 'base64'); + } + + encrypt(text: string): string { + const iv = randomBytes(this.ivLength); + const cipher = createCipheriv(this.algorithm, this.encryptionKey, iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + // Store format: base64(iv + encrypted + authTag) + return Buffer.concat([ + iv, + Buffer.from(encrypted, 'hex'), + authTag, + ]).toString('base64'); + } + + decrypt(encoded: string): string { + try { + const buf = Buffer.from(encoded, 'base64'); + + const iv = buf.subarray(0, this.ivLength); + const authTag = buf.subarray(buf.length - this.authTagLength); + const encrypted = buf.subarray(this.ivLength, buf.length - this.authTagLength); + + const decipher = createDecipheriv(this.algorithm, this.encryptionKey, iv); + decipher.setAuthTag(authTag); + + let dec = decipher.update(encrypted); + dec = Buffer.concat([dec, decipher.final()]); + + return dec.toString('utf8'); + } + catch (error) { + throw new Error(`Failed to decrypt message: ${error.message}`); + } + } +} diff --git a/src/services/LobbyMatchingService.ts b/src/services/LobbyMatchingService.ts new file mode 100644 index 000000000..089d5aa99 --- /dev/null +++ b/src/services/LobbyMatchingService.ts @@ -0,0 +1,59 @@ +import type { QueuedChannel, ServerPreferences } from '#types/ChatLobby.d.ts'; +import db from '#main/utils/Db.js'; +import getRedis from '#main/utils/Redis.js'; +import { PrismaClient } from '@prisma/client'; +import { Redis } from 'ioredis'; + +export class MatchingService { + private readonly db: PrismaClient; + private readonly redis: Redis; + + constructor() { + this.db = db; + this.redis = getRedis(); + } + + async findMatch(serverId: string, preferences: ServerPreferences): Promise { + const serverHistory = await this.db.serverHistory.findUnique({ + where: { serverId }, + }); + + const recentLobbyIds = + serverHistory?.recentLobbies + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 3) + .map((l) => l.lobbyId) || []; + + const waitingServers = await this.redis.zrange('waiting_pool', 0, -1); + + for (const serverData of waitingServers) { + const data: QueuedChannel = JSON.parse(serverData); + if (data.serverId === serverId) continue; + + const otherServerHistory = await this.db.serverHistory.findUnique({ + where: { serverId: data.serverId }, + }); + + // Check if in the past 2 calls they have been in a lobby together + const hasRecentInteraction = recentLobbyIds.some((lobbyId, i) => + i <= 2 && otherServerHistory?.recentLobbies.some((l) => l.lobbyId === lobbyId), + ); + + if (!hasRecentInteraction) { + // Check premium status and preferences + if (preferences.premiumStatus || data.preferences.premium) { + // Premium users get priority matching + return data; + } + + // Regular matching + if (preferences.maxServersInLobby === data.preferences.maxServersInLobby) { + // TODO: make it possible for servers to join already created lobbies later if they don't match immediately + return data; + } + } + } + + return null; + } +} diff --git a/src/services/LobbyNotifierService.ts b/src/services/LobbyNotifierService.ts new file mode 100644 index 000000000..bc3ce8040 --- /dev/null +++ b/src/services/LobbyNotifierService.ts @@ -0,0 +1,71 @@ +import { LobbyManager } from '#main/managers/LobbyManager.js'; +import { LobbyData } from '#main/types/ChatLobby.js'; +import { emojis } from '#main/utils/Constants.js'; +import Logger from '#main/utils/Logger.js'; +import { stripIndents } from 'common-tags'; +import { WebhookClient } from 'discord.js'; + +export default class LobbyNotifier { + private readonly manager: LobbyManager; + + constructor(manager = new LobbyManager()) { + this.manager = manager; + } + + private async sendToChannel(channelId: string, message: string) { + try { + const channel = (await this.manager.getLobbyByChannelId(channelId))?.servers + .find((s) => s.channelId === channelId); + + if (!channel) { + Logger.error(`[LobbyNotifier]: Channel ${channelId} not found in any lobby`); + return; + } + const webhook = new WebhookClient({ url: channel.webhookUrl }); + await webhook.send({ + content: message, + username: 'InterChat Lobby Notification', + allowedMentions: { parse: [] }, + }); + } + catch (error) { + Logger.error('Failed to send lobby notification to channel', error); + } + } + + public notifyChannelConnect(channelId: string, lobby: LobbyData) { + lobby.servers.forEach(async (server) => { + if (server.channelId === channelId) return; + + await this.sendToChannel( + server.channelId, + `${emojis.join} A server joined the lobby. ${lobby.servers.length}/3 server(s) in total.`, + ); + }); + Logger.info(`Channel ${channelId} connected to lobby.`); + } + + public async notifyChannelDisconnect(lobby: LobbyData, channelId: string) { + lobby.servers.forEach(async (server) => { + if (server.channelId === channelId) return; + await this.sendToChannel(server.channelId, `-# ${emojis.info} A server disconnected from lobby.`); + }); + } + + public async notifyLobbyCreate(channelId: string, lobby: LobbyData) { + await this.sendToChannel( + channelId, + stripIndents` + 🎉 Connected to a new lobby with ${lobby.servers.length}/3 server(s)! + -# Messages in this channel will be shared with other servers. + `, + ); + Logger.info(`New lobby ${lobby.id} created for ${lobby.servers.map((c) => c.serverId)}`); + } + public async notifyLobbyDelete(channelId: string) { + await this.sendToChannel( + channelId, + `${emojis.disconnect} No more servers in the lobby. Disconnected.`, + ); + } +} diff --git a/src/services/MessageProcessor.ts b/src/services/MessageProcessor.ts index 952d75925..5d27fca40 100644 --- a/src/services/MessageProcessor.ts +++ b/src/services/MessageProcessor.ts @@ -1,13 +1,15 @@ -import { Message } from 'discord.js'; -import type { ChatLobby } from '#types/ChatLobby.d.ts'; -import type { ConnectionData } from '#types/ConnectionTypes.d.ts'; -import { BroadcastService } from './BroadcastService.js'; +import { showRulesScreening } from '#main/interactions/RulesScreening.js'; import HubSettingsManager from '#main/managers/HubSettingsManager.js'; +import { LobbyManager } from '#main/managers/LobbyManager.js'; +import Constants, { emojis } from '#main/utils/Constants.js'; import { checkBlockedWords } from '#main/utils/network/blockwordsRunner.js'; import { runChecks } from '#main/utils/network/runChecks.js'; -import { showRulesScreening } from '#main/interactions/RulesScreening.js'; -import storeLobbyMessageData from '#main/utils/lobby/storeLobbyMessageData.js'; -import { handleError } from '#main/utils/Utils.js'; +import { check } from '#main/utils/ProfanityUtils.js'; +import { containsInviteLinks, handleError } from '#main/utils/Utils.js'; +import type { LobbyData } from '#types/ChatLobby.d.ts'; +import type { ConnectionData } from '#types/ConnectionTypes.d.ts'; +import { Message, WebhookClient } from 'discord.js'; +import { BroadcastService } from './BroadcastService.js'; export class MessageProcessor { private readonly broadcastService: BroadcastService; @@ -16,36 +18,35 @@ export class MessageProcessor { this.broadcastService = new BroadcastService(); } - async processLobbyMessage(message: Message, lobby: ChatLobby) { + async processLobbyMessage(message: Message, lobby: LobbyData) { await this.updateLobbyActivity(message, lobby); - for (const server of lobby.connections) { + if ( + containsInviteLinks(message.content) || + message.attachments.size > 0 || + Constants.Regex.ImageURL.test(message.content) || + check(message.content).hasSlurs + ) { + message.react(`${emojis.no}`).catch(() => null); + return; + } + + for (const server of lobby.servers) { if (server.channelId === message.channelId) continue; try { - await message.client.cluster.broadcastEval( - async (c, { channelId, content }) => { - const channel = await c.channels.fetch(channelId); - if (channel?.isSendable()) { - await channel.send({ content, allowedMentions: { parse: [] } }); - } - }, - { - context: { - channelId: server.channelId, - content: `**${message.author.username}**: ${message.content}`, - }, - guildId: server.serverId, - }, - ); + const webhook = new WebhookClient({ url: server.webhookUrl }); + await webhook.send({ + username: message.author.username, + avatarURL: message.author.displayAvatarURL(), + content: message.content, + allowedMentions: { parse: [] }, + }); } catch (err) { err.message = `Failed to send message to ${server.channelId}: ${err.message}`; handleError(err); } - - await storeLobbyMessageData(lobby, message); - } } @@ -84,8 +85,8 @@ export class MessageProcessor { ); } - private async updateLobbyActivity(message: Message, lobby: ChatLobby) { - const { lobbyService } = message.client; - await lobbyService.updateActivity(lobby.id, message.channelId); + private async updateLobbyActivity(message: Message, lobby: LobbyData) { + const lobbyManager = new LobbyManager(); + await lobbyManager.updateLastMessageTimestamp(lobby.id, message.guildId); } } diff --git a/src/types/ChatLobby.d.ts b/src/types/ChatLobby.d.ts index 282c569a0..fd8cbdad4 100644 --- a/src/types/ChatLobby.d.ts +++ b/src/types/ChatLobby.d.ts @@ -8,13 +8,56 @@ export interface ChatLobby { export interface ServerConnection { serverId: string; channelId: string; + webhookUrl: string; lastActivity: number; } export interface ChannelPreferences { minActivityLevel?: number; // minimum messages per 5 minutes - maxServers?: number; // maximum servers in group (1-3) + maxServersInLobby?: number; // maximum servers in group (1-3) premium?: boolean; maxWaitTime?: number; // maximum time to wait for a group idealLobbySize?: number; // ideal number of servers in group } + + +export interface QueuedChannel { + serverId: string; + channelId: string; + webhookUrl: string; + preferences: ChannelPreferences; + timestamp: number; + priority: number; +} + +interface MatchingPool { + high: QueuedChannel[]; + medium: QueuedChannel[]; + low: QueuedChannel[]; +} + +interface ServerPreferences { + premiumStatus: boolean; + maxServersInLobby: number; +} + +interface LobbyData { + id: string; + servers: LobbyServer[]; + createdAt: number; +} + +interface LobbyServer { + serverId: string; + webhookUrl: string; + channelId: string; + lastMessageTimestamp: number; +} + +interface ServerHistory { + serverId: string; + recentLobbies: { + lobbyId: string; + timestamp: number; + }[]; +} diff --git a/src/types/CustomClientProps.d.ts b/src/types/CustomClientProps.d.ts index 9d41d4d55..a46c0dd6e 100644 --- a/src/types/CustomClientProps.d.ts +++ b/src/types/CustomClientProps.d.ts @@ -5,7 +5,6 @@ import AntiSpamManager from '#main/managers/AntiSpamManager.js'; import UserDbManager from '#main/managers/UserDbManager.js'; import CooldownService from '#main/services/CooldownService.js'; import Scheduler from '#main/services/SchedulerService.js'; -import ChatLobbyService from '#main/services/ChatLobbyService.js'; import { ClusterClient } from 'discord-hybrid-sharding'; import { Collection, @@ -30,7 +29,6 @@ declare module 'discord.js' { readonly commands: Collection; readonly interactions: Collection; readonly prefixCommands: Collection; - readonly lobbyService: ChatLobbyService; readonly commandCooldowns: CooldownService; readonly reactionCooldowns: Collection; diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index e9e260081..dc46f9aa1 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -70,10 +70,14 @@ const findExistingWebhook = async (channel: ThreadParentChannel | VoiceBasedChan return webhooks?.find((w) => w.owner?.id === channel.client.user?.id); }; -const createWebhook = async (channel: ThreadParentChannel | VoiceBasedChannel, avatar: string) => +const createWebhook = async ( + channel: ThreadParentChannel | VoiceBasedChannel, + avatar: string, + name: string, +) => await channel ?.createWebhook({ - name: 'InterChat Network', + name, avatar, }) .catch(() => undefined); @@ -81,12 +85,13 @@ const createWebhook = async (channel: ThreadParentChannel | VoiceBasedChannel, a export const getOrCreateWebhook = async ( channel: GuildTextBasedChannel, avatar = Constants.Links.EasterAvatar, + name = 'InterChat Network', ) => { const channelOrParent = channel.isThread() ? channel.parent : channel; if (!channelOrParent) return null; const existingWebhook = await findExistingWebhook(channelOrParent); - return existingWebhook || (await createWebhook(channelOrParent, avatar)); + return existingWebhook || (await createWebhook(channelOrParent, avatar, name)); }; export const getCredits = () => [