133 lines
5.3 KiB
TypeScript
133 lines
5.3 KiB
TypeScript
import {
|
|
AttachmentBuilder,
|
|
Client,
|
|
Events,
|
|
GatewayIntentBits,
|
|
MessageFlags,
|
|
SlashCommandBuilder,
|
|
type BaseMessageOptions,
|
|
} from 'discord.js';
|
|
import { VoiceConnectionStatus, entersState } from '@discordjs/voice';
|
|
import { Recording } from './recorder.js';
|
|
import { FALLBACK_MODEL, MODEL, generateNotes } from './gemini.js';
|
|
|
|
for (const key of ['DISCORD_TOKEN', 'GEMINI_API_KEY', 'GEMINI_MODEL']) {
|
|
if (!process.env[key]) throw new Error(`Falta la variable de entorno ${key}`);
|
|
}
|
|
|
|
const MAX_MINUTES = 30;
|
|
const sessions = new Map<string, Recording>();
|
|
|
|
const commands = [
|
|
new SlashCommandBuilder().setName('start').setDescription('Empieza a grabar tu canal de voz para tomar notas'),
|
|
new SlashCommandBuilder().setName('stop').setDescription('Termina la grabación y genera las notas'),
|
|
];
|
|
|
|
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates] });
|
|
|
|
function fileName(date: Date) {
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
return `notas-${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}.md`;
|
|
}
|
|
|
|
async function finish(guildId: string): Promise<BaseMessageOptions> {
|
|
const rec = sessions.get(guildId);
|
|
if (!rec) return { content: 'No hay ninguna grabación en curso.' };
|
|
sessions.delete(guildId);
|
|
try {
|
|
const tracks = await rec.stop();
|
|
if (tracks.length === 0) return { content: 'No se grabó nada de audio. 🤷' };
|
|
const notes = await generateNotes(tracks, rec.startedAt);
|
|
const minutes = Math.round((Date.now() - rec.startedAt.getTime()) / 60000);
|
|
return {
|
|
content: `📝 Notas listas (${minutes} min, ${tracks.map((t) => t.name).join(', ')})`,
|
|
files: [new AttachmentBuilder(Buffer.from(notes, 'utf8'), { name: fileName(rec.startedAt) })],
|
|
};
|
|
} catch (err) {
|
|
console.error(err);
|
|
return { content: `❌ Error generando las notas: ${(err as Error).message}` };
|
|
} finally {
|
|
await rec.cleanup();
|
|
}
|
|
}
|
|
|
|
// Para cortes automáticos (límite de tiempo, canal vacío, desconexión)
|
|
async function autoFinish(guildId: string, reason: string) {
|
|
const rec = sessions.get(guildId);
|
|
if (!rec) return;
|
|
await rec.textChannel.send(`⏹️ ${reason}. Generando notas…`).catch(() => {});
|
|
const msg = await finish(guildId);
|
|
await rec.textChannel.send(msg).catch(console.error);
|
|
}
|
|
|
|
client.once(Events.ClientReady, async (c) => {
|
|
await c.application.commands.set(commands.map((cmd) => cmd.toJSON()));
|
|
console.log(`ready as ${c.user.tag} | modelo: ${MODEL} | respaldo: ${FALLBACK_MODEL ?? '(ninguno)'}`);
|
|
});
|
|
|
|
client.on(Events.InteractionCreate, async (interaction) => {
|
|
if (!interaction.isChatInputCommand()) return;
|
|
console.log(`/${interaction.commandName} de ${interaction.user.tag} en guild ${interaction.guildId}`);
|
|
if (!interaction.inCachedGuild()) {
|
|
console.warn('guild no cacheada: ¿el bot está invitado con el scope "bot"?');
|
|
return interaction.reply({ content: 'El bot no está bien agregado a este servidor.', flags: MessageFlags.Ephemeral });
|
|
}
|
|
const { guildId } = interaction;
|
|
|
|
if (interaction.commandName === 'start') {
|
|
const voice = interaction.member.voice.channel;
|
|
if (!voice) {
|
|
return interaction.reply({ content: 'Entrá a un canal de voz primero.', flags: MessageFlags.Ephemeral });
|
|
}
|
|
if (sessions.has(guildId)) {
|
|
return interaction.reply({ content: 'Ya estoy grabando en este servidor.', flags: MessageFlags.Ephemeral });
|
|
}
|
|
if (!interaction.channel?.isSendable()) {
|
|
return interaction.reply({ content: 'No puedo escribir en este canal.', flags: MessageFlags.Ephemeral });
|
|
}
|
|
await interaction.deferReply();
|
|
try {
|
|
const rec = await Recording.start(voice, interaction.channel);
|
|
sessions.set(guildId, rec);
|
|
rec.timer = setTimeout(() => autoFinish(guildId, `Límite de ${MAX_MINUTES} min alcanzado`), MAX_MINUTES * 60_000);
|
|
rec.connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
|
try {
|
|
await Promise.race([
|
|
entersState(rec.connection, VoiceConnectionStatus.Signalling, 5_000),
|
|
entersState(rec.connection, VoiceConnectionStatus.Connecting, 5_000),
|
|
]);
|
|
} catch {
|
|
autoFinish(guildId, 'Me desconectaron del canal');
|
|
}
|
|
});
|
|
await interaction.editReply(`🔴 Grabando en ${voice}. Usá \`/stop\` para terminar y recibir las notas.`);
|
|
} catch (err) {
|
|
console.error(err);
|
|
await interaction.editReply('❌ No pude conectarme al canal de voz.');
|
|
}
|
|
}
|
|
|
|
if (interaction.commandName === 'stop') {
|
|
if (!sessions.has(guildId)) {
|
|
return interaction.reply({ content: 'No hay ninguna grabación en curso.', flags: MessageFlags.Ephemeral });
|
|
}
|
|
await interaction.deferReply();
|
|
await interaction.editReply(await finish(guildId));
|
|
}
|
|
});
|
|
|
|
// Si todos (menos bots) salen del canal, corta solo
|
|
client.on(Events.VoiceStateUpdate, (oldState) => {
|
|
const rec = sessions.get(oldState.guild.id);
|
|
if (!rec || oldState.channelId !== rec.voiceChannel.id) return;
|
|
if (rec.voiceChannel.members.every((m) => m.user.bot)) {
|
|
autoFinish(oldState.guild.id, 'Todos salieron del canal');
|
|
}
|
|
});
|
|
|
|
process.on('unhandledRejection', (err) => console.error('unhandledRejection:', err));
|
|
client.on(Events.Error, (err) => console.error('client error:', err));
|
|
|
|
console.log('iniciando bot…');
|
|
client.login(process.env.DISCORD_TOKEN);
|