import 'dotenv/config'; import { Client, GatewayIntentBits, ChannelType, Events } from 'discord.js'; import { joinVoiceChannel, EndBehaviorType } from '@discordjs/voice'; import { writeFile } from 'fs/promises'; import { existsSync, mkdirSync, unlinkSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import { homedir } from 'os'; import { spawn } from 'child_process'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SAMPLE_RATE = 48000; const CHANNELS = 2; function expandPath(p) { if (p.startsWith('~')) { return homedir() + p.slice(1); } return p; } function getEnv(key, required = true) { const value = process.env[key]; if (!value && required) { throw new Error(`Missing required env var: ${key}`); } return value || ''; } function getEnvList(key) { const value = getEnv(key); return value.split(',').map(s => s.trim()).filter(Boolean); } async function transcribeWithOnnxAsr(wavPath) { return new Promise((resolve) => { const python = spawn('python3', [ '-c', ` import sys import os os.environ['ORT_LOGGING_LEVEL'] = '4' sys.path.insert(0, '/usr/lib/hyprwhspr/lib/src') import onnx_asr model = onnx_asr.load_model('nemo-parakeet-tdt-0.6b-v3', quantization='int8') with open('${wavPath}', 'rb') as f: result = model.transcribe(f.read()) print(result if isinstance(result, str) else result.get('text', '')) `.trim(), ]); let output = ''; let error = ''; python.stdout.on('data', (data) => { output += data.toString(); }); python.stderr.on('data', (data) => { error += data.toString(); }); python.on('close', (code) => { if (code !== 0) { console.error(`[TRANSCRIBE] onnx_asr failed: ${error.slice(0, 200)}`); resolve(''); return; } resolve(output.trim()); }); }); } class MeetingNotesBot { client; discordToken; targetUsers; transcriptsDir; openclawGatewayUrl; guildId; activeSession = null; audioBuffers = new Map(); userIdToName = new Map(); checkInterval = null; connection = null; sessionSegments = []; constructor() { this.discordToken = getEnv('discordToken'); this.targetUsers = getEnvList('targetUsers'); this.transcriptsDir = expandPath(getEnv('transcriptsDir')); this.openclawGatewayUrl = getEnv('openclawGatewayUrl'); this.guildId = getEnv('guildId'); if (!existsSync(this.transcriptsDir)) { mkdirSync(this.transcriptsDir, { recursive: true }); } this.client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildMessages, ], }); this.setupEventHandlers(); } setupEventHandlers() { this.client.on(Events.ClientReady, () => { console.log(`[INFO] Logged in as ${this.client.user?.tag}`); this.startMonitoring(); }); this.client.on(Events.VoiceStateUpdate, (oldState, newState) => { this.handleVoiceStateChange(oldState, newState); }); } startMonitoring() { console.log('[INFO] Starting VC monitoring...'); console.log(`[INFO] Watching for users: ${this.targetUsers.join(', ')}`); this.checkInterval = setInterval(() => { this.checkVoiceChannels(); }, 5000); this.checkVoiceChannels(); } async checkVoiceChannels() { if (this.activeSession) return; const guild = this.client.guilds.cache.get(this.guildId); if (!guild) { console.log('[WARN] Guild not found'); return; } const voiceChannels = guild.channels.cache.filter((ch) => ch.type === ChannelType.GuildVoice); for (const [, channel] of voiceChannels) { const members = channel.members; if (!members) continue; const presentTargets = this.targetUsers.filter((id) => members.has(id)); if (presentTargets.length === this.targetUsers.length) { console.log(`[INFO] All target users in "${channel.name}", joining...`); await this.joinAndRecord(channel.id, channel.name, guild, members); break; } } } handleVoiceStateChange(oldState, newState) { if (!this.activeSession) return; const userId = newState.id; if (!this.targetUsers.includes(userId)) return; const leftChannel = oldState.channelId === this.activeSession.channelId && !newState.channelId; const switchedChannel = oldState.channelId === this.activeSession.channelId && newState.channelId !== this.activeSession.channelId; if (leftChannel || switchedChannel) { console.log(`[INFO] Target user left the channel`); this.checkShouldEndSession(); } } checkShouldEndSession() { if (!this.activeSession) return; const guild = this.client.guilds.cache.get(this.guildId); if (!guild) return; const channel = guild.channels.cache.get(this.activeSession.channelId); if (!channel || channel.type !== ChannelType.GuildVoice) return; const members = channel.members; const presentTargets = this.targetUsers.filter((id) => members?.has(id)); if (presentTargets.length < 2) { console.log('[INFO] Not enough target users remaining, ending session...'); this.endSession(); } } async joinAndRecord(channelId, channelName, guild, members) { this.connection = joinVoiceChannel({ channelId, guildId: guild.id, adapterCreator: guild.voiceAdapterCreator, selfDeaf: false, selfMute: true, }); this.activeSession = { guildId: guild.id, channelId, channelName, startTime: new Date(), participants: new Map(), transcript: [], }; this.audioBuffers.clear(); this.sessionSegments = []; for (const [id, member] of members) { this.userIdToName.set(id, member.user?.username || id); this.activeSession.participants.set(id, member.user?.username || id); } this.setupAudioReceivers(); console.log(`[INFO] Recording session started in ${channelName}`); } setupAudioReceivers() { if (!this.connection) return; const receiver = this.connection.receiver; for (const [userId, userName] of this.userIdToName) { if (!this.targetUsers.includes(userId)) continue; const audioStream = receiver.subscribe(userId, { end: { behavior: EndBehaviorType.AfterSilence, duration: 2000, }, }); this.audioBuffers.set(userId, []); audioStream.on('data', (chunk) => { const buffers = this.audioBuffers.get(userId) || []; buffers.push(chunk); this.audioBuffers.set(userId, buffers); }); audioStream.on('end', () => { console.log(`[AUDIO] Stream ended for ${userName}`); this.processUserAudioChunk(userId); }); audioStream.on('error', (err) => { console.error(`[AUDIO] Error for ${userName}:`, err.message); }); console.log(`[AUDIO] Listening to ${userName}`); } } async processUserAudioChunk(userId) { const buffers = this.audioBuffers.get(userId); if (!buffers || buffers.length === 0) return; const totalLength = buffers.reduce((acc, buf) => acc + buf.length, 0); const combined = Buffer.concat(buffers, totalLength); this.audioBuffers.set(userId, []); const userName = this.userIdToName.get(userId) || userId; const timestamp = Date.now(); const rawFile = resolve(this.transcriptsDir, `chunk-${userName}-${timestamp}.raw`); const wavFile = rawFile.replace('.raw', '.wav'); const fs = await import('fs'); fs.writeFileSync(rawFile, combined); const ffmpeg = spawn('ffmpeg', [ '-f', 's16le', '-ar', String(SAMPLE_RATE), '-ac', String(CHANNELS), '-i', rawFile, '-y', wavFile, ]); ffmpeg.on('close', async (code) => { unlinkSync(rawFile); if (code !== 0) { console.error(`[FFMPEG] Failed with code ${code}`); return; } const text = await transcribeWithOnnxAsr(wavFile); unlinkSync(wavFile); if (text) { const segment = { speakerId: userId, speakerName: userName, timestamp, text, }; this.sessionSegments.push(segment); console.log(`[TRANSCRIPT] ${userName}: "${text}"`); } }); } async endSession() { if (!this.activeSession) return; this.activeSession.endTime = new Date(); const duration = this.getSessionDuration(); console.log(`[INFO] Session ended. Duration: ${duration}`); await new Promise((r) => setTimeout(r, 3000)); this.connection?.destroy(); this.connection = null; const transcriptPath = await this.saveTranscript(); await this.promptForActions(transcriptPath); this.activeSession = null; this.sessionSegments = []; } getSessionDuration() { if (!this.activeSession) return '0s'; const end = this.activeSession.endTime || new Date(); const duration = Math.floor((end.getTime() - this.activeSession.startTime.getTime()) / 1000); const mins = Math.floor(duration / 60); const secs = duration % 60; return `${mins}m ${secs}s`; } async saveTranscript() { if (!this.activeSession) return ''; const timestamp = this.activeSession.startTime.toISOString().replace(/[:.]/g, '-'); const filename = `meeting-${timestamp}.json`; const filepath = resolve(this.transcriptsDir, filename); const data = { channel: this.activeSession.channelName, startTime: this.activeSession.startTime, endTime: this.activeSession.endTime, duration: this.getSessionDuration(), participants: Object.fromEntries(this.activeSession.participants), transcript: this.sessionSegments, }; await writeFile(filepath, JSON.stringify(data, null, 2)); console.log(`[INFO] Transcript saved to ${filepath}`); return filepath; } async promptForActions(transcriptPath) { const promptText = `Meeting recording complete! **Channel:** ${this.activeSession?.channelName} **Duration:** ${this.getSessionDuration()} **Transcript:** \`${transcriptPath}\` Would you like me to generate structured notes and action items from this meeting?`; try { await fetch(`${this.openclawGatewayUrl}/api/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: 'discord', action: 'send', to: `user:${this.targetUsers[0]}`, message: promptText, components: { text: promptText, blocks: [ { type: 'actions', buttons: [ { label: 'Yes - Generate Notes', style: 'success' }, { label: 'No - Just Save Transcript', style: 'secondary' }, ], }, ], }, }), }); console.log('[INFO] Prompt sent to user via OpenClaw'); } catch (err) { console.error('[ERROR] Failed to send prompt:', err); } } async start() { await this.client.login(this.discordToken); } async stop() { if (this.checkInterval) { clearInterval(this.checkInterval); } if (this.activeSession) { await this.endSession(); } this.client.destroy(); console.log('[INFO] Bot stopped'); } } async function main() { const bot = new MeetingNotesBot(); process.on('SIGINT', async () => { console.log('\n[INFO] Shutting down...'); await bot.stop(); process.exit(0); }); await bot.start(); } main().catch(console.error);