initibot
This commit is contained in:
commit
ca253f1b16
11 files changed
+2416
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import { GoogleGenAI, FileState, createPartFromUri, createUserContent, type File } from '@google/genai';
|
||||
import type { AudioTrack } from './recorder.js';
|
||||
import { buildPrompt } from './prompt.js';
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-flash';
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function upload(track: AudioTrack): Promise<File> {
|
||||
let file = await ai.files.upload({ file: track.file, config: { mimeType: 'audio/ogg', displayName: track.name } });
|
||||
while (file.state === FileState.PROCESSING) {
|
||||
await sleep(2000);
|
||||
file = await ai.files.get({ name: file.name! });
|
||||
}
|
||||
if (file.state === FileState.FAILED) throw new Error(`Gemini no pudo procesar el audio de ${track.name}`);
|
||||
return file;
|
||||
}
|
||||
|
||||
export async function generateNotes(tracks: AudioTrack[], startedAt: Date): Promise<string> {
|
||||
const files: File[] = [];
|
||||
try {
|
||||
for (const track of tracks) files.push(await upload(track));
|
||||
|
||||
const parts = tracks.flatMap((track, i) => [
|
||||
`Pista ${i + 1}: ${track.name}`,
|
||||
createPartFromUri(files[i].uri!, files[i].mimeType!),
|
||||
]);
|
||||
const res = await ai.models.generateContent({
|
||||
model: MODEL,
|
||||
contents: createUserContent([buildPrompt(tracks.map((t) => t.name), startedAt), ...parts]),
|
||||
});
|
||||
const text = res.text?.trim();
|
||||
if (!text) throw new Error('Gemini devolvió una respuesta vacía');
|
||||
return text.replace(/^```(?:markdown|md)?\n([\s\S]*)\n```$/, '$1');
|
||||
} finally {
|
||||
await Promise.allSettled(files.map((f) => ai.files.delete({ name: f.name! })));
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
AttachmentBuilder,
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
MessageFlags,
|
||||
SlashCommandBuilder,
|
||||
type BaseMessageOptions,
|
||||
} from 'discord.js';
|
||||
import { VoiceConnectionStatus, entersState } from '@discordjs/voice';
|
||||
import { Recording } from './recorder.js';
|
||||
import { generateNotes } from './gemini.js';
|
||||
|
||||
for (const key of ['DISCORD_TOKEN', 'GEMINI_API_KEY']) {
|
||||
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}`);
|
||||
});
|
||||
|
||||
client.on(Events.InteractionCreate, async (interaction) => {
|
||||
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) return;
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
client.login(process.env.DISCORD_TOKEN);
|
||||
@@ -0,0 +1,25 @@
|
||||
export function buildPrompt(speakers: string[], date: Date) {
|
||||
return `Sos un asistente que toma notas de reuniones, como "Tomar notas" de Gemini en Google Meet.
|
||||
|
||||
Te paso ${speakers.length} pista(s) de audio de una charla de Discord del ${date.toISOString().slice(0, 10)}.
|
||||
Cada pista es UNA persona (${speakers.join(', ')}). Todas empiezan en el mismo instante (t=0) y hay silencio cuando esa persona no habla, así que el tiempo dentro de la pista es el tiempo real de la reunión.
|
||||
|
||||
Escribí las notas en el MISMO idioma en que se habla en la charla. Devolvé solo Markdown, sin bloques de código, con esta estructura:
|
||||
|
||||
# Notas de la reunión
|
||||
|
||||
## Resumen
|
||||
Uno o dos párrafos con lo principal.
|
||||
|
||||
## Ideas
|
||||
- Lista de ideas o propuestas que surgieron (con quién la propuso si está claro).
|
||||
|
||||
## Tareas / próximos pasos
|
||||
- [ ] **Persona**: tarea (plazo si se mencionó)
|
||||
|
||||
## Transcripción
|
||||
Intercalá las pistas en orden cronológico, una línea por intervención:
|
||||
[mm:ss] **Nombre**: texto
|
||||
|
||||
Si una sección no tiene contenido, escribí "_Nada relevante._". No inventes información que no esté en el audio.`;
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
EndBehaviorType,
|
||||
VoiceConnection,
|
||||
VoiceConnectionStatus,
|
||||
entersState,
|
||||
joinVoiceChannel,
|
||||
} from '@discordjs/voice';
|
||||
import prism from 'prism-media';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createWriteStream, type WriteStream } from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { once } from 'node:events';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { SendableChannels, VoiceBasedChannel } from 'discord.js';
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
// PCM mono 16 kHz s16le: suficiente para voz y liviano en disco
|
||||
const RATE = 16000;
|
||||
const BYTES_PER_MS = (RATE * 2) / 1000;
|
||||
const MIN_TRACK_BYTES = BYTES_PER_MS * 1000;
|
||||
|
||||
interface Track {
|
||||
name: string;
|
||||
pcm: string;
|
||||
out: WriteStream;
|
||||
bytes: number;
|
||||
listening: boolean;
|
||||
}
|
||||
|
||||
export interface AudioTrack {
|
||||
name: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export class Recording {
|
||||
readonly startedAt = new Date();
|
||||
timer?: NodeJS.Timeout;
|
||||
private tracks = new Map<string, Track>();
|
||||
private stopped = false;
|
||||
|
||||
private constructor(
|
||||
readonly connection: VoiceConnection,
|
||||
readonly voiceChannel: VoiceBasedChannel,
|
||||
readonly textChannel: SendableChannels,
|
||||
readonly dir: string,
|
||||
) {}
|
||||
|
||||
static async start(voiceChannel: VoiceBasedChannel, textChannel: SendableChannels) {
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: voiceChannel.id,
|
||||
guildId: voiceChannel.guild.id,
|
||||
adapterCreator: voiceChannel.guild.voiceAdapterCreator,
|
||||
selfDeaf: false,
|
||||
selfMute: true,
|
||||
});
|
||||
try {
|
||||
await entersState(connection, VoiceConnectionStatus.Ready, 20_000);
|
||||
} catch (err) {
|
||||
connection.destroy();
|
||||
throw err;
|
||||
}
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'notes-'));
|
||||
const rec = new Recording(connection, voiceChannel, textChannel, dir);
|
||||
connection.receiver.speaking.on('start', (userId) => rec.listen(userId));
|
||||
return rec;
|
||||
}
|
||||
|
||||
private listen(userId: string) {
|
||||
if (this.stopped) return;
|
||||
const member = this.voiceChannel.guild.members.cache.get(userId);
|
||||
if (member?.user.bot) return;
|
||||
|
||||
let track = this.tracks.get(userId);
|
||||
if (track?.listening) return;
|
||||
if (!track) {
|
||||
const pcm = path.join(this.dir, `${userId}.pcm`);
|
||||
track = { name: member?.displayName ?? userId, pcm, out: createWriteStream(pcm), bytes: 0, listening: false };
|
||||
this.tracks.set(userId, track);
|
||||
}
|
||||
track.listening = true;
|
||||
this.padSilence(track);
|
||||
|
||||
const opus = this.connection.receiver.subscribe(userId, {
|
||||
end: { behavior: EndBehaviorType.AfterSilence, duration: 800 },
|
||||
});
|
||||
const decoder = new prism.opus.Decoder({ rate: RATE, channels: 1, frameSize: RATE / 50 });
|
||||
const t = track;
|
||||
opus.pipe(decoder)
|
||||
.on('data', (chunk: Buffer) => {
|
||||
if (this.stopped) return;
|
||||
t.out.write(chunk);
|
||||
t.bytes += chunk.length;
|
||||
})
|
||||
.on('error', (err) => console.warn(`decode ${t.name}:`, err.message));
|
||||
opus.once('end', () => (t.listening = false));
|
||||
}
|
||||
|
||||
// Rellena con silencio hasta "ahora" para que todas las pistas compartan el mismo t=0
|
||||
private padSilence(track: Track) {
|
||||
const target = Math.floor((Date.now() - this.startedAt.getTime()) * BYTES_PER_MS) & ~1;
|
||||
let missing = target - track.bytes;
|
||||
const chunk = Buffer.alloc(BYTES_PER_MS * 1000);
|
||||
while (missing > 0) {
|
||||
const n = Math.min(missing, chunk.length);
|
||||
track.out.write(chunk.subarray(0, n));
|
||||
track.bytes += n;
|
||||
missing -= n;
|
||||
}
|
||||
}
|
||||
|
||||
/** Corta la grabación y devuelve las pistas convertidas a .ogg */
|
||||
async stop(): Promise<AudioTrack[]> {
|
||||
this.stopped = true;
|
||||
clearTimeout(this.timer);
|
||||
this.connection.destroy();
|
||||
|
||||
const result: AudioTrack[] = [];
|
||||
for (const track of this.tracks.values()) {
|
||||
track.out.end();
|
||||
await once(track.out, 'finish');
|
||||
if (track.bytes < MIN_TRACK_BYTES) continue;
|
||||
const ogg = track.pcm.replace(/\.pcm$/, '.ogg');
|
||||
await run('ffmpeg', ['-y', '-loglevel', 'error', '-f', 's16le', '-ar', String(RATE), '-ac', '1',
|
||||
'-i', track.pcm, '-c:a', 'libopus', '-b:a', '24k', ogg]);
|
||||
result.push({ name: track.name, file: ogg });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
await rm(this.dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user