This commit is contained in:
juanmaldini7 committed 2026-09-28 11:42:07 -03:00
commit ca253f1b16
11 files changed
+2416

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
.git
+4
View File
@@ -0,0 +1,4 @@
DISCORD_TOKEN=
GEMINI_API_KEY=
# Opcional
GEMINI_MODEL=gemini-2.5-flash
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.env
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/index.js"]
+2025
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "discord-notes",
"version": "1.0.0",
"private": true,
"description": "Bot de Discord que graba el canal de voz y genera notas con Gemini",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx --env-file=.env src/index.ts"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"@discordjs/voice": "^0.19.2",
"@google/genai": "^2.24.0",
"discord.js": "^14.27.0",
"opusscript": "^0.0.8",
"prism-media": "^1.3.5"
},
"devDependencies": {
"@types/node": "^26.6.3",
"tsx": "^4.23.15",
"typescript": "^7.0.2"
}
}
+39
View File
@@ -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
View File
@@ -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);
+25
View File
@@ -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
View File
@@ -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 });
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}