From a1352939e0bd5bd7bf9e16a0f260addb203fe0bb Mon Sep 17 00:00:00 2001 From: Nicholai Date: Fri, 27 Feb 2026 00:32:37 -0700 Subject: [PATCH] Initial commit: meeting notes bot with auto-transcription --- .env.example | 5 + .gitignore | 4 + README.md | 115 +++++++++++ bun.lock | 95 +++++++++ meeting-notes.service | 14 ++ package.json | 23 +++ src/index.ts | 445 ++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 16 ++ 8 files changed, 717 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 meeting-notes.service create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b3d8ef2 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +discordToken= +targetUsers=212290903174283264,938238002528911400 +transcriptsDir=~/.agents/meeting-transcripts +openclawGatewayUrl=http://localhost:3850 +guildId=1458978988306337966 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9690d6 --- /dev/null +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0033647 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/meeting-notes.service b/meeting-notes.service new file mode 100644 index 0000000..7506b07 --- /dev/null +++ b/meeting-notes.service @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f72104 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e2f7ad1 --- /dev/null +++ b/src/index.ts @@ -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; + 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 { + 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 = new Map(); + private userIdToName: Map = new Map(); + private checkInterval: ReturnType | 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 { + 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; + 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; + 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 + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.client.login(this.discordToken); + } + + async stop(): Promise { + if (this.checkInterval) { + clearInterval(this.checkInterval); + } + if (this.activeSession) { + await this.endSession(); + } + this.client.destroy(); + console.log('[INFO] Bot stopped'); + } +} + +async function main(): Promise { + 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); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f5733ee --- /dev/null +++ b/tsconfig.json @@ -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"] +}