Initial commit: meeting notes bot with auto-transcription
This commit is contained in:
commit
a1352939e0
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
discordToken=
|
||||
targetUsers=212290903174283264,938238002528911400
|
||||
transcriptsDir=~/.agents/meeting-transcripts
|
||||
openclawGatewayUrl=http://localhost:3850
|
||||
guildId=1458978988306337966
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
115
README.md
Normal file
115
README.md
Normal 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
95
bun.lock
Normal 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
14
meeting-notes.service
Normal 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
23
package.json
Normal 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
445
src/index.ts
Normal 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
16
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user