2026-02-27T07-41-35_auto_memory/memories.db-wal, memory/memories.db-wal

This commit is contained in:
Nicholai Vogel 2026-02-27 00:41:35 -07:00
parent a6f7e4cae7
commit 7a71fca8c3
15 changed files with 622 additions and 357 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,5 @@
discordToken=
targetUsers=212290903174283264,938238002528911400
transcriptsDir=~/.agents/meeting-transcripts
openclawGatewayUrl=http://localhost:3850
guildId=1458978988306337966

4
tools/meeting-notes/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log

View File

@ -0,0 +1,115 @@
# OpenClaw Meeting Notes
A Discord bot that automatically joins voice channels and transcribes meetings when specific users are present together. Integrates with OpenClaw for post-meeting actions.
## Features
- Auto-joins VC when configured users are together
- Per-speaker transcription using onnx-asr (hyprwhspr backend)
- Timestamped transcript saved to JSON
- Prompts via OpenClaw for post-meeting actions
- Self-contained - easy to share and deploy
## Requirements
- Node.js 18+ or Bun
- ffmpeg (for audio conversion)
- hyprwhspr with onnx-asr backend (for transcription)
- A Discord bot token
## Setup
1. Clone and install dependencies:
```bash
git clone https://git.nicholai.work/Nicholai/openclaw-meeting-notes.git
cd openclaw-meeting-notes
bun install
```
2. Copy the example env and configure:
```bash
cp .env.example .env
```
Edit `.env`:
```env
discordToken=YOUR_BOT_TOKEN
targetUsers=USER_ID_1,USER_ID_2
transcriptsDir=~/.agents/meeting-transcripts
openclawGatewayUrl=http://localhost:3850
guildId=YOUR_GUILD_ID
```
3. Build and run:
```bash
bun run build
bun run start
```
## Configuration
| Env Var | Description |
|---------|-------------|
| `discordToken` | Discord bot token |
| `targetUsers` | Comma-separated user IDs to watch for |
| `transcriptsDir` | Where to save meeting transcripts |
| `openclawGatewayUrl` | OpenClaw gateway URL for prompts |
| `guildId` | Discord guild ID to monitor |
## Bot Permissions
The bot needs these permissions in your server:
- View Channels
- Connect (to voice channels)
- Speak (muted, but needed to join)
## How It Works
1. Bot monitors voice channels for target users
2. When all target users are in the same VC, bot joins (muted)
3. Captures audio streams per speaker
4. Converts PCM → WAV → text via onnx-asr
5. When users leave, saves transcript JSON
6. Prompts via OpenClaw for follow-up actions
## Output Format
Transcripts are saved as JSON:
```json
{
"channel": "General",
"startTime": "2026-02-27T00:00:00.000Z",
"endTime": "2026-02-27T00:30:00.000Z",
"duration": "30m 0s",
"participants": {
"123456789": "username1",
"987654321": "username2"
},
"transcript": [
{
"speakerId": "123456789",
"speakerName": "username1",
"timestamp": 1708992000000,
"text": "Hey, let's talk about the project..."
}
]
}
```
## Systemd Service
To run as a background service:
```bash
cp meeting-notes.service ~/.config/systemd/user/
systemctl --user enable --now meeting-notes
```
## License
MIT

View File

@ -7,6 +7,7 @@
"dependencies": {
"@discordjs/voice": "^0.18.0",
"discord.js": "^14.18.0",
"dotenv": "^17.3.1",
"libsodium-wrappers": "^0.7.15",
"prism-media": "^1.3.5",
},
@ -47,6 +48,8 @@
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"libsodium": ["libsodium@0.7.16", "", {}, "sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q=="],

View File

@ -1,7 +0,0 @@
{
"discordToken": "MTQ2MzQwOTA4MDQ2NjE0NTMxMw.GCCGcQ.QTuDSn2re5-EFg1wQlcYydfVkpe5uS1ztNpNpg",
"targetUsers": ["212290903174283264", "938238002528911400"],
"transcriptsDir": "~/.agents/meeting-transcripts",
"openclawGatewayUrl": "http://localhost:3850",
"guildId": "1458978988306337966"
}

View File

@ -1 +1 @@
export {};
import 'dotenv/config';

View File

@ -1,31 +1,88 @@
import 'dotenv/config';
import { Client, GatewayIntentBits, ChannelType, Events } from 'discord.js';
import { joinVoiceChannel, getVoiceConnection } from '@discordjs/voice';
import { readFile } from 'fs/promises';
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;
}
async function loadConfig() {
const configPath = resolve(__dirname, '../config.json');
const raw = await readFile(configPath, 'utf-8');
const config = JSON.parse(raw);
config.transcriptsDir = expandPath(config.transcriptsDir);
return config;
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;
config;
discordToken;
targetUsers;
transcriptsDir;
openclawGatewayUrl;
guildId;
activeSession = null;
audioBuffers = new Map();
userIdToName = new Map();
checkInterval = null;
constructor(config) {
this.config = config;
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,
@ -46,6 +103,7 @@ class MeetingNotesBot {
}
startMonitoring() {
console.log('[INFO] Starting VC monitoring...');
console.log(`[INFO] Watching for users: ${this.targetUsers.join(', ')}`);
this.checkInterval = setInterval(() => {
this.checkVoiceChannels();
}, 5000);
@ -54,17 +112,20 @@ class MeetingNotesBot {
async checkVoiceChannels() {
if (this.activeSession)
return;
const guild = this.client.guilds.cache.get(this.config.guildId);
if (!guild)
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;
const targetUserIds = this.config.targetUsers;
const presentTargets = targetUserIds.filter((id) => members.has(id));
if (presentTargets.length === targetUserIds.length) {
console.log(`[INFO] All target users in ${channel.name}, joining...`);
await this.joinAndRecord(channel.id, channel.name, guild);
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;
}
}
@ -72,36 +133,35 @@ class MeetingNotesBot {
handleVoiceStateChange(oldState, newState) {
if (!this.activeSession)
return;
const targetUserIds = this.config.targetUsers;
const userId = newState.id;
if (!targetUserIds.includes(userId))
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 ${userId} left the channel`);
console.log(`[INFO] Target user left the channel`);
this.checkShouldEndSession();
}
}
checkShouldEndSession() {
if (!this.activeSession)
return;
const guild = this.client.guilds.cache.get(this.config.guildId);
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.config.targetUsers.filter((id) => members?.has(id));
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) {
const connection = joinVoiceChannel({
async joinAndRecord(channelId, channelName, guild, members) {
this.connection = joinVoiceChannel({
channelId,
guildId: guild.id,
adapterCreator: guild.voiceAdapterCreator,
@ -116,38 +176,97 @@ class MeetingNotesBot {
participants: new Map(),
transcript: [],
};
this.setupAudioReceivers(connection);
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(connection) {
const { receiver } = connection;
connection.receiver.speaking.on('start', (userId) => {
if (!this.activeSession)
return;
if (!this.audioBuffers.has(userId)) {
this.audioBuffers.set(userId, []);
}
});
receiver.subscriptions.forEach((subscription, userId) => {
const chunks = [];
subscription.on('data', (chunk) => {
if (!this.activeSession)
return;
chunks.push(chunk);
this.audioBuffers.set(userId, chunks);
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();
console.log(`[INFO] Session ended. Duration: ${this.getSessionDuration()}`);
const connection = getVoiceConnection(this.config.guildId);
connection?.destroy();
await this.processTranscript();
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.audioBuffers.clear();
this.sessionSegments = [];
}
getSessionDuration() {
if (!this.activeSession)
@ -158,36 +277,63 @@ class MeetingNotesBot {
const secs = duration % 60;
return `${mins}m ${secs}s`;
}
async processTranscript() {
async saveTranscript() {
if (!this.activeSession)
return;
return '';
const timestamp = this.activeSession.startTime.toISOString().replace(/[:.]/g, '-');
const filename = `meeting-${timestamp}.json`;
const transcriptPath = resolve(this.config.transcriptsDir, filename);
const transcriptData = {
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),
rawAudio: Object.fromEntries(Array.from(this.audioBuffers.entries()).map(([id, chunks]) => [
id,
chunks.length,
])),
transcript: this.sessionSegments,
};
console.log(`[INFO] Transcript saved to ${transcriptPath}`);
console.log('[INFO] Sending prompt to OpenClaw for action confirmation...');
await this.promptOpenClaw(transcriptPath);
await writeFile(filepath, JSON.stringify(data, null, 2));
console.log(`[INFO] Transcript saved to ${filepath}`);
return filepath;
}
async promptOpenClaw(transcriptPath) {
const message = `Meeting ended. Transcript saved to ${transcriptPath}.
async promptForActions(transcriptPath) {
const promptText = `Meeting recording complete!
What would you like to do?`;
console.log(`[PROMPT] ${message}`);
console.log('[INFO] Awaiting user decision via OpenClaw...');
**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.config.discordToken);
await this.client.login(this.discordToken);
}
async stop() {
if (this.checkInterval) {
@ -201,8 +347,7 @@ What would you like to do?`;
}
}
async function main() {
const config = await loadConfig();
const bot = new MeetingNotesBot(config);
const bot = new MeetingNotesBot();
process.on('SIGINT', async () => {
console.log('\n[INFO] Shutting down...');
await bot.stop();

View File

@ -0,0 +1,14 @@
[Unit]
Description=Meeting Notes Bot - Discord VC transcription
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/bun run /home/nicholai/.agents/tools/meeting-notes/dist/index.js
WorkingDirectory=/home/nicholai/.agents/tools/meeting-notes
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=default.target

View File

@ -12,6 +12,7 @@
"dependencies": {
"@discordjs/voice": "^0.18.0",
"discord.js": "^14.18.0",
"dotenv": "^17.3.1",
"libsodium-wrappers": "^0.7.15",
"prism-media": "^1.3.5"
},

View File

@ -1,184 +0,0 @@
import {
joinVoiceChannel,
VoiceConnection,
getVoiceConnection,
createAudioResource,
AudioReceiveStream,
EndBehaviorType,
} from '@discordjs/voice';
import { pipeline, Transform, PassThrough } from 'stream';
import { createWriteStream, mkdirSync, existsSync } from 'fs';
import { resolve, basename } from 'path';
import { spawn } from 'child_process';
import type { TranscriptSegment } from './types.js';
const FRAME_SIZE = 960;
const SAMPLE_RATE = 48000;
const CHANNELS = 2;
export class AudioCapture {
private streams: Map<string, AudioReceiveStream> = new Map();
private buffers: Map<string, Buffer[]> = new Map();
private userIdToName: Map<string, string> = new Map();
private outputDir: string;
private onSegment?: (userId: string, text: string, timestamp: number) => void;
constructor(outputDir: string) {
this.outputDir = outputDir;
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
}
setOnSegment(callback: (userId: string, text: string, timestamp: number) => void): void {
this.onSegment = callback;
}
startCapture(connection: VoiceConnection, participants: Map<string, string>): void {
this.userIdToName = new Map(participants);
const receiver = connection.receiver;
for (const [userId, userName] of participants) {
const audioStream = receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
duration: 1000,
},
});
this.buffers.set(userId, []);
const transform = new Transform({
transform(chunk: Buffer, _encoding, callback) {
this.push(chunk);
callback();
},
});
audioStream.on('data', (chunk: Buffer) => {
const buffers = this.buffers.get(userId) || [];
buffers.push(chunk);
this.buffers.set(userId, buffers);
});
audioStream.on('end', () => {
console.log(`[AUDIO] Stream ended for ${userName || userId}`);
this.processUserAudio(userId);
});
audioStream.on('error', (err) => {
console.error(`[AUDIO] Error for ${userName || userId}:`, err);
});
this.streams.set(userId, audioStream);
console.log(`[AUDIO] Capturing audio for ${userName || userId}`);
}
}
stopCapture(): void {
for (const [userId, stream] of this.streams) {
stream.destroy();
}
this.streams.clear();
}
private async processUserAudio(userId: string): Promise<void> {
const buffers = this.buffers.get(userId);
if (!buffers || buffers.length === 0) return;
const totalLength = buffers.reduce((acc, buf) => acc + buf.length, 0);
const combined = Buffer.concat(buffers, totalLength);
const userName = this.userIdToName.get(userId) || userId;
const timestamp = Date.now();
const filename = `${userName}-${timestamp}.raw`;
const filepath = resolve(this.outputDir, filename);
require('fs').writeFileSync(filepath, combined);
console.log(`[AUDIO] Saved ${buffers.length} chunks (${totalLength} bytes) for ${userName}`);
const text = await this.transcribe(filepath);
if (text && this.onSegment) {
this.onSegment(userId, text, timestamp);
}
}
private async transcribe(audioPath: string): Promise<string> {
return new Promise((resolve) => {
const wavPath = audioPath.replace('.raw', '.wav');
const ffmpeg = spawn('ffmpeg', [
'-f', 's16le',
'-ar', String(SAMPLE_RATE),
'-ac', String(CHANNELS),
'-i', audioPath,
'-y',
wavPath,
]);
ffmpeg.on('close', async (code) => {
if (code !== 0) {
console.error(`[TRANSCRIBE] ffmpeg failed with code ${code}`);
resolve('');
return;
}
const text = await this.transcribeWithHyprwhspr(wavPath);
resolve(text);
});
ffmpeg.stderr.on('data', (data) => {
// Silence ffmpeg noise
});
});
}
private async transcribeWithHyprwhspr(wavPath: string): Promise<string> {
return new Promise((resolve) => {
const python = spawn('python3', [
'-c',
`
import sys
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', ''))
`,
]);
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}`);
resolve('');
return;
}
const text = output.trim();
console.log(`[TRANSCRIBE] Result: "${text}"`);
resolve(text);
});
});
}
getSegments(): TranscriptSegment[] {
const segments: TranscriptSegment[] = [];
// This will be populated by the onSegment callback
return segments;
}
getRawBuffers(): Map<string, Buffer[]> {
return new Map(this.buffers);
}
}

View File

@ -1,13 +1,35 @@
import 'dotenv/config';
import { Client, GatewayIntentBits, Guild, VoiceState, ChannelType, Events } from 'discord.js';
import { joinVoiceChannel, VoiceConnection, getVoiceConnection } from '@discordjs/voice';
import { readFile } from 'fs/promises';
import { joinVoiceChannel, VoiceConnection, getVoiceConnection, EndBehaviorType } from '@discordjs/voice';
import { writeFile, mkdir } from 'fs/promises';
import { existsSync, mkdirSync, unlinkSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
import type { MeetingConfig, MeetingSession, TranscriptSegment } from './types.js';
import { spawn } from 'child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SAMPLE_RATE = 48000;
const CHANNELS = 2;
interface TranscriptSegment {
speakerId: string;
speakerName: string;
timestamp: number;
text: string;
}
interface MeetingSession {
guildId: string;
channelId: string;
channelName: string;
startTime: Date;
endTime?: Date;
participants: Map<string, string>;
transcript: TranscriptSegment[];
}
function expandPath(p: string): string {
if (p.startsWith('~')) {
return homedir() + p.slice(1);
@ -15,23 +37,83 @@ function expandPath(p: string): string {
return p;
}
async function loadConfig(): Promise<MeetingConfig> {
const configPath = resolve(__dirname, '../config.json');
const raw = await readFile(configPath, 'utf-8');
const config = JSON.parse(raw) as MeetingConfig;
config.transcriptsDir = expandPath(config.transcriptsDir);
return config;
function getEnv(key: string, required = true): string {
const value = process.env[key];
if (!value && required) {
throw new Error(`Missing required env var: ${key}`);
}
return value || '';
}
function getEnvList(key: string): string[] {
const value = getEnv(key);
return value.split(',').map(s => s.trim()).filter(Boolean);
}
async function transcribeWithOnnxAsr(wavPath: string): Promise<string> {
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 {
private client: Client;
private config: MeetingConfig;
private discordToken: string;
private targetUsers: string[];
private transcriptsDir: string;
private openclawGatewayUrl: string;
private guildId: string;
private activeSession: MeetingSession | null = null;
private audioBuffers: Map<string, Buffer[]> = new Map();
private checkInterval: NodeJS.Timeout | null = null;
private userIdToName: Map<string, string> = new Map();
private checkInterval: ReturnType<typeof setInterval> | null = null;
private connection: VoiceConnection | null = null;
private sessionSegments: TranscriptSegment[] = [];
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 });
}
constructor(config: MeetingConfig) {
this.config = config;
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
@ -56,6 +138,7 @@ class MeetingNotesBot {
private startMonitoring(): void {
console.log('[INFO] Starting VC monitoring...');
console.log(`[INFO] Watching for users: ${this.targetUsers.join(', ')}`);
this.checkInterval = setInterval(() => {
this.checkVoiceChannels();
}, 5000);
@ -65,22 +148,25 @@ class MeetingNotesBot {
private async checkVoiceChannels(): Promise<void> {
if (this.activeSession) return;
const guild = this.client.guilds.cache.get(this.config.guildId);
if (!guild) 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;
const targetUserIds = this.config.targetUsers;
const members = (channel as any).members as Map<string, any>;
if (!members) continue;
const presentTargets = targetUserIds.filter((id) => members.has(id));
const presentTargets = this.targetUsers.filter((id) => members.has(id));
if (presentTargets.length === targetUserIds.length) {
console.log(`[INFO] All target users in ${channel.name}, joining...`);
await this.joinAndRecord(channel.id, channel.name, guild);
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;
}
}
@ -89,17 +175,16 @@ class MeetingNotesBot {
private handleVoiceStateChange(oldState: VoiceState, newState: VoiceState): void {
if (!this.activeSession) return;
const targetUserIds = this.config.targetUsers;
const userId = newState.id;
if (!targetUserIds.includes(userId)) return;
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 ${userId} left the channel`);
console.log(`[INFO] Target user left the channel`);
this.checkShouldEndSession();
}
}
@ -107,14 +192,14 @@ class MeetingNotesBot {
private checkShouldEndSession(): void {
if (!this.activeSession) return;
const guild = this.client.guilds.cache.get(this.config.guildId);
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 as any).members;
const presentTargets = this.config.targetUsers.filter((id) => members?.has(id));
const members = (channel as any).members as Map<string, any>;
const presentTargets = this.targetUsers.filter((id) => members?.has(id));
if (presentTargets.length < 2) {
console.log('[INFO] Not enough target users remaining, ending session...');
@ -122,8 +207,13 @@ class MeetingNotesBot {
}
}
private async joinAndRecord(channelId: string, channelName: string, guild: Guild): Promise<void> {
const connection = joinVoiceChannel({
private async joinAndRecord(
channelId: string,
channelName: string,
guild: Guild,
members: Map<string, any>
): Promise<void> {
this.connection = joinVoiceChannel({
channelId,
guildId: guild.id,
adapterCreator: guild.voiceAdapterCreator,
@ -140,29 +230,99 @@ class MeetingNotesBot {
transcript: [],
};
this.setupAudioReceivers(connection);
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}`);
}
private setupAudioReceivers(connection: VoiceConnection): void {
const { receiver } = connection;
private setupAudioReceivers(): void {
if (!this.connection) return;
connection.receiver.speaking.on('start', (userId) => {
if (!this.activeSession) return;
const receiver = this.connection.receiver;
if (!this.audioBuffers.has(userId)) {
this.audioBuffers.set(userId, []);
}
});
for (const [userId, userName] of this.userIdToName) {
if (!this.targetUsers.includes(userId)) continue;
receiver.subscriptions.forEach((subscription, userId) => {
const chunks: Buffer[] = [];
subscription.on('data', (chunk: Buffer) => {
if (!this.activeSession) return;
chunks.push(chunk);
this.audioBuffers.set(userId, chunks);
const audioStream = receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
duration: 2000,
},
});
this.audioBuffers.set(userId, []);
audioStream.on('data', (chunk: Buffer) => {
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}`);
}
}
private async processUserAudioChunk(userId: string): Promise<void> {
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: TranscriptSegment = {
speakerId: userId,
speakerName: userName,
timestamp,
text,
};
this.sessionSegments.push(segment);
console.log(`[TRANSCRIPT] ${userName}: "${text}"`);
}
});
}
@ -170,14 +330,19 @@ class MeetingNotesBot {
if (!this.activeSession) return;
this.activeSession.endTime = new Date();
console.log(`[INFO] Session ended. Duration: ${this.getSessionDuration()}`);
const duration = this.getSessionDuration();
console.log(`[INFO] Session ended. Duration: ${duration}`);
const connection = getVoiceConnection(this.config.guildId);
connection?.destroy();
await new Promise((r) => setTimeout(r, 3000));
this.connection?.destroy();
this.connection = null;
const transcriptPath = await this.saveTranscript();
await this.promptForActions(transcriptPath);
await this.processTranscript();
this.activeSession = null;
this.audioBuffers.clear();
this.sessionSegments = [];
}
private getSessionDuration(): string {
@ -189,44 +354,68 @@ class MeetingNotesBot {
return `${mins}m ${secs}s`;
}
private async processTranscript(): Promise<void> {
if (!this.activeSession) return;
private async saveTranscript(): Promise<string> {
if (!this.activeSession) return '';
const timestamp = this.activeSession.startTime.toISOString().replace(/[:.]/g, '-');
const filename = `meeting-${timestamp}.json`;
const transcriptPath = resolve(this.config.transcriptsDir, filename);
const filepath = resolve(this.transcriptsDir, filename);
const transcriptData = {
const data = {
channel: this.activeSession.channelName,
startTime: this.activeSession.startTime,
endTime: this.activeSession.endTime,
duration: this.getSessionDuration(),
participants: Object.fromEntries(this.activeSession.participants),
rawAudio: Object.fromEntries(
Array.from(this.audioBuffers.entries()).map(([id, chunks]) => [
id,
chunks.length,
])
),
transcript: this.sessionSegments,
};
console.log(`[INFO] Transcript saved to ${transcriptPath}`);
console.log('[INFO] Sending prompt to OpenClaw for action confirmation...');
await writeFile(filepath, JSON.stringify(data, null, 2));
console.log(`[INFO] Transcript saved to ${filepath}`);
await this.promptOpenClaw(transcriptPath);
return filepath;
}
private async promptOpenClaw(transcriptPath: string): Promise<void> {
const message = `Meeting ended. Transcript saved to ${transcriptPath}.
private async promptForActions(transcriptPath: string): Promise<void> {
const promptText = `Meeting recording complete!
What would you like to do?`;
**Channel:** ${this.activeSession?.channelName}
**Duration:** ${this.getSessionDuration()}
**Transcript:** \`${transcriptPath}\`
console.log(`[PROMPT] ${message}`);
console.log('[INFO] Awaiting user decision via OpenClaw...');
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(): Promise<void> {
await this.client.login(this.config.discordToken);
await this.client.login(this.discordToken);
}
async stop(): Promise<void> {
@ -242,8 +431,7 @@ What would you like to do?`;
}
async function main(): Promise<void> {
const config = await loadConfig();
const bot = new MeetingNotesBot(config);
const bot = new MeetingNotesBot();
process.on('SIGINT', async () => {
console.log('\n[INFO] Shutting down...');

View File

@ -1,29 +0,0 @@
export interface MeetingConfig {
discordToken: string;
targetUsers: string[];
transcriptsDir: string;
openclawGatewayUrl: string;
guildId: string;
}
export interface TranscriptSegment {
speakerId: string;
speakerName: string;
timestamp: number;
text: string;
}
export interface MeetingSession {
guildId: string;
channelId: string;
channelName: string;
startTime: Date;
endTime?: Date;
participants: Map<string, string>;
transcript: TranscriptSegment[];
}
export interface TranscriptionResult {
text: string;
confidence?: number;
}