diff --git a/src/commands/tts/_category.ts b/src/commands/tts/_category.ts new file mode 100644 index 0000000..941870f --- /dev/null +++ b/src/commands/tts/_category.ts @@ -0,0 +1,8 @@ +import { CommandCategoryInfo } from '../../commands'; + +const info: CommandCategoryInfo = { + name: 'TTS', + description: 'Text to Speech related commands' +}; + +export default info; diff --git a/src/commands/tts/clear.ts b/src/commands/tts/clear.ts new file mode 100644 index 0000000..9923889 --- /dev/null +++ b/src/commands/tts/clear.ts @@ -0,0 +1,22 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; +import { Command } from '../../commands'; + +const builder = new SlashCommandBuilder() + .setName('tts-clear') + .setDescription('Clears the TTS queue'); + +const cmd: Command = { + name: builder.name, + builder: builder, + requiresAdmin: true, + execute: async (interaction: ChatInputCommandInteraction): Promise => { + if (!interaction.guildId) { + interaction.reply('This command only works on Guilds'); + return; + } + + interaction.reply('Queue cleared.'); + } +}; + +export default cmd; diff --git a/src/commands/tts/messageListener.ts b/src/commands/tts/messageListener.ts new file mode 100644 index 0000000..cb7a67f --- /dev/null +++ b/src/commands/tts/messageListener.ts @@ -0,0 +1,97 @@ +import { Message } from 'discord.js'; +import { Command } from '../../commands'; +import { Logger } from '../../utils/log'; +import { getVoiceConnection, VoiceConnectionStatus } from '@discordjs/voice'; +import { TTSManager } from '../../modules/tts'; +import { AudioStreamManager } from '../../modules/audiostreams'; +import { Readable } from 'stream'; +import { DataTypes } from 'sequelize'; +import { config } from '../../utils/config'; +import { DatabaseManager } from '../../modules/db'; + +const URL_REGEX = /(?:https?|ftp):\/\/[\n\S]+/g; + +class TTSListener implements Command { + private log: Logger; + + constructor() { + this.log = new Logger('TTS Listener'); + } + + user_keys = { + tts_voice: { + type: DataTypes.STRING, + defaultValue: config.tts_default_voice + }, + tts_mode: { + type: DataTypes.STRING, + defaultValue: config.tts_default_mode + } + }; + + guild_keys = { + tts_channel: { + type: DataTypes.STRING + } + }; + + messageListener = async (msg: Message): Promise => { + if (msg.content.length === 0) return; + + const guild = msg.guild; + if (!guild) return; + + const me = guild.members.me; + if (!me || !me.voice) return; + + const member = msg.member; + if (!member || !member.voice) return; + + if (member.voice.channelId !== me.voice.channelId) return; + + const voiceConnection = getVoiceConnection(guild.id); + if ( + !voiceConnection || + voiceConnection.state.status !== VoiceConnectionStatus.Ready + ) + return; + + const db = await DatabaseManager.get; + + const guildData = await db.getGuild(guild.id); + if (msg.channelId !== guildData.get('tts_channel')) return; + + const userData = await db.getUser(member.id); + + const modName = userData.get('tts_mode') as string; + const voiceName = userData.get('tts_voice') as string; + + const processTTS = async () => { + try { + const ttsModule = TTSManager.get.getModule(modName); + if (!ttsModule) return; + + const voices = await ttsModule.getVoices(); + if (!voices) return; + if (!voices.includes(voiceName)) return; + + const msgFiltered = msg.content.replace(URL_REGEX, 'a link'); + + const audio = await ttsModule.generate(voiceName, msgFiltered); + + if (audio?.data) { + const stream = + AudioStreamManager.get.getOrCreateStream(voiceConnection); + const queue = stream.getQueue('TTS'); + queue.enqueue(Readable.from(audio.data)); + } + } catch (err) { + this.log.error('error occurred while processing TTS message (%s)', err); + } + }; + + processTTS(); + }; +} + +export default new TTSListener(); diff --git a/src/commands/tts/setchannel.ts b/src/commands/tts/setchannel.ts new file mode 100644 index 0000000..dc88cf3 --- /dev/null +++ b/src/commands/tts/setchannel.ts @@ -0,0 +1,34 @@ +import { + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder +} from 'discord.js'; +import { Command } from '../../commands'; +import { DatabaseManager } from '../../modules/db'; + +const builder = new SlashCommandBuilder() + .setName('tts-channel') + .setDescription('Sets the channel where TTS messages will be read from'); + +const cmd: Command = { + name: builder.name, + builder: builder, + + execute: async (interaction: ChatInputCommandInteraction): Promise => { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + if (!interaction.guild) { + interaction.editReply('This message can only be executed on guilds'); + return; + } + + const guildData = await DatabaseManager.get.getGuild(interaction.guild.id); + + await guildData.set('tts_channel', interaction.channelId); + await guildData.save(); + + interaction.editReply('TTS channel updated.'); + } +}; + +export default cmd; diff --git a/src/commands/tts/setmode.ts b/src/commands/tts/setmode.ts new file mode 100644 index 0000000..6deb396 --- /dev/null +++ b/src/commands/tts/setmode.ts @@ -0,0 +1,65 @@ +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder +} from 'discord.js'; +import { Command } from '../../commands'; +import { DatabaseManager } from '../../modules/db'; +import { TTSManager } from '../../modules/tts'; + +const builder = new SlashCommandBuilder() + .setName('tts-mode') + .setDescription('Selects a mode to use for TTS') + .addStringOption((opt) => + opt + .setName('mode') + .setDescription('Which TTS provider to use') + .setAutocomplete(true) + .setRequired(true) + ); + +const cmd: Command = { + name: builder.name, + builder: builder, + + execute: async (interaction: ChatInputCommandInteraction): Promise => { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const userData = await DatabaseManager.get.getUser(interaction.user.id); + const modeName = interaction.options.getString('mode', true); + const selectedMode = TTSManager.get + .getModules() + .find((mode) => mode.name === modeName); + + if (!selectedMode) { + await interaction.editReply(`Unknown mode (${modeName})`); + return; + } + + await userData.set('tts_mode', modeName); + await userData.save(); + + interaction.editReply(`TTS mode has been set to: ${modeName}.`); + }, + + autocomplete: async (interaction: AutocompleteInteraction): Promise => { + const focused = interaction.options.getFocused(true); + if (focused.name != 'mode') return; + + const modes = TTSManager.get.getModules(); + + const filtered: string[] = modes + .filter((mode) => + mode.name.toLowerCase().startsWith(focused.value.toLowerCase()) + ) + .map((mode) => mode.name) + .slice(0, 25); + + await interaction.respond( + filtered.map((choice) => ({ name: choice, value: choice })) + ); + } +}; + +export default cmd; diff --git a/src/commands/tts/setvoice.ts b/src/commands/tts/setvoice.ts new file mode 100644 index 0000000..ea74f69 --- /dev/null +++ b/src/commands/tts/setvoice.ts @@ -0,0 +1,76 @@ +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder +} from 'discord.js'; +import { Command } from '../../commands'; +import { DatabaseManager } from '../../modules/db'; +import { TTSManager } from '../../modules/tts'; + +const builder = new SlashCommandBuilder() + .setName('tts-voice') + .setDescription('Selects a voice to use for TTS') + .addStringOption((opt) => + opt + .setName('voice') + .setDescription('Which voice to use') + .setAutocomplete(true) + .setRequired(true) + ); + +const cmd: Command = { + name: builder.name, + builder: builder, + + execute: async (interaction: ChatInputCommandInteraction): Promise => { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const userData = await DatabaseManager.get.getUser(interaction.user.id); + const mod = TTSManager.get.getModule(userData.get('tts_mode') as string); + if (!mod) return; + + const voices = await mod.getVoices(); + if (!voices) { + await interaction.editReply( + 'Unknown error occured while fetching TTS voices' + ); + return; + } + + const voiceName = interaction.options.getString('voice', true); + if (!voices.includes(voiceName)) { + await interaction.editReply('Invalid voice'); + return; + } + + await userData.set('tts_voice', voiceName); + await userData.save(); + + interaction.editReply(`TTS voice has been set to: ${voiceName}.`); + }, + autocomplete: async (interaction: AutocompleteInteraction): Promise => { + const focused = interaction.options.getFocused(true); + if (focused.name != 'voice') return; + + const userData = await DatabaseManager.get.getUser(interaction.user.id); + + const mode = TTSManager.get.getModule(userData.get('tts_mode') as string); + if (!mode) return; + + const voices: string[] | undefined = await mode.getVoices(); + if (!voices) return; + + const filtered: string[] = voices + .filter((voice) => + voice.toLowerCase().startsWith(focused.value.toLowerCase()) + ) + .slice(0, 25); + + await interaction.respond( + filtered.map((choice) => ({ name: choice, value: choice })) + ); + } +}; + +export default cmd;