Skip to content

Commit d459efe

Browse files
committed
Reserve channels
1 parent 4fde471 commit d459efe

File tree

5 files changed

+466
-21
lines changed

5 files changed

+466
-21
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
3+
*/
4+
export const shorthands = undefined;
5+
6+
/**
7+
* @param pgm {import('node-pg-migrate').MigrationBuilder}
8+
* @param run {() => void | undefined}
9+
* @returns {Promise<void> | void}
10+
*/
11+
export const up = (pgm) => {
12+
pgm.createTable('channel_pool', {
13+
id: 'id',
14+
channel_id: { type: 'varchar(255)', notNull: true, unique: true },
15+
in_use: { type: 'boolean', notNull: true, default: false },
16+
match_id: { type: 'integer', notNull: false, default: null },
17+
created_at: {
18+
type: 'timestamp',
19+
notNull: true,
20+
default: pgm.func('current_timestamp'),
21+
},
22+
});
23+
24+
// Add index for faster lookups of available channels
25+
pgm.createIndex('channel_pool', 'in_use');
26+
};
27+
28+
/**
29+
* @param pgm {import('node-pg-migrate').MigrationBuilder}
30+
* @param run {() => void | undefined}
31+
* @returns {Promise<void> | void}
32+
*/
33+
export const down = (pgm) => {
34+
pgm.dropTable('channel_pool');
35+
};

src/events/ready.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { Events, Client } from 'discord.js'
22
import { incrementEloCronJobAllQueues } from '../utils/cronJobs'
3+
import { initializeChannelPool } from '../utils/channelPool'
34

45
export default {
56
name: Events.ClientReady,
67
once: true,
78
async execute(client: Client) {
89
await incrementEloCronJobAllQueues()
910
console.log('Started up queues.')
11+
12+
// Initialize the channel pool
13+
await initializeChannelPool()
14+
1015
console.log(`Ready! Logged in as ${client.user?.tag}`)
1116
},
1217
}

src/utils/channelPool.ts

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import {
2+
ChannelType,
3+
OverwriteType,
4+
PermissionFlagsBits,
5+
TextChannel,
6+
} from 'discord.js'
7+
import { pool } from '../db'
8+
import { client, getGuild } from '../client'
9+
import { getSettings } from './queryDB'
10+
11+
const POOL_SIZE = 10 // Number of channels to keep in the pool
12+
const MIN_POOL_SIZE = 5 // Minimum channels before creating more
13+
14+
/**
15+
* Initializes the channel pool by creating pre-made channels
16+
* Call this on bot startup
17+
*/
18+
export async function initializeChannelPool(): Promise<void> {
19+
try {
20+
console.log('Initializing channel pool...')
21+
22+
// Check how many channels are already in the pool
23+
const existingChannels = await pool.query(
24+
'SELECT COUNT(*) FROM channel_pool WHERE in_use = false',
25+
)
26+
const availableCount = parseInt(existingChannels.rows[0].count)
27+
28+
console.log(`Found ${availableCount} available channels in pool`)
29+
30+
// Calculate how many we need to create
31+
const channelsToCreate = POOL_SIZE - availableCount
32+
33+
if (channelsToCreate <= 0) {
34+
console.log('Channel pool is already full')
35+
return
36+
}
37+
38+
const guild = await getGuild()
39+
40+
console.log(`Creating ${channelsToCreate} channels for the pool...`)
41+
42+
// Create channels with a delay between each to avoid rate limiting
43+
for (let i = 0; i < channelsToCreate; i++) {
44+
try {
45+
const channel = await guild.channels.create({
46+
name: `reserve-channel-${i + 1}`,
47+
type: ChannelType.GuildText,
48+
parent: null, // No category for reserve channels
49+
permissionOverwrites: [
50+
{
51+
id: guild.roles.everyone,
52+
deny: [PermissionFlagsBits.ViewChannel],
53+
},
54+
],
55+
})
56+
57+
// Add to database pool
58+
await pool.query(
59+
'INSERT INTO channel_pool (channel_id, in_use) VALUES ($1, false)',
60+
[channel.id],
61+
)
62+
63+
console.log(
64+
`Created pooled channel ${i + 1}/${channelsToCreate}: ${channel.id}`,
65+
)
66+
67+
// Wait 2 seconds between channel creations to avoid rate limiting
68+
if (i < channelsToCreate - 1) {
69+
await new Promise((resolve) => setTimeout(resolve, 2000))
70+
}
71+
} catch (err) {
72+
console.error(`Failed to create pooled channel ${i + 1}:`, err)
73+
// Continue trying to create remaining channels
74+
}
75+
}
76+
77+
console.log('Channel pool initialization complete')
78+
} catch (err) {
79+
console.error('Error initializing channel pool:', err)
80+
}
81+
}
82+
83+
/**
84+
* Gets an available channel from the pool for a match
85+
* @param matchId - The match ID that will use this channel
86+
* @param userIds - Array of user IDs who should have access to the channel
87+
* @param channelName - Name to set for the channel
88+
* @returns The text channel, or null if none available
89+
*/
90+
export async function getAvailableChannel(
91+
matchId: number,
92+
userIds: string[],
93+
channelName: string,
94+
): Promise<TextChannel | null> {
95+
const dbClient = await pool.connect()
96+
97+
try {
98+
await dbClient.query('BEGIN')
99+
100+
// Get an available channel (with row lock to prevent race conditions)
101+
const result = await dbClient.query(
102+
`SELECT channel_id FROM channel_pool
103+
WHERE in_use = false
104+
LIMIT 1
105+
FOR UPDATE SKIP LOCKED`,
106+
)
107+
108+
if (result.rows.length === 0) {
109+
await dbClient.query('ROLLBACK')
110+
console.warn('No available channels in pool!')
111+
return null
112+
}
113+
114+
const channelId = result.rows[0].channel_id
115+
116+
// Mark channel as in use
117+
await dbClient.query(
118+
'UPDATE channel_pool SET in_use = true, match_id = $1 WHERE channel_id = $2',
119+
[matchId, channelId],
120+
)
121+
122+
await dbClient.query('COMMIT')
123+
124+
// Configure the channel
125+
const guild = await getGuild()
126+
const channel = (await guild.channels.fetch(channelId)) as TextChannel
127+
128+
if (!channel) {
129+
console.error(`Channel ${channelId} not found in Discord`)
130+
// Return it to pool since it's invalid
131+
await pool.query(
132+
'UPDATE channel_pool SET in_use = false, match_id = null WHERE channel_id = $1',
133+
[channelId],
134+
)
135+
return null
136+
}
137+
138+
const settings = await getSettings()
139+
140+
// Set up permissions for match users
141+
const permissionOverwrites = [
142+
{
143+
id: guild.roles.everyone,
144+
deny: [PermissionFlagsBits.ViewChannel],
145+
},
146+
...userIds.map((userId) => ({
147+
id: userId,
148+
allow: [PermissionFlagsBits.ViewChannel],
149+
type: OverwriteType.Member,
150+
})),
151+
]
152+
153+
if (settings?.queue_helper_role_id) {
154+
permissionOverwrites.push({
155+
id: settings.queue_helper_role_id,
156+
allow: [PermissionFlagsBits.ViewChannel],
157+
type: OverwriteType.Role,
158+
})
159+
}
160+
161+
// Get the category for active matches
162+
const categoryId = settings.queue_category_id
163+
const backupCat = '1427367817803464914'
164+
165+
const category = await guild.channels.fetch(categoryId)
166+
const channelCount =
167+
category && category.type === ChannelType.GuildCategory
168+
? category.children.cache.size
169+
: 0
170+
171+
// Update channel name, permissions, and move to match category
172+
await channel.edit({
173+
name: channelName,
174+
parent: channelCount > 45 ? backupCat : categoryId,
175+
permissionOverwrites: permissionOverwrites,
176+
})
177+
178+
console.log(`Assigned channel ${channelId} to match ${matchId}`)
179+
180+
// Check if pool needs refilling (async, don't wait)
181+
maintainChannelPool().catch((err) =>
182+
console.error('Error maintaining channel pool:', err),
183+
)
184+
185+
return channel
186+
} catch (err) {
187+
await dbClient.query('ROLLBACK')
188+
console.error('Error getting available channel:', err)
189+
return null
190+
} finally {
191+
dbClient.release()
192+
}
193+
}
194+
195+
/**
196+
* Returns a channel to the pool after a match ends
197+
* @param channelId - The Discord channel ID to return to pool
198+
*/
199+
export async function returnChannelToPool(channelId: string): Promise<void> {
200+
try {
201+
const guild = await getGuild()
202+
const channel = (await guild.channels.fetch(channelId)) as TextChannel
203+
204+
if (!channel) {
205+
console.warn(`Channel ${channelId} not found, removing from pool`)
206+
await pool.query('DELETE FROM channel_pool WHERE channel_id = $1', [
207+
channelId,
208+
])
209+
return
210+
}
211+
212+
// Reset channel to default state (no category, hidden)
213+
await channel.edit({
214+
name: 'reserve-channel',
215+
parent: null, // Remove from category
216+
permissionOverwrites: [
217+
{
218+
id: guild.roles.everyone,
219+
deny: [PermissionFlagsBits.ViewChannel],
220+
},
221+
],
222+
})
223+
224+
// Clear all messages in the channel
225+
try {
226+
let fetched
227+
do {
228+
fetched = await channel.messages.fetch({ limit: 100 })
229+
if (fetched.size > 0) {
230+
await channel.bulkDelete(fetched, true)
231+
}
232+
} while (fetched.size >= 2)
233+
} catch (err) {
234+
console.error(`Failed to clear messages in channel ${channelId}:`, err)
235+
// Continue even if message deletion fails
236+
}
237+
238+
// Mark as available in database
239+
await pool.query(
240+
'UPDATE channel_pool SET in_use = false, match_id = null WHERE channel_id = $1',
241+
[channelId],
242+
)
243+
244+
console.log(`Returned channel ${channelId} to pool`)
245+
} catch (err) {
246+
console.error(`Error returning channel ${channelId} to pool:`, err)
247+
}
248+
}
249+
250+
/**
251+
* Maintains the channel pool by creating new channels if below minimum
252+
* Can be called periodically or after channels are used
253+
*/
254+
export async function maintainChannelPool(): Promise<void> {
255+
try {
256+
const result = await pool.query(
257+
'SELECT COUNT(*) FROM channel_pool WHERE in_use = false',
258+
)
259+
const availableCount = parseInt(result.rows[0].count)
260+
261+
if (availableCount >= MIN_POOL_SIZE) {
262+
return
263+
}
264+
265+
console.log(
266+
`Channel pool low (${availableCount}), creating more channels...`,
267+
)
268+
269+
const channelsToCreate = POOL_SIZE - availableCount
270+
const guild = await getGuild()
271+
272+
for (let i = 0; i < channelsToCreate; i++) {
273+
try {
274+
const channel = await guild.channels.create({
275+
name: `reserve-channel`,
276+
type: ChannelType.GuildText,
277+
parent: null, // No category for reserve channels
278+
permissionOverwrites: [
279+
{
280+
id: guild.roles.everyone,
281+
deny: [PermissionFlagsBits.ViewChannel],
282+
},
283+
],
284+
})
285+
286+
await pool.query(
287+
'INSERT INTO channel_pool (channel_id, in_use) VALUES ($1, false)',
288+
[channel.id],
289+
)
290+
291+
console.log(`Created maintenance channel ${i + 1}/${channelsToCreate}`)
292+
293+
// Wait 2 seconds between creations
294+
if (i < channelsToCreate - 1) {
295+
await new Promise((resolve) => setTimeout(resolve, 2000))
296+
}
297+
} catch (err) {
298+
console.error(`Failed to create maintenance channel ${i + 1}:`, err)
299+
}
300+
}
301+
} catch (err) {
302+
console.error('Error maintaining channel pool:', err)
303+
}
304+
}
305+
306+
/**
307+
* Cleans up orphaned channels (channels in pool that no longer exist in Discord)
308+
*/
309+
export async function cleanupChannelPool(): Promise<void> {
310+
try {
311+
const result = await pool.query('SELECT channel_id FROM channel_pool')
312+
const guild = await getGuild()
313+
314+
for (const row of result.rows) {
315+
try {
316+
await guild.channels.fetch(row.channel_id)
317+
} catch (err) {
318+
// Channel doesn't exist, remove from pool
319+
console.log(`Removing orphaned channel ${row.channel_id} from pool`)
320+
await pool.query('DELETE FROM channel_pool WHERE channel_id = $1', [
321+
row.channel_id,
322+
])
323+
}
324+
}
325+
} catch (err) {
326+
console.error('Error cleaning up channel pool:', err)
327+
}
328+
}

0 commit comments

Comments
 (0)