Compare commits

...

9 Commits

Author SHA1 Message Date
neru 07da7538ba feat: add tts commands 2026-01-13 18:37:21 -03:00
neru 889b86b019 feat: add key methods 2026-01-13 18:37:11 -03:00
neru cfae674cb4 feat: implement guild_keys and user_keys 2026-01-13 18:37:06 -03:00
neru 7d0b5dc459 feat: implement db 2026-01-13 18:36:54 -03:00
neru af7c25e6ec style: run format:apply and cleanup unused imports 2026-01-13 18:36:40 -03:00
neru 9b4d1cbe24 feat: add (temp?) db mgr 2026-01-13 18:35:40 -03:00
neru aba00e3599 chore: add db.sqlite to ignore 2026-01-13 18:18:31 -03:00
neru 5f1a32dcb3 chore: add sequelize, sqlite3 2026-01-13 18:15:34 -03:00
neru 2ec7212cd3 style: run format:apply 2026-01-13 17:53:31 -03:00
17 changed files with 1853 additions and 99 deletions
+1
View File
@@ -1,4 +1,5 @@
node_modules/* node_modules/*
dist/* dist/*
db.sqlite
.env .env
+1339 -4
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -42,6 +42,8 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"node-audio-mixer": "^2.1.0", "node-audio-mixer": "^2.1.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prism-media": "^1.3.5" "prism-media": "^1.3.5",
"sequelize": "^6.37.7",
"sqlite3": "^5.1.7"
} }
} }
+7
View File
@@ -11,6 +11,7 @@ import {
import { Logger } from './utils/log'; import { Logger } from './utils/log';
import { config } from './utils/config'; import { config } from './utils/config';
import { CommandManager } from './commands'; import { CommandManager } from './commands';
import { DatabaseManager } from './modules/db';
type BotEventListeners = { type BotEventListeners = {
messageCreate: (message: Message) => void; messageCreate: (message: Message) => void;
@@ -46,6 +47,12 @@ export class Bot {
this.log.info('Loading commands'); this.log.info('Loading commands');
await this.cmdMgr.init(); await this.cmdMgr.init();
this.log.info('Configuring database');
DatabaseManager.get.init(
this.cmdMgr.getUserKeys(),
this.cmdMgr.getGuildKeys()
);
this.log.info('Instantiating client'); this.log.info('Instantiating client');
this.client = new Client({ this.client = new Client({
intents: [ intents: [
+21 -1
View File
@@ -22,8 +22,13 @@ import { isModule } from './utils/misc';
export interface Command { export interface Command {
name?: string; name?: string;
builder?: SlashCommandOptionsOnlyBuilder;
requiresAdmin?: boolean; requiresAdmin?: boolean;
ownerOnly?: boolean; ownerOnly?: boolean;
guild_keys?: Record<string, unknown>;
user_keys?: Record<string, unknown>;
execute?: (interaction: ChatInputCommandInteraction) => Promise<void>; execute?: (interaction: ChatInputCommandInteraction) => Promise<void>;
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void>; autocomplete?: (interaction: AutocompleteInteraction) => Promise<void>;
messageListener?: (msg: Message) => Promise<void>; messageListener?: (msg: Message) => Promise<void>;
@@ -31,7 +36,6 @@ export interface Command {
prevState: VoiceState, prevState: VoiceState,
newState: VoiceState newState: VoiceState
) => Promise<void>; ) => Promise<void>;
builder?: SlashCommandOptionsOnlyBuilder;
} }
export interface CommandCategoryInfo { export interface CommandCategoryInfo {
@@ -95,6 +99,22 @@ export class CommandManager {
return this.getAll().filter((cmd) => !!cmd.name); return this.getAll().filter((cmd) => !!cmd.name);
} }
public getGuildKeys(): Record<string, unknown> {
return this.mergeKeys('guild_keys');
}
public getUserKeys(): Record<string, unknown> {
return this.mergeKeys('user_keys');
}
private mergeKeys(
keyType: 'guild_keys' | 'user_keys'
): Record<string, unknown> {
return this.getAll()
.flatMap((cmd) => (cmd[keyType] ? [cmd[keyType]] : []))
.reduce((acc, val) => Object.assign(acc, val), {});
}
/* /*
internal internal
*/ */
+8
View File
@@ -0,0 +1,8 @@
import { CommandCategoryInfo } from '../../commands';
const info: CommandCategoryInfo = {
name: 'TTS',
description: 'Text to Speech related commands'
};
export default info;
+22
View File
@@ -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<void> => {
if (!interaction.guildId) {
interaction.reply('This command only works on Guilds');
return;
}
interaction.reply('Queue cleared.');
}
};
export default cmd;
+97
View File
@@ -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<void> => {
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();
+34
View File
@@ -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<void> => {
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;
+65
View File
@@ -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<void> => {
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<void> => {
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;
+76
View File
@@ -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<void> => {
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<void> => {
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;
+27 -14
View File
@@ -1,10 +1,19 @@
import { ChatInputCommandInteraction, GuildMember, SlashCommandBuilder } from "discord.js"; import {
import { Command } from "../../commands"; ChatInputCommandInteraction,
import { CreateVoiceConnectionOptions, getVoiceConnection, joinVoiceChannel, JoinVoiceChannelOptions } from "@discordjs/voice"; GuildMember,
SlashCommandBuilder
} from 'discord.js';
import { Command } from '../../commands';
import {
CreateVoiceConnectionOptions,
getVoiceConnection,
joinVoiceChannel,
JoinVoiceChannelOptions
} from '@discordjs/voice';
const builder = new SlashCommandBuilder() const builder = new SlashCommandBuilder()
.setName("join") .setName('join')
.setDescription("Makes the bot join your current voice channel"); .setDescription('Makes the bot join your current voice channel');
const cmd: Command = { const cmd: Command = {
name: builder.name, name: builder.name,
@@ -12,23 +21,27 @@ const cmd: Command = {
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => { execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
const member = interaction.member as GuildMember; const member = interaction.member as GuildMember;
if (!member || !interaction.guild) { if (!member || !interaction.guild) {
interaction.reply("This command only works on guilds"); interaction.reply('This command only works on guilds');
return; return;
} }
if (!member.voice.channelId) { if (!member.voice.channelId) {
interaction.reply("You are not currently on a voice channel"); interaction.reply('You are not currently on a voice channel');
return; return;
} }
const me = interaction.guild.members.me as GuildMember; const me = interaction.guild.members.me as GuildMember;
if (getVoiceConnection(interaction.guild.id) && me.voice.channelId === member.voice.channelId) { if (
interaction.reply("Already connected"); getVoiceConnection(interaction.guild.id) &&
me.voice.channelId === member.voice.channelId
) {
interaction.reply('Already connected');
return; return;
} }
const voiceOptions: JoinVoiceChannelOptions & CreateVoiceConnectionOptions = { const voiceOptions: JoinVoiceChannelOptions & CreateVoiceConnectionOptions =
{
channelId: member.voice.channelId, channelId: member.voice.channelId,
guildId: interaction.guild.id, guildId: interaction.guild.id,
adapterCreator: interaction.guild.voiceAdapterCreator adapterCreator: interaction.guild.voiceAdapterCreator
@@ -36,12 +49,12 @@ const cmd: Command = {
const connection = await joinVoiceChannel(voiceOptions); const connection = await joinVoiceChannel(voiceOptions);
if (!connection) { if (!connection) {
interaction.reply("Unable to join"); interaction.reply('Unable to join');
return return;
} }
interaction.reply("Joined"); interaction.reply('Joined');
}
} }
};
export default cmd; export default cmd;
+13 -9
View File
@@ -1,10 +1,14 @@
import { ChatInputCommandInteraction, GuildMember, SlashCommandBuilder } from "discord.js"; import {
import { Command } from "../../commands"; ChatInputCommandInteraction,
import { getVoiceConnection } from "@discordjs/voice"; GuildMember,
SlashCommandBuilder
} from 'discord.js';
import { Command } from '../../commands';
import { getVoiceConnection } from '@discordjs/voice';
const builder = new SlashCommandBuilder() const builder = new SlashCommandBuilder()
.setName("leave") .setName('leave')
.setDescription("Makes the bot leave its current voice channel"); .setDescription('Makes the bot leave its current voice channel');
const cmd: Command = { const cmd: Command = {
name: builder.name, name: builder.name,
@@ -12,21 +16,21 @@ const cmd: Command = {
execute: async (interaction: ChatInputCommandInteraction): Promise<void> => { execute: async (interaction: ChatInputCommandInteraction): Promise<void> => {
const member = interaction.member as GuildMember; const member = interaction.member as GuildMember;
if (!member || interaction.guild === null) { if (!member || interaction.guild === null) {
interaction.reply("This command only works on guilds"); interaction.reply('This command only works on guilds');
return; return;
} }
const connection = getVoiceConnection(interaction.guildId as string); const connection = getVoiceConnection(interaction.guildId as string);
if (!connection) { if (!connection) {
interaction.reply('currently not connected to a voice channel') interaction.reply('currently not connected to a voice channel');
return; return;
} }
connection.disconnect(); connection.disconnect();
connection.destroy(); connection.destroy();
interaction.reply("Disconnected"); interaction.reply('Disconnected');
}
} }
};
export default cmd; export default cmd;
+7 -10
View File
@@ -1,6 +1,6 @@
import { VoiceState } from "discord.js"; import { VoiceState } from 'discord.js';
import { Command } from "../../commands"; import { Command } from '../../commands';
import { getVoiceConnection } from "@discordjs/voice"; import { getVoiceConnection } from '@discordjs/voice';
const cmd: Command = { const cmd: Command = {
voiceStateListener: async function (oldState: VoiceState): Promise<void> { voiceStateListener: async function (oldState: VoiceState): Promise<void> {
@@ -11,18 +11,15 @@ const cmd: Command = {
if (!voiceConnection) return; if (!voiceConnection) return;
const me = guild.members.me; const me = guild.members.me;
if (!me) if (!me) return;
return;
if (!me.voice.channel) if (!me.voice.channel) return;
return;
if (me.voice.channel.members.size > 1) if (me.voice.channel.members.size > 1) return;
return;
voiceConnection.disconnect(); voiceConnection.disconnect();
voiceConnection.destroy(); voiceConnection.destroy();
} }
} };
export default cmd; export default cmd;
+73
View File
@@ -0,0 +1,73 @@
import {
DataTypes,
Model,
ModelStatic,
Sequelize
} from 'sequelize';
export class DatabaseManager {
private readonly db: Sequelize;
public guildData: ModelStatic<Model> | null = null;
public userData: ModelStatic<Model> | null = null;
private constructor() {
this.db = new Sequelize('db', 'luma', '', {
host: 'localhost',
dialect: 'sqlite',
storage: 'db.sqlite',
logging: false
});
this.db.sync();
}
public async init(userKeys: object, guildKeys: object) {
this.userData = this.db.define('user_data', {
user_id: { type: DataTypes.STRING, unique: true, primaryKey: true },
...userKeys
});
this.guildData = this.db.define('guild_data', {
guild_id: { type: DataTypes.STRING, unique: true, primaryKey: true },
...guildKeys
});
await this.db.sync({ alter: true });
}
public sync(): void {
this.db.sync();
}
public async getUser(userId: string) {
if (!this.userData) throw new Error('Database not initialized');
const [user] = await this.userData.findOrCreate({
where: { user_id: userId },
defaults: { user_id: userId }
});
return user;
}
public async getGuild(guildId: string) {
if (!this.guildData) throw new Error('Database not initialized');
const [guild] = await this.guildData.findOrCreate({
where: { guild_id: guildId },
defaults: { guild_id: guildId }
});
return guild;
}
/*
singleton logic
*/
static #instance: DatabaseManager | null = null;
public static get get(): DatabaseManager {
if (!DatabaseManager.#instance)
DatabaseManager.#instance = new DatabaseManager();
return DatabaseManager.#instance;
}
}