diff --git a/assets/gyokuon/gyokuon.mp3 b/assets/gyokuon/gyokuon.mp3 new file mode 100644 index 00000000..44b84e8c Binary files /dev/null and b/assets/gyokuon/gyokuon.mp3 differ diff --git a/src/server/index.ts b/src/server/index.ts index dd8c8467..789d5e84 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -45,6 +45,7 @@ import { DiscordRoleManager } from '../adaptor/discord/role.js'; import { DiscordSheriff } from '../adaptor/discord/sheriff.js'; import { DiscordWS } from '../adaptor/discord/ws.js'; import { GenVersionFetcher } from '../adaptor/version/fetch.js'; +import type { GyokuonAssetKey } from '../service/command/gyokuon.js'; import type { KaereMusicKey } from '../service/command/kaere.js'; import type { Snowflake } from '../model/id.js'; import dotenv from 'dotenv'; @@ -137,16 +138,16 @@ if (features.includes('COMMAND')) { registerAllCommandResponder({ typoRepo, reservationRepo, - factory: new DiscordVoiceConnectionFactory( - client, - { - COFFIN_INTRO: join('assets', 'party', 'coffin-intro.mp3'), - COFFIN_DROP: join('assets', 'party', 'coffin-drop.mp3'), - KAKAPO: join('assets', 'party', 'kakapo.mp3'), - KAKUSIN_DAISUKE: join('assets', 'party', 'kakusin-daisuke.mp3'), - NEROYO: join('assets', 'kaere', 'neroyo.mp3') - } - ), + factory: new DiscordVoiceConnectionFactory< + AssetKey | KaereMusicKey | GyokuonAssetKey + >(client, { + COFFIN_INTRO: join('assets', 'party', 'coffin-intro.mp3'), + COFFIN_DROP: join('assets', 'party', 'coffin-drop.mp3'), + KAKAPO: join('assets', 'party', 'kakapo.mp3'), + KAKUSIN_DAISUKE: join('assets', 'party', 'kakusin-daisuke.mp3'), + NEROYO: join('assets', 'kaere', 'neroyo.mp3'), + GYOKUON: join('assets', 'gyokuon', 'gyokuon.mp3') + }), clock, scheduleRunner, random: new MathRandomGenerator(), diff --git a/src/service/command.ts b/src/service/command.ts index 5f1d83ec..2ec0ead1 100644 --- a/src/service/command.ts +++ b/src/service/command.ts @@ -8,6 +8,7 @@ import { DebugCommand, MessageRepository } from './command/debug.js'; import { DiceCommand, DiceQueen } from './command/dice.js'; import { GetVersionCommand, VersionFetcher } from './command/version.js'; import { GuildInfo, GuildStatsRepository } from './command/guild-info.js'; +import { GyokuonAssetKey, GyokuonCommand } from './command/gyokuon.js'; import { JudgingCommand, RandomGenerator } from './command/judging.js'; import { KaereCommand, @@ -54,7 +55,7 @@ export const registerAllCommandResponder = ({ }: { typoRepo: TypoRepository; reservationRepo: ReservationRepository; - factory: VoiceConnectionFactory; + factory: VoiceConnectionFactory; clock: Clock; scheduleRunner: ScheduleRunner; random: PartyRng & RandomGenerator; @@ -82,6 +83,10 @@ export const registerAllCommandResponder = ({ scheduleRunner, repo: reservationRepo }), + new GyokuonCommand({ + connectionFactory: factory, + controller: roomController + }), new JudgingCommand(random), new Meme(), new HelpCommand(commandRunner), diff --git a/src/service/command/gyokuon.test.ts b/src/service/command/gyokuon.test.ts new file mode 100644 index 00000000..0e3543d5 --- /dev/null +++ b/src/service/command/gyokuon.test.ts @@ -0,0 +1,21 @@ +import { GyokuonAssetKey, GyokuonCommand } from './gyokuon.js'; +import { it, vi } from 'vitest'; + +import { MockVoiceConnectionFactory } from '../../adaptor/index.js'; +import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; + +it('use case of gyokuon', async () => { + const fn = vi.fn(); + const connectionFactory = new MockVoiceConnectionFactory(); + const responder = new GyokuonCommand({ + connectionFactory, + controller: { + disconnectAllUsersIn: fn + } + }); + + await responder.on( + createMockMessage(parseStringsOrThrow(['gyokuon'], responder.schema)) + ); +}); diff --git a/src/service/command/gyokuon.ts b/src/service/command/gyokuon.ts new file mode 100644 index 00000000..e4392a29 --- /dev/null +++ b/src/service/command/gyokuon.ts @@ -0,0 +1,77 @@ +import type { + CommandMessage, + CommandResponder, + HelpInfo +} from './command-message.js'; +import type { Schema } from '../../model/command-schema.js'; +import type { Snowflake } from '../../model/id.js'; +import type { VoiceConnectionFactory } from '../voice-connection.js'; +import type { VoiceRoomController } from './kaere.js'; + +export type GyokuonAssetKey = 'GYOKUON'; + +const SCHEMA = { + names: ['gyokuon'], + subCommands: {} +} as const satisfies Schema; + +/** + * gyokuon コマンドでこるくの玉音放送をボイスチャンネルに再生する機能 + * + * @export + */ +export class GyokuonCommand implements CommandResponder { + help: Readonly = { + title: 'こるくの玉音放送', + description: + 'VC内にこるくの玉音放送を再生するよ。引数無しで即起動。どの方式でもコマンド発行者がVCに居ないと動かないよ。' + }; + readonly schema = SCHEMA; + + constructor( + private readonly deps: { + connectionFactory: VoiceConnectionFactory; + controller: VoiceRoomController; + } + ) {} + + async on(message: CommandMessage): Promise { + const roomId = message.senderVoiceChannelId; + if (!roomId) { + await message.reply({ + title: 'Gyokuon安全装置が作動したよ。', + description: + '起動した本人がボイスチャンネルに居ないのでキャンセルしておいた。悪く思わないでね。' + }); + return; + } + + await this.start(message.senderGuildId, roomId); + return; + } + + // 玉音放送がすでに行われているか + private doingGyokuon = false; + private async start(guildId: Snowflake, roomId: Snowflake): Promise { + if (this.doingGyokuon) { + return; + } + + this.doingGyokuon = true; + const connectionVC = await this.deps.connectionFactory.connectTo( + guildId, + roomId + ); + + connectionVC.connect(); + connectionVC.onDisconnected(() => { + this.doingGyokuon = false; + return false; + }); + + await connectionVC.playToEnd('GYOKUON'); + + connectionVC.destroy(); + this.doingGyokuon = false; + } +}