Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions migrations/1760807555697_channel-pools.js
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this shouldnt be renamed seeming as the timestamp already comes after the last one

Original file line number Diff line number Diff line change
@@ -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> | 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> | void}
*/
export const down = (pgm) => {
pgm.dropTable('channel_pool');
};
5 changes: 5 additions & 0 deletions src/events/ready.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { Events, Client } from 'discord.js'
import { incrementEloCronJobAllQueues } from '../utils/cronJobs'
import { initializeChannelPool } from '../utils/channelPool'

export default {
name: Events.ClientReady,
once: true,
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}`)
},
}
325 changes: 325 additions & 0 deletions src/utils/channelPool.ts
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can make these editable with a command for dynamic load handling

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<void> {
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...`)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will just get us rate limited - we should only create pool channels when there is less than x players in queue - add to pool when numbers are low, use pool when numbers are high

// 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<TextChannel | null> {
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 = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

turn off message history (we need this for all match creation)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk how to do this lol

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but then we dont need to delete all the messages nescessarily, which might help with rate limiting (?)

{
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<void> {
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<void> {
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<void> {
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)
}
}
Loading