359 lines
13 KiB
JavaScript
359 lines
13 KiB
JavaScript
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);
|