From ef56cef5c0933447cffd093a2ac4d404b4d12ecc Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:17:21 +0900 Subject: [PATCH 01/11] [add]Add ClientManager --- src/classes/client.ts | 89 +++++++++++++++++++++++++++++++++++++++++++ src/clientManager.ts | 7 ++++ src/index.ts | 11 ++++-- src/utils.ts | 20 ++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 src/classes/client.ts create mode 100644 src/clientManager.ts diff --git a/src/classes/client.ts b/src/classes/client.ts new file mode 100644 index 0000000..0784b16 --- /dev/null +++ b/src/classes/client.ts @@ -0,0 +1,89 @@ +import type { ClientOptions } from 'discord.js'; +import { Client, Collection, Guild } from 'discord.js'; + +export default class ClientManager { + public primaryClient?: Client; + public secondaryClients: Client[] = []; + #primaryToken; + #secondaryTokens; + + /** + * Key: GuildId + * Value: WeakSet of usable Clients + * @type {Map>} + * @memberof ClientManager + */ + #freeClients: Collection = new Collection(); + constructor(primaryToken: string, secondaryTokens: string[]) { + this.#primaryToken = primaryToken; + this.#secondaryTokens = secondaryTokens; + } + public loginPrimary(client: Client) { + this.primaryClient = client; + this.#AddClientToFreeClients(client); + client.login(this.#primaryToken); + } + #AddClientToFreeClients(client: Client) { + client.on('ready', async (client: Client) => { + (await client.guilds.fetch()).forEach((guild) => { + const c = this.#getClientArray(guild.id); + c.push(client); + }); + }); + client.on('guildCreate', (guild) => { + const c = this.#getClientArray(guild.id); + c.push(client); + }); + client.on('guildDelete', (guild) => { + const c = this.#getClientArray(guild.id); + this.#freeClients.set( + guild.id, + c.filter((cn) => cn !== client) + ); + }); + } + public async instantiateSecondary(options: ClientOptions) { + this.secondaryClients = await Promise.all( + this.#secondaryTokens.map(async (token) => { + const client = new Client(options); + this.#AddClientToFreeClients(client); + client.login(token); + return client; + }) + ); + } + #getClientArray(guildId: string) { + let c = this.#freeClients.get(guildId); + if (!c) { + c = []; + this.#freeClients.set(guildId, c); + } + return c; + } + public destroy() { + this.secondaryClients.forEach((client) => { + client.destroy(); + }); + } + public allocateClient(guildId: string) { + const c = this.#getClientArray(guildId); + const alloc = c.shift(); + console.log('alloc:', alloc?.user?.username); + return alloc; + } + public freeClient(guildId: string, client: Client) { + const c = this.#getClientArray(guildId); + console.log('free:', client.user?.username); + if ( + this.primaryClient === client || + this.secondaryClients.some((c) => c === client) + ) { + c.push(client); + } + } + static async getAltGuild(guild: Guild, client: Client) { + return client.guilds.fetch({ + guild: guild.id, + }); + } +} diff --git a/src/clientManager.ts b/src/clientManager.ts new file mode 100644 index 0000000..5d453f7 --- /dev/null +++ b/src/clientManager.ts @@ -0,0 +1,7 @@ +import { env } from './utils'; +import ClientManager from './classes/client'; + +export const clientManager = new ClientManager( + env.BOT_TOKEN, + env.SECONDARY_BOT_TOKEN +); diff --git a/src/index.ts b/src/index.ts index 535f20f..76093b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,19 @@ import { Client, Intents } from 'discord.js'; -import { env } from './utils'; import * as handler from './handler'; +import { clientManager } from './clientManager'; -const client = new Client({ +const clientOptions = { intents: [ Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_VOICE_STATES, ], -}); +}; + +const client = new Client(clientOptions); client.on('ready', handler.ready); client.on('interactionCreate', handler.interaction); -client.login(env.BOT_TOKEN); +clientManager.loginPrimary(client); +clientManager.instantiateSecondary(clientOptions); diff --git a/src/utils.ts b/src/utils.ts index ef71b61..6ff3c94 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,6 +10,26 @@ if (process.env.NODE_ENV !== 'production') */ export const env = readenv({ BOT_TOKEN: {}, + SECONDARY_BOT_TOKEN: { + default: [] as string[], + parse: (s) => { + try { + const parsed = JSON.parse(s); + if ( + Array.isArray(parsed) && + parsed.every((t) => typeof t === 'string') + ) { + // 'as' assertion; parsed.every above guarantees this + return parsed as string[]; + } else { + return []; + } + } catch (e) { + console.error(e); + return []; + } + }, + }, GUILD_ID: {}, production: { from: 'NODE_ENV', From 92b4f7e92c2e551acfc126a4229afbfa2efb2606 Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:20:27 +0900 Subject: [PATCH 02/11] [add]Use Client from ClientManager in room --- src/classes/room.ts | 67 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/src/classes/room.ts b/src/classes/room.ts index 93ec526..9254fdd 100644 --- a/src/classes/room.ts +++ b/src/classes/room.ts @@ -2,14 +2,15 @@ import { AudioPlayer, AudioPlayerStatus, AudioResource, - DiscordGatewayAdapterCreator, entersState, + getGroups, joinVoiceChannel, NoSubscriberBehavior, VoiceConnection, VoiceConnectionStatus, } from '@discordjs/voice'; import { + Client, Collection, Guild, GuildTextBasedChannel, @@ -19,7 +20,9 @@ import { VoiceBasedChannel, } from 'discord.js'; import { Preprocessor, Speaker } from '.'; +import { clientManager } from '../clientManager'; import { prisma } from '../database'; +import ClientManager from './client'; /** * represents one reading session. @@ -37,12 +40,15 @@ export default class Room { */ guildId: Snowflake; - #connection: VoiceConnection; + #connection?: VoiceConnection; #messageCollector: MessageCollector; #queue: AudioResource[] = []; #player: AudioPlayer; #preprocessor: Preprocessor; #speakers: Collection = new Collection(); + #allocatedClient?: Client; + + #joinVCPromise; constructor( /** @@ -57,15 +63,6 @@ export default class Room { this.guild = voiceChannel.guild; this.guildId = voiceChannel.guildId; - this.#connection = joinVoiceChannel({ - channelId: voiceChannel.id, - guildId: voiceChannel.guildId, - // needs cast: https://github.com/discordjs/discord.js/issues/7273 - adapterCreator: voiceChannel.guild - .voiceAdapterCreator as unknown as DiscordGatewayAdapterCreator, - debug: true, - }); - this.#player = new AudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Stop, @@ -77,8 +74,6 @@ export default class Room { if (state.status === AudioPlayerStatus.Idle) this.#play(); }); - this.#connection.subscribe(this.#player); - this.#preprocessor = new Preprocessor(this); this.#messageCollector = textChannel.createMessageCollector({ @@ -86,6 +81,8 @@ export default class Room { message.cleanContent !== '' && !message.cleanContent.startsWith(';'), }); + this.#joinVCPromise = this.#joinVC(this.#player); + this.#messageCollector.on('collect', async (message) => { const speaker = await this.getOrCreateSpeaker(message.author); @@ -97,19 +94,54 @@ export default class Room { }); process.on('SIGINT', () => { - if (this.#connection.state.status !== VoiceConnectionStatus.Destroyed) + if ( + this.#connection && + this.#connection.state.status !== VoiceConnectionStatus.Destroyed + ) this.#connection.destroy(); process.exit(0); }); } + async #joinVC(player: AudioPlayer) { + const client = (this.#allocatedClient = clientManager.allocateClient( + this.guildId + )); + if (!client || !client.user?.id) { + return Promise.reject(new Error('Could not find any usable client.')); + } + const guild = await ClientManager.getAltGuild(this.guild, client); + + const groups = getGroups(); + const userConnections = groups.get(client.user.id); + if (userConnections) { + groups.set('default', userConnections); + } else { + const newUserConnections = new Map(); + groups.set(client.user.id, newUserConnections); + groups.set('default', newUserConnections); + } + + this.#connection = joinVoiceChannel({ + channelId: this.voiceChannel.id, + guildId: this.voiceChannel.guildId, + adapterCreator: guild.voiceAdapterCreator, + debug: true, + }); + + this.#connection.subscribe(player); + return this.#connection; + } + /** * @returns promise that resolves when bot successfully connected * and rejects when bot could connect within 2 secs. */ async ready() { await Promise.all([ - entersState(this.#connection, VoiceConnectionStatus.Ready, 2000), + this.#joinVCPromise.then((connection: VoiceConnection) => { + return entersState(connection, VoiceConnectionStatus.Ready, 2000); + }), this.#preprocessor.dictLoadPromise, ]); return; @@ -166,7 +198,10 @@ export default class Room { * disconnects from voice channel and stop collecting messages. */ destroy() { - this.#connection.destroy(); + this.#connection?.destroy(); this.#messageCollector.stop(); + if (this.#allocatedClient) { + clientManager.freeClient(this.guildId, this.#allocatedClient); + } } } From e378f35aacf2ccea69ccfe5e3ed26965c1bb5ab3 Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Thu, 10 Feb 2022 19:30:52 +0900 Subject: [PATCH 03/11] [update]Store multiple rooms per guild in rooms --- src/classes/room.ts | 8 ++++---- src/commands/cancel.ts | 6 +++--- src/commands/dict/delete.ts | 6 +++--- src/commands/dict/set.ts | 6 +++--- src/commands/end.ts | 6 ++++-- src/commands/start.ts | 20 ++++++++++++++++++-- src/commands/voice/get.ts | 2 +- src/commands/voice/random.ts | 23 +++++++++++++++++------ src/commands/voice/set.ts | 22 ++++++++++++++++------ src/rooms.ts | 6 ++++-- 10 files changed, 73 insertions(+), 32 deletions(-) diff --git a/src/classes/room.ts b/src/classes/room.ts index 9254fdd..5d1ce91 100644 --- a/src/classes/room.ts +++ b/src/classes/room.ts @@ -46,7 +46,7 @@ export default class Room { #player: AudioPlayer; #preprocessor: Preprocessor; #speakers: Collection = new Collection(); - #allocatedClient?: Client; + allocatedClient?: Client; #joinVCPromise; @@ -104,7 +104,7 @@ export default class Room { } async #joinVC(player: AudioPlayer) { - const client = (this.#allocatedClient = clientManager.allocateClient( + const client = (this.allocatedClient = clientManager.allocateClient( this.guildId )); if (!client || !client.user?.id) { @@ -200,8 +200,8 @@ export default class Room { destroy() { this.#connection?.destroy(); this.#messageCollector.stop(); - if (this.#allocatedClient) { - clientManager.freeClient(this.guildId, this.#allocatedClient); + if (this.allocatedClient) { + clientManager.freeClient(this.guildId, this.allocatedClient); } } } diff --git a/src/commands/cancel.ts b/src/commands/cancel.ts index 974c9e2..e68ea3a 100644 --- a/src/commands/cancel.ts +++ b/src/commands/cancel.ts @@ -23,10 +23,10 @@ export const permissions: ApplicationCommandPermissions[] = []; */ export async function handle(interaction: CommandInteraction<'cached'>) { try { - const room = rooms.get(interaction.guildId); - if (!room) throw new Error('現在読み上げ中ではありません。'); + const roomCollection = rooms.get(interaction.guildId); + if (!roomCollection) throw new Error('現在読み上げ中ではありません。'); - room.cancel(); + roomCollection.each((room) => room.cancel()); await interaction.reply({ content: '読み上げを中断しました。', ephemeral: true, diff --git a/src/commands/dict/delete.ts b/src/commands/dict/delete.ts index ed64fdf..0384bba 100644 --- a/src/commands/dict/delete.ts +++ b/src/commands/dict/delete.ts @@ -57,9 +57,9 @@ export async function handle(interaction: CommandInteraction<'cached'>) { throw new Error('絵文字以外の単語登録は実装されていません。'); } - const room = rooms.get(interaction.guildId); - if (room) { - await room.reloadEmojiDict(); + const roomCollection = rooms.get(interaction.guildId); + if (roomCollection) { + await roomCollection.each((room) => room.reloadEmojiDict()); } } catch (e) { await interaction.reply({ diff --git a/src/commands/dict/set.ts b/src/commands/dict/set.ts index 569616b..857f054 100644 --- a/src/commands/dict/set.ts +++ b/src/commands/dict/set.ts @@ -63,9 +63,9 @@ export async function handle(interaction: CommandInteraction<'cached'>) { throw new Error('データベースへの登録に失敗しました。'); }); - const room = rooms.get(interaction.guildId); - if (room) { - await room.reloadEmojiDict(); + const roomCollection = rooms.get(interaction.guildId); + if (roomCollection) { + await roomCollection.each((room) => room.reloadEmojiDict()); } } else { throw new Error('絵文字以外の単語登録は実装されていません。'); diff --git a/src/commands/end.ts b/src/commands/end.ts index 930ba3d..d4528ba 100644 --- a/src/commands/end.ts +++ b/src/commands/end.ts @@ -24,11 +24,13 @@ export const permissions: ApplicationCommandPermissions[] = []; */ export async function handle(interaction: CommandInteraction<'cached'>) { try { - const room = rooms.get(interaction.guildId); + const roomCollection = rooms.get(interaction.guildId); + const clientId = roomCollection?.firstKey(); + const room = roomCollection && clientId && roomCollection?.get(clientId); if (!room) throw new Error('現在読み上げ中ではありません。'); room.destroy(); - rooms.delete(interaction.guildId); + roomCollection.delete(clientId); await interaction.reply({ embeds: [new EndMessageEmbed(room)], }); diff --git a/src/commands/start.ts b/src/commands/start.ts index 1c28363..27471e8 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -1,7 +1,9 @@ -import type { +import { ApplicationCommandData, ApplicationCommandPermissions, + Collection, CommandInteraction, + Snowflake, } from 'discord.js'; import rooms from '../rooms'; import { Room } from '../classes'; @@ -52,6 +54,14 @@ export async function handle(interaction: CommandInteraction<'cached'>) { throw new Error('ボイスチャンネルへの接続時にエラーが発生しました。'); }); + if (!room.allocatedClient?.user?.id) { + room.destroy(); + throw new Error( + 'ボットをボイスチャンネルに割り当てることに失敗しました。' + + 'なお、このエラーは通常発生しません。開発者に連絡してください。' + ); + } + const surpressed = voiceChannel.type === 'GUILD_STAGE_VOICE' && me.voice.suppress && @@ -60,10 +70,16 @@ export async function handle(interaction: CommandInteraction<'cached'>) { () => true )); + const roomCollection = + rooms.get(interaction.guildId) ?? new Collection(); + roomCollection.set(room.allocatedClient.user.id, room); + if (!rooms.has(interaction.guildId)) { + rooms.set(interaction.guildId, roomCollection); + } + await interaction.reply({ embeds: [new StartMessageEmbed(room, surpressed)], }); - rooms.set(interaction.guildId, room); } catch (e) { await interaction.reply({ embeds: [new ErrorMessageEmbed('読み上げを開始できませんでした。', e)], diff --git a/src/commands/voice/get.ts b/src/commands/voice/get.ts index b0edaa3..399ac0b 100644 --- a/src/commands/voice/get.ts +++ b/src/commands/voice/get.ts @@ -19,7 +19,7 @@ export const data: ApplicationCommandSubCommandData = { */ export async function handle(interaction: CommandInteraction<'cached'>) { try { - const room = rooms.get(interaction.guildId); + const room = rooms.get(interaction.guildId)?.first(); if (!room) throw new Error('現在読み上げ中ではありません。'); const speaker = await room.getOrCreateSpeaker(interaction.user); diff --git a/src/commands/voice/random.ts b/src/commands/voice/random.ts index 6c8c46b..5bc4436 100644 --- a/src/commands/voice/random.ts +++ b/src/commands/voice/random.ts @@ -20,11 +20,21 @@ export const data: ApplicationCommandSubCommandData = { */ export async function handle(interaction: CommandInteraction<'cached'>) { try { - const room = rooms.get(interaction.guildId); - if (!room) throw new Error('現在読み上げ中ではありません。'); + const roomCollection = rooms.get(interaction.guildId); + const roomR = roomCollection?.first(); + if (!roomCollection || !roomR) + throw new Error('現在読み上げ中ではありません。'); + + const speakerR = await roomR.getOrCreateSpeaker(interaction.user); + speakerR.setRandomOptions(); + + await Promise.all( + roomCollection.map(async (room) => { + const speaker = await room.getOrCreateSpeaker(interaction.user); + speaker.options = speakerR.options; + }) + ); - const speaker = await room.getOrCreateSpeaker(interaction.user); - speaker.setRandomOptions(); await prisma.member.update({ where: { guildId_userId: { @@ -32,10 +42,11 @@ export async function handle(interaction: CommandInteraction<'cached'>) { userId: interaction.user.id, }, }, - data: speaker.options, + data: speakerR.options, }); + await interaction.reply({ - embeds: [new VoiceMessageEmbed('set', speaker.options)], + embeds: [new VoiceMessageEmbed('set', speakerR.options)], }); } catch (e) { await interaction.reply({ diff --git a/src/commands/voice/set.ts b/src/commands/voice/set.ts index b90d3cf..c2ac420 100644 --- a/src/commands/voice/set.ts +++ b/src/commands/voice/set.ts @@ -54,16 +54,26 @@ export const data: ApplicationCommandSubCommandData = { */ export async function handle(interaction: CommandInteraction<'cached'>) { try { - const room = rooms.get(interaction.guildId); - if (!room) throw new Error('現在読み上げ中ではありません。'); + const roomCollection = rooms.get(interaction.guildId); + const roomFirst = roomCollection?.first(); + if (!roomCollection || !roomFirst) + throw new Error('現在読み上げ中ではありません。'); - const speaker = await room.getOrCreateSpeaker(interaction.user); - speaker.options = { + const speakerFirst = await roomFirst.getOrCreateSpeaker(interaction.user); + speakerFirst.options = { htsvoice: interaction.options.getString('htsvoice') ?? undefined, tone: interaction.options.getNumber('tone') ?? undefined, speed: interaction.options.getNumber('speed') ?? undefined, f0: interaction.options.getNumber('f0') ?? undefined, }; + + Promise.all( + roomCollection.map(async (room) => { + const speaker = await room.getOrCreateSpeaker(interaction.user); + speaker.options = speakerFirst.options; + }) + ); + await prisma.member.update({ where: { guildId_userId: { @@ -71,10 +81,10 @@ export async function handle(interaction: CommandInteraction<'cached'>) { userId: interaction.user.id, }, }, - data: speaker.options, + data: speakerFirst.options, }); await interaction.reply({ - embeds: [new VoiceMessageEmbed('set', speaker.options)], + embeds: [new VoiceMessageEmbed('set', speakerFirst.options)], }); } catch (e) { await interaction.reply({ diff --git a/src/rooms.ts b/src/rooms.ts index 59a5a53..4aff88f 100644 --- a/src/rooms.ts +++ b/src/rooms.ts @@ -2,8 +2,10 @@ import { Collection, type Snowflake } from 'discord.js'; import type { Room } from './classes'; /** - * {@link Collection} of rooms in current process + * {@link Collection} of + * '{@link Collection} of rooms in current process + * with clientId used for connecting to voice channel as key' * with its guildId as key. */ -const rooms = new Collection(); +const rooms = new Collection>(); export default rooms; From 7eb3a258d34c8297ce6dfabb375421cfdce2bd3d Mon Sep 17 00:00:00 2001 From: CallMe AsYouFeel <75649254+cm-ayf@users.noreply.github.com> Date: Thu, 10 Feb 2022 19:47:01 +0900 Subject: [PATCH 04/11] asserts instead of asserts --- src/utils.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 53fafe6..075e371 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,6 +5,11 @@ if (process.env.NODE_ENV !== 'production') // eslint-disable-next-line @typescript-eslint/no-var-requires require('dotenv').config(); +function assertsStringArray(target: unknown): asserts target is string[] { + if (!Array.isArray(target) || !target.every((t) => typeof t === 'string')) + throw new Error('not a string[]'); +} + /** * environment variables that are in use; always load from here */ @@ -15,15 +20,8 @@ export const env = readenv({ parse: (s) => { try { const parsed = JSON.parse(s); - if ( - Array.isArray(parsed) && - parsed.every((t) => typeof t === 'string') - ) { - // 'as' assertion; parsed.every above guarantees this - return parsed as string[]; - } else { - return []; - } + assertsStringArray(parsed); + return parsed; } catch (e) { console.error(e); return []; From b38d67370810646ab8c6b6ec6daccf710c5d40dc Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Thu, 10 Feb 2022 21:13:30 +0900 Subject: [PATCH 05/11] [update]Refactor speaker --- src/classes/room.ts | 33 +++++++++++++-------------------- src/classes/speaker.ts | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/classes/room.ts b/src/classes/room.ts index bbe5bc3..e5f9a65 100644 --- a/src/classes/room.ts +++ b/src/classes/room.ts @@ -19,7 +19,6 @@ import { } from 'discord.js'; import { Preprocessor, Speaker } from '.'; import { EndMessageEmbed } from '../components'; -import { prisma } from '../database'; /** * represents one reading session. @@ -143,25 +142,7 @@ export default class Room { let speaker = this.getSpeaker(user.id); if (!speaker) { speaker = new Speaker(user, true); - const options = await prisma.member.findUnique({ - where: { - guildId_userId: { - guildId: this.guildId, - userId: user.id, - }, - }, - }); - if (options) { - speaker.options = options; - } else { - await prisma.member.create({ - data: { - guildId: this.guildId, - userId: user.id, - ...speaker.options, - }, - }); - } + await speaker.fetchOptions(this.guildId); this.#speakers.set(user.id, speaker); } return speaker; @@ -171,6 +152,18 @@ export default class Room { await this.#preprocessor.loadEmojiDict(); } + async reloadSpeakOptions(user?: User) { + if (user) { + await this.#speakers.get(user.id)?.fetchOptions(this.guildId); + } else { + await Promise.all( + this.#speakers.map((speaker) => { + speaker.fetchOptions(this.guildId); + }) + ); + } + } + #play() { if (this.#player.state.status === AudioPlayerStatus.Idle) { const resource = this.#queue.shift(); diff --git a/src/classes/speaker.ts b/src/classes/speaker.ts index 828211f..7c057ed 100644 --- a/src/classes/speaker.ts +++ b/src/classes/speaker.ts @@ -7,6 +7,7 @@ import { silenceOnError, synthesis, } from 'node-openjtalk-binding-discordjs'; +import { prisma } from '../database'; const voiceDir = './voice'; @@ -116,6 +117,28 @@ export default class Speaker { this.#options.htsvoice = options.htsvoice; } + public async fetchOptions(guildId: Snowflake) { + const options = await prisma.member.findUnique({ + where: { + guildId_userId: { + guildId, + userId: this.user.id, + }, + }, + }); + if (options) { + this.options = options; + } else { + await prisma.member.create({ + data: { + guildId, + userId: this.user.id, + ...this.options, + }, + }); + } + } + /** * randomly set options passed to Open JTalk */ From 3ca00ce0e74486f90eef9eb9330d915970a17b9f Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Thu, 17 Feb 2022 14:25:40 +0900 Subject: [PATCH 06/11] [fix]Do not free/destroy twice --- src/classes/client.ts | 5 +++-- src/classes/room.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/classes/client.ts b/src/classes/client.ts index 0784b16..9ee68bd 100644 --- a/src/classes/client.ts +++ b/src/classes/client.ts @@ -75,8 +75,9 @@ export default class ClientManager { const c = this.#getClientArray(guildId); console.log('free:', client.user?.username); if ( - this.primaryClient === client || - this.secondaryClients.some((c) => c === client) + (this.primaryClient === client || + this.secondaryClients.some((c) => c === client)) && + c.every((t) => t.user?.id !== client.user?.id) ) { c.push(client); } diff --git a/src/classes/room.ts b/src/classes/room.ts index 33c9618..43ecadb 100644 --- a/src/classes/room.ts +++ b/src/classes/room.ts @@ -220,7 +220,12 @@ export default class Room { * disconnects from voice channel and stop collecting messages. */ destroy() { - this.#connection?.destroy(); + if ( + this.#connection && + this.#connection.state.status !== VoiceConnectionStatus.Destroyed + ) { + this.#connection.destroy(); + } this.#messageCollector.stop(); if (this.allocatedClient) { clientManager.freeClient(this.guildId, this.allocatedClient); From 78bab37147c5ef863dbc75fb179e04f621c2d660 Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Mon, 28 Mar 2022 07:29:13 +0900 Subject: [PATCH 07/11] [update]Synchronously join voiceChat --- src/classes/client.ts | 9 ++--- src/classes/room.ts | 78 +++++++++++++++++++++---------------------- 2 files changed, 44 insertions(+), 43 deletions(-) diff --git a/src/classes/client.ts b/src/classes/client.ts index 9ee68bd..a6a6778 100644 --- a/src/classes/client.ts +++ b/src/classes/client.ts @@ -82,9 +82,10 @@ export default class ClientManager { c.push(client); } } - static async getAltGuild(guild: Guild, client: Client) { - return client.guilds.fetch({ - guild: guild.id, - }); + static getAltGuild(guild: Guild, client: Client) { + const n = { ...guild, client }; + const d = Object.getOwnPropertyDescriptors(Guild.prototype); + Object.defineProperties(n, d); + return n; } } diff --git a/src/classes/room.ts b/src/classes/room.ts index 10616e8..1bca776 100644 --- a/src/classes/room.ts +++ b/src/classes/room.ts @@ -45,7 +45,7 @@ export default class Room { */ guildId: Snowflake; - #connection?: VoiceConnection; + #connection: VoiceConnection; #messageCollector: MessageCollector; #synthesizing = 0; #synthesisQueue: (() => Readable)[] = []; @@ -55,11 +55,18 @@ export default class Room { #speakers: Collection = new Collection(); allocatedClient?: Client; - #joinVCPromise; guildSettings?: GuildSettings; #loadGuildSettingsPromise; + /** + * Creates an instance of Room. + * Responsible for freeing allocated client, + * when an exception is thrown. + * @param {VoiceBasedChannel} voiceChannel + * @param {GuildTextBasedChannel} textChannel + * @memberof Room + */ constructor( /** * voice channel that this room is bound to. @@ -73,6 +80,31 @@ export default class Room { this.guild = voiceChannel.guild; this.guildId = voiceChannel.guildId; + const client = (this.allocatedClient = clientManager.allocateClient( + this.guildId + )); + if (!client || !client.user?.id) { + throw new Error('Could not find any usable client.'); + } + const guild = ClientManager.getAltGuild(this.guild, client); + + const groups = getGroups(); + const userConnections = groups.get(client.user.id); + if (userConnections) { + groups.set('default', userConnections); + } else { + const newUserConnections = new Map(); + groups.set(client.user.id, newUserConnections); + groups.set('default', newUserConnections); + } + + this.#connection = joinVoiceChannel({ + channelId: voiceChannel.id, + guildId: voiceChannel.guildId, + adapterCreator: guild.voiceAdapterCreator, + debug: true, + }); + this.#player = new AudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Stop, @@ -84,6 +116,8 @@ export default class Room { if (state.status === AudioPlayerStatus.Idle) this.#play(); }); + this.#connection.subscribe(this.#player); + this.#preprocessor = new Preprocessor(this); this.#loadGuildSettingsPromise = this.loadGuildSettings(); @@ -93,8 +127,8 @@ export default class Room { oldState.guild.id === voiceChannel.guildId && oldState.channelId === voiceChannel.id && newState.channelId === null && //disconnect - voiceChannel.client.user?.id && - voiceChannel.members.has(voiceChannel.client.user?.id) && + client.user?.id && + voiceChannel.members.has(client.user?.id) && voiceChannel.members.size === 1 ) { //no member now. leaving the channel. @@ -117,8 +151,6 @@ export default class Room { !message.author.bot, }); - this.#joinVCPromise = this.#joinVC(this.#player); - this.#messageCollector.on('collect', async (message) => { if (!this.guildSettings) return; const speaker = await this.getOrCreateSpeaker(message.author); @@ -144,45 +176,13 @@ export default class Room { }); } - async #joinVC(player: AudioPlayer) { - const client = (this.allocatedClient = clientManager.allocateClient( - this.guildId - )); - if (!client || !client.user?.id) { - return Promise.reject(new Error('Could not find any usable client.')); - } - const guild = await ClientManager.getAltGuild(this.guild, client); - - const groups = getGroups(); - const userConnections = groups.get(client.user.id); - if (userConnections) { - groups.set('default', userConnections); - } else { - const newUserConnections = new Map(); - groups.set(client.user.id, newUserConnections); - groups.set('default', newUserConnections); - } - - this.#connection = joinVoiceChannel({ - channelId: this.voiceChannel.id, - guildId: this.voiceChannel.guildId, - adapterCreator: guild.voiceAdapterCreator, - debug: true, - }); - - this.#connection.subscribe(player); - return this.#connection; - } - /** * @returns promise that resolves when bot successfully connected * and rejects when bot could connect within 2 secs. */ async ready() { await Promise.all([ - this.#joinVCPromise.then((connection: VoiceConnection) => { - return entersState(connection, VoiceConnectionStatus.Ready, 2000); - }), + entersState(this.#connection, VoiceConnectionStatus.Ready, 2000), this.#preprocessor.dictLoadPromise, this.#loadGuildSettingsPromise, ]); From 99a2e83d816b6eebc6d2ed0acae9a8522b9b471e Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Sat, 2 Apr 2022 09:54:05 +0900 Subject: [PATCH 08/11] [add]Add EventEmitter to ClientManager --- src/classes/client.ts | 95 +++++++++++++++++++++++++++++++++++-------- src/index.ts | 3 +- 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/classes/client.ts b/src/classes/client.ts index a6a6778..0fb86f2 100644 --- a/src/classes/client.ts +++ b/src/classes/client.ts @@ -1,9 +1,10 @@ -import type { ClientOptions } from 'discord.js'; +import type { Awaitable, ClientEvents, ClientOptions } from 'discord.js'; import { Client, Collection, Guild } from 'discord.js'; +import EventEmitter from 'events'; -export default class ClientManager { - public primaryClient?: Client; - public secondaryClients: Client[] = []; +export default class ClientManager extends EventEmitter { + public primaryClient?: Client; + public secondaryClients: Client[] = []; #primaryToken; #secondaryTokens; @@ -15,26 +16,21 @@ export default class ClientManager { */ #freeClients: Collection = new Collection(); constructor(primaryToken: string, secondaryTokens: string[]) { + super(); this.#primaryToken = primaryToken; this.#secondaryTokens = secondaryTokens; - } - public loginPrimary(client: Client) { - this.primaryClient = client; - this.#AddClientToFreeClients(client); - client.login(this.#primaryToken); - } - #AddClientToFreeClients(client: Client) { - client.on('ready', async (client: Client) => { + + this.on('ready', async (_, client) => { (await client.guilds.fetch()).forEach((guild) => { const c = this.#getClientArray(guild.id); c.push(client); }); }); - client.on('guildCreate', (guild) => { + this.on('guildCreate', (client, guild) => { const c = this.#getClientArray(guild.id); c.push(client); }); - client.on('guildDelete', (guild) => { + this.on('guildDelete', (client, guild) => { const c = this.#getClientArray(guild.id); this.#freeClients.set( guild.id, @@ -42,16 +38,34 @@ export default class ClientManager { ); }); } + public async loginPrimary(client: Client) { + this.primaryClient = await this.#login(client, this.#primaryToken); + } public async instantiateSecondary(options: ClientOptions) { this.secondaryClients = await Promise.all( this.#secondaryTokens.map(async (token) => { const client = new Client(options); - this.#AddClientToFreeClients(client); - client.login(token); - return client; + return this.#login(client, token); }) ); } + async #login(client: Client, token: string) { + client.emit = this.#altEmit(client); + await client.login(token); + //When login resolve, webSocket is ready. + return client; + } + #altEmit(client: Client) { + const managerEmit = this.emit.bind(this); + const originalEmit = client.emit.bind(client); + return function emit( + eventName: K, + ...args: ClientEvents[K] + ) { + managerEmit(eventName, client, ...args); + return originalEmit(eventName, ...args); + }; + } #getClientArray(guildId: string) { let c = this.#freeClients.get(guildId); if (!c) { @@ -88,4 +102,51 @@ export default class ClientManager { Object.defineProperties(n, d); return n; } + + public on( + event: K, + listener: (client: Client, ...args: ClientEvents[K]) => Awaitable + ): this; + public on( + eventName: string | symbol, + listener: (client: Client, ...args: unknown[]) => void + ) { + return super.on(eventName, listener); + } + + public once( + event: K, + listener: (client: Client, ...args: ClientEvents[K]) => Awaitable + ): this; + public once( + eventName: string | symbol, + listener: (client: Client, ...args: unknown[]) => void + ) { + return super.once(eventName, listener); + } + + public emit( + event: K, + client: Client, + ...args: ClientEvents[K] + ): boolean; + public emit(eventName: string | symbol, ...args: unknown[]) { + return super.emit(eventName, ...args); + } + + public off( + event: K, + listener: (client: Client, ...args: ClientEvents[K]) => Awaitable + ): this; + public off( + eventName: string | symbol, + listener: (client: Client, ...args: unknown[]) => void + ) { + return super.off(eventName, listener); + } + + public removeAllListeners(event?: K): this; + public removeAllListeners(eventName?: string | symbol) { + return super.removeAllListeners(eventName); + } } diff --git a/src/index.ts b/src/index.ts index d99b44a..6ebfe6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,6 @@ client.on('ready', handler.ready); client.on('interactionCreate', handler.interaction); client.on('guildCreate', handler.guild); client.on('roleDelete', handler.roleDelete); -//client.on('voiceStateUpdate', handler.voiceStateUpdate); process.on('exit', handler.onExit); process.on('SIGINT', handler.onExit); @@ -24,5 +23,7 @@ process.on('SIGUSR1', handler.onExit); process.on('SIGUSR2', handler.onExit); process.on('uncaughtException', handler.onExit); +clientManager.on('voiceStateUpdate', handler.voiceStateUpdate); + clientManager.loginPrimary(client); clientManager.instantiateSecondary(clientOptions); From 4afc5a16cc7fa4acd56bce088165e67019639d45 Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Sat, 2 Apr 2022 10:13:11 +0900 Subject: [PATCH 09/11] [fix]Client switch in onVoiceStateUpdate --- src/handler.ts | 10 ++++++++-- src/rooms.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/handler.ts b/src/handler.ts index efc8699..5118b10 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -1,4 +1,4 @@ -import type { Client, Guild, Interaction, Role } from 'discord.js'; +import type { Client, Guild, Interaction, Role, VoiceState } from 'discord.js'; import commands from './commands'; import rooms from './rooms'; @@ -25,7 +25,13 @@ export async function roleDelete(role: Role) { commands.checkRole(role.guild).catch(console.error); } -export const voiceStateUpdate = rooms.onVoiceStateUpdate; +export async function voiceStateUpdate( + client: Client, + oldState: VoiceState, + newState: VoiceState +) { + await rooms.onVoiceStateUpdate(client, oldState, newState); +} export function onExit() { try { diff --git a/src/rooms.ts b/src/rooms.ts index 74eb485..416d734 100644 --- a/src/rooms.ts +++ b/src/rooms.ts @@ -81,8 +81,8 @@ export class RoomManager { const room = this.cache.get(newState.guild.id)?.get(client.user.id); if (!room) return; if ( - room.voiceChannel.client.user?.id && - !room.voiceChannel.members.has(room.voiceChannel.client.user?.id) + room.client.user?.id && + !room.voiceChannel.members.has(room.client.user?.id) ) { this.destroy(newState.guild.id); await room.textChannel.send({ @@ -92,8 +92,8 @@ export class RoomManager { if ( oldState.channelId === room.voiceChannel.id && newState.channelId === null && //disconnect - room.voiceChannel.client.user?.id && - room.voiceChannel.members.has(room.voiceChannel.client.user?.id) && + room.client.user?.id && + room.voiceChannel.members.has(room.client.user?.id) && room.voiceChannel.members.size === 1 ) { this.destroy(newState.guild.id); @@ -108,7 +108,9 @@ export class RoomManager { const room = this.cache.get(guildId)?.first(); if (!room) throw new Error('現在読み上げ中ではありません。'); room.destroy(); - this.cache.delete(guildId); + if (room.client.user?.id) { + this.cache.get(guildId)?.delete(room.client.user?.id); + } return room; } public destroyAll() { From 1ecc9a44b59af8535e5536aec0584bcbd9bbc46f Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Wed, 27 Apr 2022 08:11:40 +0900 Subject: [PATCH 10/11] [clean]remove voice group hack(voice v0.9.0) --- src/classes/room.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/classes/room.ts b/src/classes/room.ts index 89ceb5a..ee27ee7 100644 --- a/src/classes/room.ts +++ b/src/classes/room.ts @@ -5,7 +5,6 @@ import { AudioPlayerStatus, AudioResource, entersState, - getGroups, joinVoiceChannel, NoSubscriberBehavior, VoiceConnection, @@ -86,20 +85,11 @@ export default class Room { this.client = client; const guild = ClientManager.getAltGuild(this.guild, client); - const groups = getGroups(); - const userConnections = groups.get(client.user.id); - if (userConnections) { - groups.set('default', userConnections); - } else { - const newUserConnections = new Map(); - groups.set(client.user.id, newUserConnections); - groups.set('default', newUserConnections); - } - this.#connection = joinVoiceChannel({ channelId: voiceChannel.id, guildId: voiceChannel.guildId, adapterCreator: guild.voiceAdapterCreator, + group: client.user.id, debug: true, }); From a42d91473ee6f5078ec76cfbb17557193df4199a Mon Sep 17 00:00:00 2001 From: femshima <49227365+femshima@users.noreply.github.com> Date: Mon, 1 Aug 2022 12:28:35 +0900 Subject: [PATCH 11/11] fix bugs --- src/classes/client.ts | 2 +- src/commands/end.ts | 25 +++++++++++++++++++++++-- src/index.ts | 12 ++++++------ src/rooms.ts | 20 +++++++++----------- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/classes/client.ts b/src/classes/client.ts index 0fb86f2..9aa575e 100644 --- a/src/classes/client.ts +++ b/src/classes/client.ts @@ -100,7 +100,7 @@ export default class ClientManager extends EventEmitter { const n = { ...guild, client }; const d = Object.getOwnPropertyDescriptors(Guild.prototype); Object.defineProperties(n, d); - return n; + return n as Guild; } public on( diff --git a/src/commands/end.ts b/src/commands/end.ts index 0df726b..0116543 100644 --- a/src/commands/end.ts +++ b/src/commands/end.ts @@ -1,5 +1,7 @@ -import type { +import { ApplicationCommandData, + ApplicationCommandOptionType, + ChannelType, ChatInputCommandInteraction, } from 'discord.js'; import rooms from '../rooms'; @@ -11,6 +13,15 @@ import { EndMessageEmbed, ErrorMessageEmbed } from '../components'; export const data: ApplicationCommandData = { name: 'end', description: '読み上げを終了し、ボイスチャンネルから退出します。', + options: [ + { + name: 'vc', + type: ApplicationCommandOptionType.Channel, + description: + 'ボイスチャンネル。あなたがどこのチャンネルにも入っていない場合、指定必須です。', + channelTypes: [ChannelType.GuildStageVoice, ChannelType.GuildVoice], + }, + ], }; /** @@ -20,7 +31,17 @@ export async function handle( interaction: ChatInputCommandInteraction<'cached'> ) { try { - const room = rooms.destroy(interaction.guildId); + const voiceChannel = + interaction.options.getChannel('vc') ?? interaction.member.voice.channel; + if (!voiceChannel?.isVoiceBased()) + throw new Error('ボイスチャンネルを指定してください。'); + const clientId = rooms.cache + .get(interaction.guildId) + ?.find((_, clientId) => voiceChannel.members.has(clientId))?.client + .user?.id; + if (!clientId) + throw new Error('ボイスチャンネルにボットが参加していません。'); + const room = rooms.destroy(interaction.guildId, clientId); await interaction.reply({ embeds: [new EndMessageEmbed(room)], }); diff --git a/src/index.ts b/src/index.ts index 9967c17..226514b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,21 +2,18 @@ import { Client, GatewayIntentBits } from 'discord.js'; import * as handler from './handler'; import { clientManager } from './clientManager'; -const clientOptions = { +const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildVoiceStates, ], -}; - -const client = new Client(clientOptions); +}); client.on('ready', handler.ready); client.on('interactionCreate', handler.interaction); client.on('guildCreate', handler.guild); -client.on('voiceStateUpdate', handler.voiceStateUpdate); process.on('exit', handler.onExit); process.on('SIGINT', handler.onExit); @@ -27,4 +24,7 @@ process.on('uncaughtException', handler.onExit); clientManager.on('voiceStateUpdate', handler.voiceStateUpdate); clientManager.loginPrimary(client); -clientManager.instantiateSecondary(clientOptions); + +clientManager.instantiateSecondary({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates], +}); diff --git a/src/rooms.ts b/src/rooms.ts index e4fb24a..1fd76eb 100644 --- a/src/rooms.ts +++ b/src/rooms.ts @@ -80,11 +80,8 @@ export class RoomManager { ) { const room = this.cache.get(newState.guild.id)?.get(client.user.id); if (!room) return; - if ( - room.client.user?.id && - !room.voiceChannel.members.has(room.client.user?.id) - ) { - this.destroy(newState.guild.id); + if (!room.voiceChannel.members.has(client.user.id)) { + this.destroy(newState.guild.id, client.user.id); await room.textChannel.send({ embeds: [new EndMessageEmbed(room, '切断されたため、')], }); @@ -92,11 +89,10 @@ export class RoomManager { if ( oldState.channelId === room.voiceChannel.id && newState.channelId !== room.voiceChannel.id && //disconnect or channel switch - room.client.user?.id && - room.voiceChannel.members.has(room.client.user?.id) && + room.voiceChannel.members.has(client.user.id) && room.voiceChannel.members.size === 1 ) { - this.destroy(newState.guild.id); + this.destroy(newState.guild.id, client.user.id); await room.textChannel.send({ embeds: [ new EndMessageEmbed(room, 'ボイスチャンネルに誰もいなくなったため、'), @@ -104,8 +100,8 @@ export class RoomManager { }); } } - public destroy(guildId: Snowflake) { - const room = this.cache.get(guildId)?.first(); + public destroy(guildId: Snowflake, clientUserId: Snowflake) { + const room = this.cache.get(guildId)?.get(clientUserId); if (!room) throw new Error('現在読み上げ中ではありません。'); room.destroy(); if (room.client.user?.id) { @@ -114,7 +110,9 @@ export class RoomManager { return room; } public destroyAll() { - this.cache.forEach((_, guildId) => this.destroy(guildId)); + this.cache.forEach((coll, guildId) => + coll.forEach((_, clientUserId) => this.destroy(guildId, clientUserId)) + ); } }