Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement multiple VoiceChannel support #52

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
152 changes: 152 additions & 0 deletions src/classes/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { Awaitable, ClientEvents, ClientOptions } from 'discord.js';
import { Client, Collection, Guild } from 'discord.js';
import EventEmitter from 'events';

export default class ClientManager extends EventEmitter {
public primaryClient?: Client<true>;
public secondaryClients: Client<true>[] = [];
#primaryToken;
#secondaryTokens;

/**
* Key: GuildId
* Value: WeakSet of usable Clients
* @type {Map<string,WeakSet<Client>>}
* @memberof ClientManager
*/
#freeClients: Collection<string, Client[]> = new Collection();
constructor(primaryToken: string, secondaryTokens: string[]) {
super();
this.#primaryToken = primaryToken;
this.#secondaryTokens = secondaryTokens;

this.on('ready', async (_, client) => {
(await client.guilds.fetch()).forEach((guild) => {
const c = this.#getClientArray(guild.id);
c.push(client);
});
});
this.on('guildCreate', (client, guild) => {
const c = this.#getClientArray(guild.id);
c.push(client);
});
this.on('guildDelete', (client, guild) => {
const c = this.#getClientArray(guild.id);
this.#freeClients.set(
guild.id,
c.filter((cn) => cn !== client)
);
});
}
public async loginPrimary(client: Client<true>) {
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);
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<K extends keyof ClientEvents>(
eventName: K,
...args: ClientEvents[K]
) {
managerEmit(eventName, client, ...args);
return originalEmit(eventName, ...args);
};
}
#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.every((t) => t.user?.id !== client.user?.id)
) {
c.push(client);
}
}
static getAltGuild(guild: Guild, client: Client) {
const n = { ...guild, client };
const d = Object.getOwnPropertyDescriptors(Guild.prototype);
Object.defineProperties(n, d);
return n as Guild;
}

public on<K extends keyof ClientEvents>(
event: K,
listener: (client: Client, ...args: ClientEvents[K]) => Awaitable<void>
): this;
public on(
eventName: string | symbol,
listener: (client: Client, ...args: unknown[]) => void
) {
return super.on(eventName, listener);
}

public once<K extends keyof ClientEvents>(
event: K,
listener: (client: Client, ...args: ClientEvents[K]) => Awaitable<void>
): this;
public once(
eventName: string | symbol,
listener: (client: Client, ...args: unknown[]) => void
) {
return super.once(eventName, listener);
}

public emit<K extends keyof ClientEvents>(
event: K,
client: Client,
...args: ClientEvents[K]
): boolean;
public emit(eventName: string | symbol, ...args: unknown[]) {
return super.emit(eventName, ...args);
}

public off<K extends keyof ClientEvents>(
event: K,
listener: (client: Client, ...args: ClientEvents[K]) => Awaitable<void>
): this;
public off(
eventName: string | symbol,
listener: (client: Client, ...args: unknown[]) => void
) {
return super.off(eventName, listener);
}

public removeAllListeners<K extends keyof ClientEvents>(event?: K): this;
public removeAllListeners(eventName?: string | symbol) {
return super.removeAllListeners(eventName);
}
}
56 changes: 36 additions & 20 deletions src/classes/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@discordjs/voice';
import type { GuildSettings } from '@prisma/client';
import {
Client,
Collection,
Guild,
GuildTextBasedChannel,
Expand All @@ -21,7 +22,10 @@ import {
VoiceBasedChannel,
} from 'discord.js';
import { Preprocessor, Speaker } from '.';
import { clientManager } from '../clientManager';
import { prisma } from '../database';
import ClientManager from './client';

import type { Readable } from 'stream';
/**
* represents one reading session.
Expand All @@ -47,10 +51,20 @@ export default class Room {
#player: AudioPlayer;
#preprocessor: Preprocessor;
#speakers: Collection<Snowflake, Speaker> = new Collection();
client: Client;

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.
Expand All @@ -64,10 +78,18 @@ export default class Room {
this.guild = voiceChannel.guild;
this.guildId = voiceChannel.guildId;

const client = clientManager.allocateClient(this.guildId);
if (!client || !client.user?.id) {
throw new Error('Could not find any usable client.');
}
this.client = client;
const guild = ClientManager.getAltGuild(this.guild, client);

this.#connection = joinVoiceChannel({
channelId: voiceChannel.id,
guildId: voiceChannel.guildId,
adapterCreator: voiceChannel.guild.voiceAdapterCreator,
adapterCreator: guild.voiceAdapterCreator,
group: client.user.id,
debug: true,
});

Expand Down Expand Up @@ -132,25 +154,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;
Expand All @@ -160,6 +164,17 @@ 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);
})
);
}
}
async reloadGuildDict() {
await this.#preprocessor.loadGuildDict();
}
Expand Down Expand Up @@ -216,5 +231,6 @@ export default class Room {
if (this.#connection.state.status !== VoiceConnectionStatus.Destroyed) {
this.#connection.destroy();
}
clientManager.freeClient(this.guildId, this.client);
}
}
23 changes: 23 additions & 0 deletions src/classes/speaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
silenceOnError,
synthesis,
} from 'node-openjtalk-binding-discordjs';
import { prisma } from '../database';

const voiceDir = './voice';

Expand Down Expand Up @@ -115,6 +116,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
*/
Expand Down
7 changes: 7 additions & 0 deletions src/clientManager.ts
Original file line number Diff line number Diff line change
@@ -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
);
25 changes: 23 additions & 2 deletions src/commands/end.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type {
import {
ApplicationCommandData,
ApplicationCommandOptionType,
ChannelType,
ChatInputCommandInteraction,
} from 'discord.js';
import rooms from '../rooms';
Expand All @@ -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],
},
],
};

/**
Expand All @@ -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)],
});
Expand Down
8 changes: 8 additions & 0 deletions src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ export async function handle(

const room = await rooms.create(voiceChannel, textChannel);

if (!room.client.user?.id) {
room.destroy();
throw new Error(
'ボットをボイスチャンネルに割り当てることに失敗しました。' +
'なお、このエラーは通常発生しません。開発者に連絡してください。'
);
}

const surpressed =
voiceChannel.type === ChannelType.GuildStageVoice &&
me.voice.suppress === false &&
Expand Down
7 changes: 7 additions & 0 deletions src/commands/voice/random.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export async function handle(
interaction.user
);
speaker.setRandomOptions();
await rooms.setSpeakerOption(
interaction.guildId,
interaction.user,
speaker.options
);

await prisma.member.update({
where: {
guildId_userId: {
Expand All @@ -37,6 +43,7 @@ export async function handle(
},
data: speaker.options,
});

await interaction.reply({
embeds: [new VoiceMessageEmbed('set', speaker.options)],
});
Expand Down
Loading