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(); 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 { 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);