diff --git a/migrations/1760807555697_channel-pools.js b/migrations/1760807555697_channel-pools.js new file mode 100644 index 0000000..3a1aa89 --- /dev/null +++ b/migrations/1760807555697_channel-pools.js @@ -0,0 +1,35 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const up = (pgm) => { + pgm.createTable('channel_pool', { + id: 'id', + channel_id: { type: 'varchar(255)', notNull: true, unique: true }, + in_use: { type: 'boolean', notNull: true, default: false }, + match_id: { type: 'integer', notNull: false, default: null }, + created_at: { + type: 'timestamp', + notNull: true, + default: pgm.func('current_timestamp'), + }, + }); + + // Add index for faster lookups of available channels + pgm.createIndex('channel_pool', 'in_use'); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => { + pgm.dropTable('channel_pool'); +}; diff --git a/src/events/ready.ts b/src/events/ready.ts index 7b9d59c..4c79f9c 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,5 +1,6 @@ import { Events, Client } from 'discord.js' import { incrementEloCronJobAllQueues } from '../utils/cronJobs' +import { initializeChannelPool } from '../utils/channelPool' export default { name: Events.ClientReady, @@ -7,6 +8,10 @@ export default { async execute(client: Client) { await incrementEloCronJobAllQueues() console.log('Started up queues.') + + // Initialize the channel pool + await initializeChannelPool() + console.log(`Ready! Logged in as ${client.user?.tag}`) }, } diff --git a/src/utils/channelPool.ts b/src/utils/channelPool.ts new file mode 100644 index 0000000..18abe3b --- /dev/null +++ b/src/utils/channelPool.ts @@ -0,0 +1,325 @@ +import { + ChannelType, + OverwriteType, + PermissionFlagsBits, + TextChannel, +} from 'discord.js' +import { pool } from '../db' +import { getGuild } from '../client' +import { getAllUsersInQueue, getSettings } from './queryDB' + +const POOL_SIZE = 10 // Number of channels to keep in the pool +const MIN_POOL_SIZE = 5 // Minimum channels before creating more + +/** + * Initializes the channel pool by creating pre-made channels + * Call this on bot startup + */ +export async function initializeChannelPool(): Promise { + try { + console.log('Initializing channel pool...') + + // Check how many channels are already in the pool + const existingChannels = await pool.query( + 'SELECT COUNT(*) FROM channel_pool WHERE in_use = false', + ) + const availableCount = parseInt(existingChannels.rows[0].count) + + console.log(`Found ${availableCount} available channels in pool`) + + // Calculate how many we need to create + const channelsToCreate = POOL_SIZE - availableCount + + if (channelsToCreate <= 0) { + console.log('Channel pool is already full') + return + } + + const guild = await getGuild() + const amountInQueue = await getAllUsersInQueue() + if (amountInQueue.length >= 2) { + console.log('Too many users in queue, waiting to create channels') + return + } + + console.log(`Creating ${channelsToCreate} channels for the pool...`) + + // Create channels with a delay between each to avoid rate limiting + for (let i = 0; i < channelsToCreate; i++) { + try { + const channel = await guild.channels.create({ + name: `reserve-channel-${i + 1}`, + type: ChannelType.GuildText, + parent: null, // No category for reserve channels + permissionOverwrites: [ + { + id: guild.roles.everyone, + deny: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.ReadMessageHistory, + ], + }, + ], + }) + + // Add to database pool + await pool.query( + 'INSERT INTO channel_pool (channel_id, in_use) VALUES ($1, false)', + [channel.id], + ) + + console.log( + `Created pooled channel ${i + 1}/${channelsToCreate}: ${channel.id}`, + ) + + // Wait 2 seconds between channel creations to avoid rate limiting + if (i < channelsToCreate - 1) { + await new Promise((resolve) => setTimeout(resolve, 2000)) + } + } catch (err) { + console.error(`Failed to create pooled channel ${i + 1}:`, err) + // Continue trying to create remaining channels + } + } + + console.log('Channel pool initialization complete') + } catch (err) { + console.error('Error initializing channel pool:', err) + } +} + +/** + * Gets an available channel from the pool for a match + * @param matchId - The match ID that will use this channel + * @param userIds - Array of user IDs who should have access to the channel + * @param channelName - Name to set for the channel + * @returns The text channel, or null if none available + */ +export async function getAvailableChannel( + matchId: number, + userIds: string[], + channelName: string, +): Promise { + const dbClient = await pool.connect() + + try { + await dbClient.query('BEGIN') + + // Get an available channel (with row lock to prevent race conditions) + const result = await dbClient.query( + `SELECT channel_id FROM channel_pool + WHERE in_use = false + LIMIT 1 + FOR UPDATE SKIP LOCKED`, + ) + + if (result.rows.length === 0) { + await dbClient.query('ROLLBACK') + console.warn('No available channels in pool!') + return null + } + + const channelId = result.rows[0].channel_id + + // Mark channel as in use + await dbClient.query( + 'UPDATE channel_pool SET in_use = true, match_id = $1 WHERE channel_id = $2', + [matchId, channelId], + ) + + await dbClient.query('COMMIT') + + // Configure the channel + const guild = await getGuild() + const channel = (await guild.channels.fetch(channelId)) as TextChannel + + if (!channel) { + console.error(`Channel ${channelId} not found in Discord`) + // Return it to pool since it's invalid + await pool.query( + 'UPDATE channel_pool SET in_use = false, match_id = null WHERE channel_id = $1', + [channelId], + ) + return null + } + + const settings = await getSettings() + + // Set up permissions for match users + const permissionOverwrites = [ + { + id: guild.roles.everyone, + deny: [PermissionFlagsBits.ViewChannel], + }, + ...userIds.map((userId) => ({ + id: userId, + allow: [PermissionFlagsBits.ViewChannel], + type: OverwriteType.Member, + })), + ] + + if (settings?.queue_helper_role_id) { + permissionOverwrites.push({ + id: settings.queue_helper_role_id, + allow: [PermissionFlagsBits.ViewChannel], + type: OverwriteType.Role, + }) + } + + // Get the category for active matches + const categoryId = settings.queue_category_id + const backupCat = '1427367817803464914' + + const category = await guild.channels.fetch(categoryId) + const channelCount = + category && category.type === ChannelType.GuildCategory + ? category.children.cache.size + : 0 + + // Update channel name, permissions, and move to match category + await channel.edit({ + name: channelName, + parent: channelCount > 45 ? backupCat : categoryId, + permissionOverwrites: permissionOverwrites, + }) + + console.log(`Assigned channel ${channelId} to match ${matchId}`) + + // Check if pool needs refilling (async, don't wait) + maintainChannelPool().catch((err) => + console.error('Error maintaining channel pool:', err), + ) + + return channel + } catch (err) { + await dbClient.query('ROLLBACK') + console.error('Error getting available channel:', err) + return null + } finally { + dbClient.release() + } +} + +/** + * Returns a channel to the pool after a match ends + * @param channelId - The Discord channel ID to return to pool + */ +export async function returnChannelToPool(channelId: string): Promise { + try { + const guild = await getGuild() + const channel = (await guild.channels.fetch(channelId)) as TextChannel + + if (!channel) { + console.warn(`Channel ${channelId} not found, removing from pool`) + await pool.query('DELETE FROM channel_pool WHERE channel_id = $1', [ + channelId, + ]) + return + } + + // Reset channel to default state (no category, hidden, history hidden) + await channel.edit({ + name: 'reserve-channel', + parent: null, // Remove from category + permissionOverwrites: [ + { + id: guild.roles.everyone, + deny: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.ReadMessageHistory, + ], + }, + ], + }) + + // Mark as available in database + await pool.query( + 'UPDATE channel_pool SET in_use = false, match_id = null WHERE channel_id = $1', + [channelId], + ) + + console.log(`Returned channel ${channelId} to pool`) + } catch (err) { + console.error(`Error returning channel ${channelId} to pool:`, err) + } +} + +/** + * Maintains the channel pool by creating new channels if below minimum + * Can be called periodically or after channels are used + */ +export async function maintainChannelPool(): Promise { + try { + const result = await pool.query( + 'SELECT COUNT(*) FROM channel_pool WHERE in_use = false', + ) + const availableCount = parseInt(result.rows[0].count) + + if (availableCount >= MIN_POOL_SIZE) { + return + } + + console.log( + `Channel pool low (${availableCount}), creating more channels...`, + ) + + const channelsToCreate = POOL_SIZE - availableCount + const guild = await getGuild() + + for (let i = 0; i < channelsToCreate; i++) { + try { + const channel = await guild.channels.create({ + name: `reserve-channel`, + type: ChannelType.GuildText, + parent: null, // No category for reserve channels + permissionOverwrites: [ + { + id: guild.roles.everyone, + deny: [PermissionFlagsBits.ViewChannel], + }, + ], + }) + + await pool.query( + 'INSERT INTO channel_pool (channel_id, in_use) VALUES ($1, false)', + [channel.id], + ) + + console.log(`Created maintenance channel ${i + 1}/${channelsToCreate}`) + + // Wait 2 seconds between creations + if (i < channelsToCreate - 1) { + await new Promise((resolve) => setTimeout(resolve, 2000)) + } + } catch (err) { + console.error(`Failed to create maintenance channel ${i + 1}:`, err) + } + } + } catch (err) { + console.error('Error maintaining channel pool:', err) + } +} + +/** + * Cleans up orphaned channels (channels in pool that no longer exist in Discord) + */ +export async function cleanupChannelPool(): Promise { + try { + const result = await pool.query('SELECT channel_id FROM channel_pool') + const guild = await getGuild() + + for (const row of result.rows) { + try { + await guild.channels.fetch(row.channel_id) + } catch (err) { + // Channel doesn't exist, remove from pool + console.log(`Removing orphaned channel ${row.channel_id} from pool`) + await pool.query('DELETE FROM channel_pool WHERE channel_id = $1', [ + row.channel_id, + ]) + } + } + } catch (err) { + console.error('Error cleaning up channel pool:', err) + } +} diff --git a/src/utils/matchHelpers.ts b/src/utils/matchHelpers.ts index b1312fc..8e2e521 100644 --- a/src/utils/matchHelpers.ts +++ b/src/utils/matchHelpers.ts @@ -53,6 +53,7 @@ import { clearChannelMessageCount, setLastWinVoteMessage, } from '../events/messageCreate' +import { returnChannelToPool } from './channelPool' require('dotenv').config() @@ -981,14 +982,35 @@ export async function deleteMatchChannel(matchId: number): Promise { // Clear the message count for this channel clearChannelMessageCount(textChannel.id) - setTimeout(async () => { - await textChannel.delete().catch((err) => { - console.error( - `Failed to delete text channel for match ${matchId}: ${err}`, - ) - return false - }) - }, 1000) + // Check if this channel is from the pool + const poolCheck = await pool.query( + 'SELECT channel_id FROM channel_pool WHERE channel_id = $1', + [textChannel.id] + ) + + if (poolCheck.rows.length > 0) { + // Channel is from pool, return it instead of deleting + console.log(`Returning channel ${textChannel.id} to pool for match ${matchId}`) + setTimeout(async () => { + await returnChannelToPool(textChannel.id).catch((err) => { + console.error( + `Failed to return channel to pool for match ${matchId}: ${err}`, + ) + }) + }, 1000) + } else { + // Channel was created as fallback, delete it normally + console.log(`Deleting non-pooled channel ${textChannel.id} for match ${matchId}`) + setTimeout(async () => { + await textChannel.delete().catch((err) => { + console.error( + `Failed to delete text channel for match ${matchId}: ${err}`, + ) + return false + }) + }, 1000) + } + return true } diff --git a/src/utils/queryDB.ts b/src/utils/queryDB.ts index 8853d4c..bd40de8 100644 --- a/src/utils/queryDB.ts +++ b/src/utils/queryDB.ts @@ -799,6 +799,19 @@ export async function getUsersInQueue(queueId: number): Promise { return response.rows.map((row) => row.user_id) } +// Get users in all queues +export async function getAllUsersInQueue(): Promise { + const response = await pool.query( + ` + SELECT u.user_id + FROM queue_users u + WHERE u.queue_join_time IS NOT NULL + `, + ) + + return response.rows.map((row) => row.user_id) +} + // Remove user from queue export async function removeUserFromQueue( queueId: number, diff --git a/src/utils/queueHelpers.ts b/src/utils/queueHelpers.ts index 3062ab0..741e8f3 100644 --- a/src/utils/queueHelpers.ts +++ b/src/utils/queueHelpers.ts @@ -31,6 +31,7 @@ import { import { Queues } from 'psqlDB' import { QueryResult } from 'pg' import { client, getGuild } from '../client' +import { getAvailableChannel } from './channelPool' // Updates or sends a new queue message for the specified text channel export async function updateQueueMessage(): Promise { @@ -500,23 +501,77 @@ export async function createMatch( ) const matchId = response.rows[0].id - const backupCat = '1427367817803464914' - const category = await guild.channels.fetch(categoryId) - if (!category || category.type !== ChannelType.GuildCategory) { - return console.log('Not a valid category.') - } - const channelCount = category.children.cache.size - const channel = await guild.channels.create({ - name: `match-${matchId}`, - type: ChannelType.GuildText, - parent: channelCount > 45 ? backupCat : categoryId, - permissionOverwrites: permissionOverwrites, - }) + // Get a channel from the pool instead of creating a new one + const channel = await getAvailableChannel(matchId, userIds, `match-${matchId}`) + + if (!channel) { + console.error(`No available channels in pool for match ${matchId}`) + // Fallback to creating a new channel if pool is empty + const backupCat = '1427367817803464914' + const category = await guild.channels.fetch(categoryId) + if (!category || category.type !== ChannelType.GuildCategory) { + return console.log('Not a valid category.') + } + const channelCount = category.children.cache.size + + const fallbackChannel = await guild.channels.create({ + name: `match-${matchId}`, + type: ChannelType.GuildText, + parent: channelCount > 45 ? backupCat : categoryId, + permissionOverwrites: permissionOverwrites, + }) + + await pool.query( + ` + UPDATE matches + SET channel_id = $1 + WHERE id = $2 + `, + [fallbackChannel.id, matchId], + ) + + // Use the fallback channel for the rest of the function + const fallbackChannelForReturn = fallbackChannel + + // Insert match_users (queue_join_time is already NULL from matchUpGames) + for (const userId of userIds) { + await pool.query( + `INSERT INTO match_users (user_id, match_id, team) + VALUES ($1, $2, $3)`, + [userId, matchId, userIds.indexOf(userId) + 1], + ) + + const member = await guild.members.fetch(userId) + try { + await member.send({ + embeds: [ + new EmbedBuilder() + .setTitle('Match Found!') + .setDescription(`**Match Channel**\n<#${fallbackChannelForReturn.id}>`) + .setColor(0x00ff00), + ], + }) + } catch (err) {} + } + + await updateQueueMessage() + + // Wait 2 seconds for channel to fully propagate in Discord's API + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // Send queue start messages + await sendMatchInitMessages(queueId, matchId, fallbackChannelForReturn) + + // Log match creation + await sendQueueLog(matchId, queueId, userIds) + + return fallbackChannelForReturn + } await pool.query( ` - UPDATE matches + UPDATE matches SET channel_id = $1 WHERE id = $2 `,