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