375 lines
14 KiB
JavaScript

import 'dotenv/config';
import { Client, GatewayIntentBits, ChannelType, Events } from 'discord.js';
import { joinVoiceChannel, EndBehaviorType } from '@discordjs/voice';
import prism from 'prism-media';
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);
}
const PYWHISPER_PYTHON = '/home/nicholai/.local/share/hyprwhspr/venv/bin/python3';
const PYWHISPER_MODEL = 'base.en';
async function transcribeWithWhisper(wavPath) {
return new Promise((resolve) => {
const python = spawn(PYWHISPER_PYTHON, [
'-c',
`
from pywhispercpp.model import Model
model = Model('${PYWHISPER_MODEL}', redirect_whispercpp_logs_to=None)
segments = model.transcribe('${wavPath}')
print(' '.join(seg.text for seg in segments))
`.trim(),
], { env: { ...process.env, CUDA_VISIBLE_DEVICES: '' } });
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] pywhispercpp failed (exit ${code}): ${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;
this.audioBuffers.set(userId, []);
this.subscribeToUser(receiver, userId, userName);
}
}
subscribeToUser(receiver, userId, userName) {
if (!this.activeSession)
return;
const opusStream = receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
duration: 2000,
},
});
const decoder = new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 });
const audioStream = opusStream.pipe(decoder);
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}, processing and re-subscribing...`);
this.processUserAudioChunk(userId);
// Re-subscribe to keep listening
setTimeout(() => this.subscribeToUser(receiver, userId, userName), 100);
});
audioStream.on('error', (err) => {
console.error(`[AUDIO] Error for ${userName}:`, err.message);
// Re-subscribe on error too
setTimeout(() => this.subscribeToUser(receiver, userId, userName), 500);
});
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;
console.log(`[DEBUG] ${userName}: ${buffers.length} chunks, ${totalLength} bytes total`);
if (totalLength < 1000) {
console.log(`[DEBUG] ${userName}: chunk too small, skipping`);
return;
}
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,
'-ar', '16000',
'-ac', '1',
'-y', wavFile,
]);
ffmpeg.on('close', async (code) => {
unlinkSync(rawFile);
if (code !== 0) {
console.error(`[FFMPEG] Failed with code ${code}`);
return;
}
const text = await transcribeWithWhisper(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);