initibot
This commit is contained in:
commit
ca253f1b16
11 files changed
+2416
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DISCORD_TOKEN=
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
# Opcional
|
||||||
|
GEMINI_MODEL=gemini-2.5-flash
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
+17
@@ -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"]
|
||||||
Generated
+2025
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user