Initial commit: meeting notes bot with auto-transcription

This commit is contained in:
Nicholai Vogel 2026-02-27 00:32:37 -07:00
commit a1352939e0
8 changed files with 717 additions and 0 deletions

5
.env.example Normal file
View File

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

4
.gitignore vendored Normal file
View File

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

115
README.md Normal file
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

95
bun.lock Normal file
View File

@ -0,0 +1,95 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "meeting-notes",
"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",
},
"devDependencies": {
"@types/node": "^22.13.0",
"typescript": "^5.7.3",
},
},
},
"packages": {
"@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="],
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
"@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
"@discordjs/voice": ["@discordjs/voice@0.18.0", "", { "dependencies": { "@types/ws": "^8.5.12", "discord-api-types": "^0.37.103", "prism-media": "^1.3.5", "tslib": "^2.6.3", "ws": "^8.18.0" } }, "sha512-BvX6+VJE5/vhD9azV9vrZEt9hL1G+GlOdsQaVl5iv9n87fkXjf3cSwllhR3GdaUC8m6dqT8umXIWtn3yCu4afg=="],
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
"@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"discord-api-types": ["discord-api-types@0.37.120", "", {}, "sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw=="],
"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=="],
"libsodium-wrappers": ["libsodium-wrappers@0.7.16", "", { "dependencies": { "libsodium": "^0.7.16" } }, "sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
"prism-media": ["prism-media@1.3.5", "", { "peerDependencies": { "@discordjs/opus": ">=0.8.0 <1.0.0", "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", "node-opus": "^0.3.3", "opusscript": "^0.0.8" }, "optionalPeers": ["@discordjs/opus", "ffmpeg-static", "node-opus", "opusscript"] }, "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"@discordjs/builders/discord-api-types": ["discord-api-types@0.38.40", "", {}, "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ=="],
"@discordjs/formatters/discord-api-types": ["discord-api-types@0.38.40", "", {}, "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ=="],
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.40", "", {}, "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ=="],
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.40", "", {}, "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ=="],
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/ws/discord-api-types": ["discord-api-types@0.38.40", "", {}, "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ=="],
"discord.js/discord-api-types": ["discord-api-types@0.38.40", "", {}, "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ=="],
}
}

14
meeting-notes.service Normal file
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

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "meeting-notes",
"version": "1.0.0",
"description": "Discord meeting notes bot - auto-transcribes VC when Nicholai + Jake are together",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc -w"
},
"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"
},
"devDependencies": {
"@types/node": "^22.13.0",
"typescript": "^5.7.3"
}
}

445
src/index.ts Normal file
View File

@ -0,0 +1,445 @@
import 'dotenv/config';
import { Client, GatewayIntentBits, Guild, VoiceState, ChannelType, Events } from 'discord.js';
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 { 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);
}
return p;
}
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 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 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 });
}
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
],
});
this.setupEventHandlers();
}
private setupEventHandlers(): void {
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);
});
}
private startMonitoring(): void {
console.log('[INFO] Starting VC monitoring...');
console.log(`[INFO] Watching for users: ${this.targetUsers.join(', ')}`);
this.checkInterval = setInterval(() => {
this.checkVoiceChannels();
}, 5000);
this.checkVoiceChannels();
}
private async checkVoiceChannels(): Promise<void> {
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 as any).members as Map<string, any>;
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;
}
}
}
private handleVoiceStateChange(oldState: VoiceState, newState: VoiceState): void {
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();
}
}
private checkShouldEndSession(): void {
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 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...');
this.endSession();
}
}
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,
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}`);
}
private setupAudioReceivers(): void {
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: 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}"`);
}
});
}
private async endSession(): Promise<void> {
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 = [];
}
private getSessionDuration(): string {
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`;
}
private async saveTranscript(): Promise<string> {
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;
}
private async promptForActions(transcriptPath: string): Promise<void> {
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(): Promise<void> {
await this.client.login(this.discordToken);
}
async stop(): Promise<void> {
if (this.checkInterval) {
clearInterval(this.checkInterval);
}
if (this.activeSession) {
await this.endSession();
}
this.client.destroy();
console.log('[INFO] Bot stopped');
}
}
async function main(): Promise<void> {
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);

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}