2026-02-27T07-41-35_auto_memory/memories.db-wal, memory/memories.db-wal
This commit is contained in:
parent
a6f7e4cae7
commit
7a71fca8c3
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
5
tools/meeting-notes/.env.example
Normal file
5
tools/meeting-notes/.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
discordToken=
|
||||
targetUsers=212290903174283264,938238002528911400
|
||||
transcriptsDir=~/.agents/meeting-transcripts
|
||||
openclawGatewayUrl=http://localhost:3850
|
||||
guildId=1458978988306337966
|
||||
4
tools/meeting-notes/.gitignore
vendored
Normal file
4
tools/meeting-notes/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
115
tools/meeting-notes/README.md
Normal file
115
tools/meeting-notes/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
|
||||
@ -7,6 +7,7 @@
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.18.0",
|
||||
"discord.js": "^14.18.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"prism-media": "^1.3.5",
|
||||
},
|
||||
@ -47,6 +48,8 @@
|
||||
|
||||
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
||||
|
||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"libsodium": ["libsodium@0.7.16", "", {}, "sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q=="],
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"discordToken": "MTQ2MzQwOTA4MDQ2NjE0NTMxMw.GCCGcQ.QTuDSn2re5-EFg1wQlcYydfVkpe5uS1ztNpNpg",
|
||||
"targetUsers": ["212290903174283264", "938238002528911400"],
|
||||
"transcriptsDir": "~/.agents/meeting-transcripts",
|
||||
"openclawGatewayUrl": "http://localhost:3850",
|
||||
"guildId": "1458978988306337966"
|
||||
}
|
||||
2
tools/meeting-notes/dist/index.d.ts
vendored
2
tools/meeting-notes/dist/index.d.ts
vendored
@ -1 +1 @@
|
||||
export {};
|
||||
import 'dotenv/config';
|
||||
|
||||
277
tools/meeting-notes/dist/index.js
vendored
277
tools/meeting-notes/dist/index.js
vendored
@ -1,31 +1,88 @@
|
||||
import 'dotenv/config';
|
||||
import { Client, GatewayIntentBits, ChannelType, Events } from 'discord.js';
|
||||
import { joinVoiceChannel, getVoiceConnection } from '@discordjs/voice';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { joinVoiceChannel, EndBehaviorType } from '@discordjs/voice';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { homedir } from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SAMPLE_RATE = 48000;
|
||||
const CHANNELS = 2;
|
||||
function expandPath(p) {
|
||||
if (p.startsWith('~')) {
|
||||
return homedir() + p.slice(1);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
async function loadConfig() {
|
||||
const configPath = resolve(__dirname, '../config.json');
|
||||
const raw = await readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
config.transcriptsDir = expandPath(config.transcriptsDir);
|
||||
return config;
|
||||
function getEnv(key, required = true) {
|
||||
const value = process.env[key];
|
||||
if (!value && required) {
|
||||
throw new Error(`Missing required env var: ${key}`);
|
||||
}
|
||||
return value || '';
|
||||
}
|
||||
function getEnvList(key) {
|
||||
const value = getEnv(key);
|
||||
return value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
async function transcribeWithOnnxAsr(wavPath) {
|
||||
return new Promise((resolve) => {
|
||||
const python = spawn('python3', [
|
||||
'-c',
|
||||
`
|
||||
import sys
|
||||
import os
|
||||
os.environ['ORT_LOGGING_LEVEL'] = '4'
|
||||
sys.path.insert(0, '/usr/lib/hyprwhspr/lib/src')
|
||||
import onnx_asr
|
||||
model = onnx_asr.load_model('nemo-parakeet-tdt-0.6b-v3', quantization='int8')
|
||||
with open('${wavPath}', 'rb') as f:
|
||||
result = model.transcribe(f.read())
|
||||
print(result if isinstance(result, str) else result.get('text', ''))
|
||||
`.trim(),
|
||||
]);
|
||||
let output = '';
|
||||
let error = '';
|
||||
python.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
python.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
python.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
console.error(`[TRANSCRIBE] onnx_asr failed: ${error.slice(0, 200)}`);
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
resolve(output.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
class MeetingNotesBot {
|
||||
client;
|
||||
config;
|
||||
discordToken;
|
||||
targetUsers;
|
||||
transcriptsDir;
|
||||
openclawGatewayUrl;
|
||||
guildId;
|
||||
activeSession = null;
|
||||
audioBuffers = new Map();
|
||||
userIdToName = new Map();
|
||||
checkInterval = null;
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
connection = null;
|
||||
sessionSegments = [];
|
||||
constructor() {
|
||||
this.discordToken = getEnv('discordToken');
|
||||
this.targetUsers = getEnvList('targetUsers');
|
||||
this.transcriptsDir = expandPath(getEnv('transcriptsDir'));
|
||||
this.openclawGatewayUrl = getEnv('openclawGatewayUrl');
|
||||
this.guildId = getEnv('guildId');
|
||||
if (!existsSync(this.transcriptsDir)) {
|
||||
mkdirSync(this.transcriptsDir, { recursive: true });
|
||||
}
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
@ -46,6 +103,7 @@ class MeetingNotesBot {
|
||||
}
|
||||
startMonitoring() {
|
||||
console.log('[INFO] Starting VC monitoring...');
|
||||
console.log(`[INFO] Watching for users: ${this.targetUsers.join(', ')}`);
|
||||
this.checkInterval = setInterval(() => {
|
||||
this.checkVoiceChannels();
|
||||
}, 5000);
|
||||
@ -54,17 +112,20 @@ class MeetingNotesBot {
|
||||
async checkVoiceChannels() {
|
||||
if (this.activeSession)
|
||||
return;
|
||||
const guild = this.client.guilds.cache.get(this.config.guildId);
|
||||
if (!guild)
|
||||
const guild = this.client.guilds.cache.get(this.guildId);
|
||||
if (!guild) {
|
||||
console.log('[WARN] Guild not found');
|
||||
return;
|
||||
}
|
||||
const voiceChannels = guild.channels.cache.filter((ch) => ch.type === ChannelType.GuildVoice);
|
||||
for (const [, channel] of voiceChannels) {
|
||||
const members = channel.members;
|
||||
const targetUserIds = this.config.targetUsers;
|
||||
const presentTargets = targetUserIds.filter((id) => members.has(id));
|
||||
if (presentTargets.length === targetUserIds.length) {
|
||||
console.log(`[INFO] All target users in ${channel.name}, joining...`);
|
||||
await this.joinAndRecord(channel.id, channel.name, guild);
|
||||
if (!members)
|
||||
continue;
|
||||
const presentTargets = this.targetUsers.filter((id) => members.has(id));
|
||||
if (presentTargets.length === this.targetUsers.length) {
|
||||
console.log(`[INFO] All target users in "${channel.name}", joining...`);
|
||||
await this.joinAndRecord(channel.id, channel.name, guild, members);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -72,36 +133,35 @@ class MeetingNotesBot {
|
||||
handleVoiceStateChange(oldState, newState) {
|
||||
if (!this.activeSession)
|
||||
return;
|
||||
const targetUserIds = this.config.targetUsers;
|
||||
const userId = newState.id;
|
||||
if (!targetUserIds.includes(userId))
|
||||
if (!this.targetUsers.includes(userId))
|
||||
return;
|
||||
const leftChannel = oldState.channelId === this.activeSession.channelId && !newState.channelId;
|
||||
const switchedChannel = oldState.channelId === this.activeSession.channelId &&
|
||||
newState.channelId !== this.activeSession.channelId;
|
||||
if (leftChannel || switchedChannel) {
|
||||
console.log(`[INFO] Target user ${userId} left the channel`);
|
||||
console.log(`[INFO] Target user left the channel`);
|
||||
this.checkShouldEndSession();
|
||||
}
|
||||
}
|
||||
checkShouldEndSession() {
|
||||
if (!this.activeSession)
|
||||
return;
|
||||
const guild = this.client.guilds.cache.get(this.config.guildId);
|
||||
const guild = this.client.guilds.cache.get(this.guildId);
|
||||
if (!guild)
|
||||
return;
|
||||
const channel = guild.channels.cache.get(this.activeSession.channelId);
|
||||
if (!channel || channel.type !== ChannelType.GuildVoice)
|
||||
return;
|
||||
const members = channel.members;
|
||||
const presentTargets = this.config.targetUsers.filter((id) => members?.has(id));
|
||||
const presentTargets = this.targetUsers.filter((id) => members?.has(id));
|
||||
if (presentTargets.length < 2) {
|
||||
console.log('[INFO] Not enough target users remaining, ending session...');
|
||||
this.endSession();
|
||||
}
|
||||
}
|
||||
async joinAndRecord(channelId, channelName, guild) {
|
||||
const connection = joinVoiceChannel({
|
||||
async joinAndRecord(channelId, channelName, guild, members) {
|
||||
this.connection = joinVoiceChannel({
|
||||
channelId,
|
||||
guildId: guild.id,
|
||||
adapterCreator: guild.voiceAdapterCreator,
|
||||
@ -116,38 +176,97 @@ class MeetingNotesBot {
|
||||
participants: new Map(),
|
||||
transcript: [],
|
||||
};
|
||||
this.setupAudioReceivers(connection);
|
||||
this.audioBuffers.clear();
|
||||
this.sessionSegments = [];
|
||||
for (const [id, member] of members) {
|
||||
this.userIdToName.set(id, member.user?.username || id);
|
||||
this.activeSession.participants.set(id, member.user?.username || id);
|
||||
}
|
||||
this.setupAudioReceivers();
|
||||
console.log(`[INFO] Recording session started in ${channelName}`);
|
||||
}
|
||||
setupAudioReceivers(connection) {
|
||||
const { receiver } = connection;
|
||||
connection.receiver.speaking.on('start', (userId) => {
|
||||
if (!this.activeSession)
|
||||
return;
|
||||
if (!this.audioBuffers.has(userId)) {
|
||||
this.audioBuffers.set(userId, []);
|
||||
}
|
||||
});
|
||||
receiver.subscriptions.forEach((subscription, userId) => {
|
||||
const chunks = [];
|
||||
subscription.on('data', (chunk) => {
|
||||
if (!this.activeSession)
|
||||
return;
|
||||
chunks.push(chunk);
|
||||
this.audioBuffers.set(userId, chunks);
|
||||
setupAudioReceivers() {
|
||||
if (!this.connection)
|
||||
return;
|
||||
const receiver = this.connection.receiver;
|
||||
for (const [userId, userName] of this.userIdToName) {
|
||||
if (!this.targetUsers.includes(userId))
|
||||
continue;
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
this.audioBuffers.set(userId, []);
|
||||
audioStream.on('data', (chunk) => {
|
||||
const buffers = this.audioBuffers.get(userId) || [];
|
||||
buffers.push(chunk);
|
||||
this.audioBuffers.set(userId, buffers);
|
||||
});
|
||||
audioStream.on('end', () => {
|
||||
console.log(`[AUDIO] Stream ended for ${userName}`);
|
||||
this.processUserAudioChunk(userId);
|
||||
});
|
||||
audioStream.on('error', (err) => {
|
||||
console.error(`[AUDIO] Error for ${userName}:`, err.message);
|
||||
});
|
||||
console.log(`[AUDIO] Listening to ${userName}`);
|
||||
}
|
||||
}
|
||||
async processUserAudioChunk(userId) {
|
||||
const buffers = this.audioBuffers.get(userId);
|
||||
if (!buffers || buffers.length === 0)
|
||||
return;
|
||||
const totalLength = buffers.reduce((acc, buf) => acc + buf.length, 0);
|
||||
const combined = Buffer.concat(buffers, totalLength);
|
||||
this.audioBuffers.set(userId, []);
|
||||
const userName = this.userIdToName.get(userId) || userId;
|
||||
const timestamp = Date.now();
|
||||
const rawFile = resolve(this.transcriptsDir, `chunk-${userName}-${timestamp}.raw`);
|
||||
const wavFile = rawFile.replace('.raw', '.wav');
|
||||
const fs = await import('fs');
|
||||
fs.writeFileSync(rawFile, combined);
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-f', 's16le',
|
||||
'-ar', String(SAMPLE_RATE),
|
||||
'-ac', String(CHANNELS),
|
||||
'-i', rawFile,
|
||||
'-y', wavFile,
|
||||
]);
|
||||
ffmpeg.on('close', async (code) => {
|
||||
unlinkSync(rawFile);
|
||||
if (code !== 0) {
|
||||
console.error(`[FFMPEG] Failed with code ${code}`);
|
||||
return;
|
||||
}
|
||||
const text = await transcribeWithOnnxAsr(wavFile);
|
||||
unlinkSync(wavFile);
|
||||
if (text) {
|
||||
const segment = {
|
||||
speakerId: userId,
|
||||
speakerName: userName,
|
||||
timestamp,
|
||||
text,
|
||||
};
|
||||
this.sessionSegments.push(segment);
|
||||
console.log(`[TRANSCRIPT] ${userName}: "${text}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
async endSession() {
|
||||
if (!this.activeSession)
|
||||
return;
|
||||
this.activeSession.endTime = new Date();
|
||||
console.log(`[INFO] Session ended. Duration: ${this.getSessionDuration()}`);
|
||||
const connection = getVoiceConnection(this.config.guildId);
|
||||
connection?.destroy();
|
||||
await this.processTranscript();
|
||||
const duration = this.getSessionDuration();
|
||||
console.log(`[INFO] Session ended. Duration: ${duration}`);
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
this.connection?.destroy();
|
||||
this.connection = null;
|
||||
const transcriptPath = await this.saveTranscript();
|
||||
await this.promptForActions(transcriptPath);
|
||||
this.activeSession = null;
|
||||
this.audioBuffers.clear();
|
||||
this.sessionSegments = [];
|
||||
}
|
||||
getSessionDuration() {
|
||||
if (!this.activeSession)
|
||||
@ -158,36 +277,63 @@ class MeetingNotesBot {
|
||||
const secs = duration % 60;
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
async processTranscript() {
|
||||
async saveTranscript() {
|
||||
if (!this.activeSession)
|
||||
return;
|
||||
return '';
|
||||
const timestamp = this.activeSession.startTime.toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `meeting-${timestamp}.json`;
|
||||
const transcriptPath = resolve(this.config.transcriptsDir, filename);
|
||||
const transcriptData = {
|
||||
const filepath = resolve(this.transcriptsDir, filename);
|
||||
const data = {
|
||||
channel: this.activeSession.channelName,
|
||||
startTime: this.activeSession.startTime,
|
||||
endTime: this.activeSession.endTime,
|
||||
duration: this.getSessionDuration(),
|
||||
participants: Object.fromEntries(this.activeSession.participants),
|
||||
rawAudio: Object.fromEntries(Array.from(this.audioBuffers.entries()).map(([id, chunks]) => [
|
||||
id,
|
||||
chunks.length,
|
||||
])),
|
||||
transcript: this.sessionSegments,
|
||||
};
|
||||
console.log(`[INFO] Transcript saved to ${transcriptPath}`);
|
||||
console.log('[INFO] Sending prompt to OpenClaw for action confirmation...');
|
||||
await this.promptOpenClaw(transcriptPath);
|
||||
await writeFile(filepath, JSON.stringify(data, null, 2));
|
||||
console.log(`[INFO] Transcript saved to ${filepath}`);
|
||||
return filepath;
|
||||
}
|
||||
async promptOpenClaw(transcriptPath) {
|
||||
const message = `Meeting ended. Transcript saved to ${transcriptPath}.
|
||||
async promptForActions(transcriptPath) {
|
||||
const promptText = `Meeting recording complete!
|
||||
|
||||
What would you like to do?`;
|
||||
console.log(`[PROMPT] ${message}`);
|
||||
console.log('[INFO] Awaiting user decision via OpenClaw...');
|
||||
**Channel:** ${this.activeSession?.channelName}
|
||||
**Duration:** ${this.getSessionDuration()}
|
||||
**Transcript:** \`${transcriptPath}\`
|
||||
|
||||
Would you like me to generate structured notes and action items from this meeting?`;
|
||||
try {
|
||||
await fetch(`${this.openclawGatewayUrl}/api/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channel: 'discord',
|
||||
action: 'send',
|
||||
to: `user:${this.targetUsers[0]}`,
|
||||
message: promptText,
|
||||
components: {
|
||||
text: promptText,
|
||||
blocks: [
|
||||
{
|
||||
type: 'actions',
|
||||
buttons: [
|
||||
{ label: 'Yes - Generate Notes', style: 'success' },
|
||||
{ label: 'No - Just Save Transcript', style: 'secondary' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
console.log('[INFO] Prompt sent to user via OpenClaw');
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[ERROR] Failed to send prompt:', err);
|
||||
}
|
||||
}
|
||||
async start() {
|
||||
await this.client.login(this.config.discordToken);
|
||||
await this.client.login(this.discordToken);
|
||||
}
|
||||
async stop() {
|
||||
if (this.checkInterval) {
|
||||
@ -201,8 +347,7 @@ What would you like to do?`;
|
||||
}
|
||||
}
|
||||
async function main() {
|
||||
const config = await loadConfig();
|
||||
const bot = new MeetingNotesBot(config);
|
||||
const bot = new MeetingNotesBot();
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n[INFO] Shutting down...');
|
||||
await bot.stop();
|
||||
|
||||
14
tools/meeting-notes/meeting-notes.service
Normal file
14
tools/meeting-notes/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
|
||||
@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.18.0",
|
||||
"discord.js": "^14.18.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"prism-media": "^1.3.5"
|
||||
},
|
||||
|
||||
@ -1,184 +0,0 @@
|
||||
import {
|
||||
joinVoiceChannel,
|
||||
VoiceConnection,
|
||||
getVoiceConnection,
|
||||
createAudioResource,
|
||||
AudioReceiveStream,
|
||||
EndBehaviorType,
|
||||
} from '@discordjs/voice';
|
||||
import { pipeline, Transform, PassThrough } from 'stream';
|
||||
import { createWriteStream, mkdirSync, existsSync } from 'fs';
|
||||
import { resolve, basename } from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import type { TranscriptSegment } from './types.js';
|
||||
|
||||
const FRAME_SIZE = 960;
|
||||
const SAMPLE_RATE = 48000;
|
||||
const CHANNELS = 2;
|
||||
|
||||
export class AudioCapture {
|
||||
private streams: Map<string, AudioReceiveStream> = new Map();
|
||||
private buffers: Map<string, Buffer[]> = new Map();
|
||||
private userIdToName: Map<string, string> = new Map();
|
||||
private outputDir: string;
|
||||
private onSegment?: (userId: string, text: string, timestamp: number) => void;
|
||||
|
||||
constructor(outputDir: string) {
|
||||
this.outputDir = outputDir;
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
setOnSegment(callback: (userId: string, text: string, timestamp: number) => void): void {
|
||||
this.onSegment = callback;
|
||||
}
|
||||
|
||||
startCapture(connection: VoiceConnection, participants: Map<string, string>): void {
|
||||
this.userIdToName = new Map(participants);
|
||||
const receiver = connection.receiver;
|
||||
|
||||
for (const [userId, userName] of participants) {
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
this.buffers.set(userId, []);
|
||||
|
||||
const transform = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
this.push(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
audioStream.on('data', (chunk: Buffer) => {
|
||||
const buffers = this.buffers.get(userId) || [];
|
||||
buffers.push(chunk);
|
||||
this.buffers.set(userId, buffers);
|
||||
});
|
||||
|
||||
audioStream.on('end', () => {
|
||||
console.log(`[AUDIO] Stream ended for ${userName || userId}`);
|
||||
this.processUserAudio(userId);
|
||||
});
|
||||
|
||||
audioStream.on('error', (err) => {
|
||||
console.error(`[AUDIO] Error for ${userName || userId}:`, err);
|
||||
});
|
||||
|
||||
this.streams.set(userId, audioStream);
|
||||
console.log(`[AUDIO] Capturing audio for ${userName || userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
stopCapture(): void {
|
||||
for (const [userId, stream] of this.streams) {
|
||||
stream.destroy();
|
||||
}
|
||||
this.streams.clear();
|
||||
}
|
||||
|
||||
private async processUserAudio(userId: string): Promise<void> {
|
||||
const buffers = this.buffers.get(userId);
|
||||
if (!buffers || buffers.length === 0) return;
|
||||
|
||||
const totalLength = buffers.reduce((acc, buf) => acc + buf.length, 0);
|
||||
const combined = Buffer.concat(buffers, totalLength);
|
||||
|
||||
const userName = this.userIdToName.get(userId) || userId;
|
||||
const timestamp = Date.now();
|
||||
const filename = `${userName}-${timestamp}.raw`;
|
||||
const filepath = resolve(this.outputDir, filename);
|
||||
|
||||
require('fs').writeFileSync(filepath, combined);
|
||||
console.log(`[AUDIO] Saved ${buffers.length} chunks (${totalLength} bytes) for ${userName}`);
|
||||
|
||||
const text = await this.transcribe(filepath);
|
||||
if (text && this.onSegment) {
|
||||
this.onSegment(userId, text, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
private async transcribe(audioPath: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const wavPath = audioPath.replace('.raw', '.wav');
|
||||
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-f', 's16le',
|
||||
'-ar', String(SAMPLE_RATE),
|
||||
'-ac', String(CHANNELS),
|
||||
'-i', audioPath,
|
||||
'-y',
|
||||
wavPath,
|
||||
]);
|
||||
|
||||
ffmpeg.on('close', async (code) => {
|
||||
if (code !== 0) {
|
||||
console.error(`[TRANSCRIBE] ffmpeg failed with code ${code}`);
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await this.transcribeWithHyprwhspr(wavPath);
|
||||
resolve(text);
|
||||
});
|
||||
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
// Silence ffmpeg noise
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async transcribeWithHyprwhspr(wavPath: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const python = spawn('python3', [
|
||||
'-c',
|
||||
`
|
||||
import sys
|
||||
sys.path.insert(0, '/usr/lib/hyprwhspr/lib/src')
|
||||
import onnx_asr
|
||||
model = onnx_asr.load_model('nemo-parakeet-tdt-0.6b-v3', quantization='int8')
|
||||
with open('${wavPath}', 'rb') as f:
|
||||
result = model.transcribe(f.read())
|
||||
print(result if isinstance(result, str) else result.get('text', ''))
|
||||
`,
|
||||
]);
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
python.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
python.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
python.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
console.error(`[TRANSCRIBE] onnx_asr failed: ${error}`);
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
const text = output.trim();
|
||||
console.log(`[TRANSCRIBE] Result: "${text}"`);
|
||||
resolve(text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getSegments(): TranscriptSegment[] {
|
||||
const segments: TranscriptSegment[] = [];
|
||||
// This will be populated by the onSegment callback
|
||||
return segments;
|
||||
}
|
||||
|
||||
getRawBuffers(): Map<string, Buffer[]> {
|
||||
return new Map(this.buffers);
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,35 @@
|
||||
import 'dotenv/config';
|
||||
import { Client, GatewayIntentBits, Guild, VoiceState, ChannelType, Events } from 'discord.js';
|
||||
import { joinVoiceChannel, VoiceConnection, getVoiceConnection } from '@discordjs/voice';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { joinVoiceChannel, VoiceConnection, getVoiceConnection, EndBehaviorType } from '@discordjs/voice';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { homedir } from 'os';
|
||||
import type { MeetingConfig, MeetingSession, TranscriptSegment } from './types.js';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const SAMPLE_RATE = 48000;
|
||||
const CHANNELS = 2;
|
||||
|
||||
interface TranscriptSegment {
|
||||
speakerId: string;
|
||||
speakerName: string;
|
||||
timestamp: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface MeetingSession {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
participants: Map<string, string>;
|
||||
transcript: TranscriptSegment[];
|
||||
}
|
||||
|
||||
function expandPath(p: string): string {
|
||||
if (p.startsWith('~')) {
|
||||
return homedir() + p.slice(1);
|
||||
@ -15,23 +37,83 @@ function expandPath(p: string): string {
|
||||
return p;
|
||||
}
|
||||
|
||||
async function loadConfig(): Promise<MeetingConfig> {
|
||||
const configPath = resolve(__dirname, '../config.json');
|
||||
const raw = await readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw) as MeetingConfig;
|
||||
config.transcriptsDir = expandPath(config.transcriptsDir);
|
||||
return config;
|
||||
function getEnv(key: string, required = true): string {
|
||||
const value = process.env[key];
|
||||
if (!value && required) {
|
||||
throw new Error(`Missing required env var: ${key}`);
|
||||
}
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function getEnvList(key: string): string[] {
|
||||
const value = getEnv(key);
|
||||
return value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function transcribeWithOnnxAsr(wavPath: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const python = spawn('python3', [
|
||||
'-c',
|
||||
`
|
||||
import sys
|
||||
import os
|
||||
os.environ['ORT_LOGGING_LEVEL'] = '4'
|
||||
sys.path.insert(0, '/usr/lib/hyprwhspr/lib/src')
|
||||
import onnx_asr
|
||||
model = onnx_asr.load_model('nemo-parakeet-tdt-0.6b-v3', quantization='int8')
|
||||
with open('${wavPath}', 'rb') as f:
|
||||
result = model.transcribe(f.read())
|
||||
print(result if isinstance(result, str) else result.get('text', ''))
|
||||
`.trim(),
|
||||
]);
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
python.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
python.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
python.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
console.error(`[TRANSCRIBE] onnx_asr failed: ${error.slice(0, 200)}`);
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
resolve(output.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class MeetingNotesBot {
|
||||
private client: Client;
|
||||
private config: MeetingConfig;
|
||||
private discordToken: string;
|
||||
private targetUsers: string[];
|
||||
private transcriptsDir: string;
|
||||
private openclawGatewayUrl: string;
|
||||
private guildId: string;
|
||||
private activeSession: MeetingSession | null = null;
|
||||
private audioBuffers: Map<string, Buffer[]> = new Map();
|
||||
private checkInterval: NodeJS.Timeout | null = null;
|
||||
private userIdToName: Map<string, string> = new Map();
|
||||
private checkInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private connection: VoiceConnection | null = null;
|
||||
private sessionSegments: TranscriptSegment[] = [];
|
||||
|
||||
constructor() {
|
||||
this.discordToken = getEnv('discordToken');
|
||||
this.targetUsers = getEnvList('targetUsers');
|
||||
this.transcriptsDir = expandPath(getEnv('transcriptsDir'));
|
||||
this.openclawGatewayUrl = getEnv('openclawGatewayUrl');
|
||||
this.guildId = getEnv('guildId');
|
||||
|
||||
if (!existsSync(this.transcriptsDir)) {
|
||||
mkdirSync(this.transcriptsDir, { recursive: true });
|
||||
}
|
||||
|
||||
constructor(config: MeetingConfig) {
|
||||
this.config = config;
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
@ -56,6 +138,7 @@ class MeetingNotesBot {
|
||||
|
||||
private startMonitoring(): void {
|
||||
console.log('[INFO] Starting VC monitoring...');
|
||||
console.log(`[INFO] Watching for users: ${this.targetUsers.join(', ')}`);
|
||||
this.checkInterval = setInterval(() => {
|
||||
this.checkVoiceChannels();
|
||||
}, 5000);
|
||||
@ -65,22 +148,25 @@ class MeetingNotesBot {
|
||||
private async checkVoiceChannels(): Promise<void> {
|
||||
if (this.activeSession) return;
|
||||
|
||||
const guild = this.client.guilds.cache.get(this.config.guildId);
|
||||
if (!guild) return;
|
||||
const guild = this.client.guilds.cache.get(this.guildId);
|
||||
if (!guild) {
|
||||
console.log('[WARN] Guild not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const voiceChannels = guild.channels.cache.filter(
|
||||
(ch) => ch.type === ChannelType.GuildVoice
|
||||
);
|
||||
|
||||
for (const [, channel] of voiceChannels) {
|
||||
const members = channel.members;
|
||||
const targetUserIds = this.config.targetUsers;
|
||||
const members = (channel as any).members as Map<string, any>;
|
||||
if (!members) continue;
|
||||
|
||||
const presentTargets = targetUserIds.filter((id) => members.has(id));
|
||||
const presentTargets = this.targetUsers.filter((id) => members.has(id));
|
||||
|
||||
if (presentTargets.length === targetUserIds.length) {
|
||||
console.log(`[INFO] All target users in ${channel.name}, joining...`);
|
||||
await this.joinAndRecord(channel.id, channel.name, guild);
|
||||
if (presentTargets.length === this.targetUsers.length) {
|
||||
console.log(`[INFO] All target users in "${channel.name}", joining...`);
|
||||
await this.joinAndRecord(channel.id, channel.name, guild, members);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -89,17 +175,16 @@ class MeetingNotesBot {
|
||||
private handleVoiceStateChange(oldState: VoiceState, newState: VoiceState): void {
|
||||
if (!this.activeSession) return;
|
||||
|
||||
const targetUserIds = this.config.targetUsers;
|
||||
const userId = newState.id;
|
||||
|
||||
if (!targetUserIds.includes(userId)) return;
|
||||
if (!this.targetUsers.includes(userId)) return;
|
||||
|
||||
const leftChannel = oldState.channelId === this.activeSession.channelId && !newState.channelId;
|
||||
const switchedChannel = oldState.channelId === this.activeSession.channelId &&
|
||||
newState.channelId !== this.activeSession.channelId;
|
||||
|
||||
if (leftChannel || switchedChannel) {
|
||||
console.log(`[INFO] Target user ${userId} left the channel`);
|
||||
console.log(`[INFO] Target user left the channel`);
|
||||
this.checkShouldEndSession();
|
||||
}
|
||||
}
|
||||
@ -107,14 +192,14 @@ class MeetingNotesBot {
|
||||
private checkShouldEndSession(): void {
|
||||
if (!this.activeSession) return;
|
||||
|
||||
const guild = this.client.guilds.cache.get(this.config.guildId);
|
||||
const guild = this.client.guilds.cache.get(this.guildId);
|
||||
if (!guild) return;
|
||||
|
||||
const channel = guild.channels.cache.get(this.activeSession.channelId);
|
||||
if (!channel || channel.type !== ChannelType.GuildVoice) return;
|
||||
|
||||
const members = (channel as any).members;
|
||||
const presentTargets = this.config.targetUsers.filter((id) => members?.has(id));
|
||||
const members = (channel as any).members as Map<string, any>;
|
||||
const presentTargets = this.targetUsers.filter((id) => members?.has(id));
|
||||
|
||||
if (presentTargets.length < 2) {
|
||||
console.log('[INFO] Not enough target users remaining, ending session...');
|
||||
@ -122,8 +207,13 @@ class MeetingNotesBot {
|
||||
}
|
||||
}
|
||||
|
||||
private async joinAndRecord(channelId: string, channelName: string, guild: Guild): Promise<void> {
|
||||
const connection = joinVoiceChannel({
|
||||
private async joinAndRecord(
|
||||
channelId: string,
|
||||
channelName: string,
|
||||
guild: Guild,
|
||||
members: Map<string, any>
|
||||
): Promise<void> {
|
||||
this.connection = joinVoiceChannel({
|
||||
channelId,
|
||||
guildId: guild.id,
|
||||
adapterCreator: guild.voiceAdapterCreator,
|
||||
@ -140,29 +230,99 @@ class MeetingNotesBot {
|
||||
transcript: [],
|
||||
};
|
||||
|
||||
this.setupAudioReceivers(connection);
|
||||
this.audioBuffers.clear();
|
||||
this.sessionSegments = [];
|
||||
|
||||
for (const [id, member] of members) {
|
||||
this.userIdToName.set(id, member.user?.username || id);
|
||||
this.activeSession.participants.set(id, member.user?.username || id);
|
||||
}
|
||||
|
||||
this.setupAudioReceivers();
|
||||
console.log(`[INFO] Recording session started in ${channelName}`);
|
||||
}
|
||||
|
||||
private setupAudioReceivers(connection: VoiceConnection): void {
|
||||
const { receiver } = connection;
|
||||
private setupAudioReceivers(): void {
|
||||
if (!this.connection) return;
|
||||
|
||||
connection.receiver.speaking.on('start', (userId) => {
|
||||
if (!this.activeSession) return;
|
||||
const receiver = this.connection.receiver;
|
||||
|
||||
if (!this.audioBuffers.has(userId)) {
|
||||
this.audioBuffers.set(userId, []);
|
||||
}
|
||||
});
|
||||
for (const [userId, userName] of this.userIdToName) {
|
||||
if (!this.targetUsers.includes(userId)) continue;
|
||||
|
||||
receiver.subscriptions.forEach((subscription, userId) => {
|
||||
const chunks: Buffer[] = [];
|
||||
subscription.on('data', (chunk: Buffer) => {
|
||||
if (!this.activeSession) return;
|
||||
chunks.push(chunk);
|
||||
this.audioBuffers.set(userId, chunks);
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
this.audioBuffers.set(userId, []);
|
||||
|
||||
audioStream.on('data', (chunk: Buffer) => {
|
||||
const buffers = this.audioBuffers.get(userId) || [];
|
||||
buffers.push(chunk);
|
||||
this.audioBuffers.set(userId, buffers);
|
||||
});
|
||||
|
||||
audioStream.on('end', () => {
|
||||
console.log(`[AUDIO] Stream ended for ${userName}`);
|
||||
this.processUserAudioChunk(userId);
|
||||
});
|
||||
|
||||
audioStream.on('error', (err) => {
|
||||
console.error(`[AUDIO] Error for ${userName}:`, err.message);
|
||||
});
|
||||
|
||||
console.log(`[AUDIO] Listening to ${userName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async processUserAudioChunk(userId: string): Promise<void> {
|
||||
const buffers = this.audioBuffers.get(userId);
|
||||
if (!buffers || buffers.length === 0) return;
|
||||
|
||||
const totalLength = buffers.reduce((acc, buf) => acc + buf.length, 0);
|
||||
const combined = Buffer.concat(buffers, totalLength);
|
||||
this.audioBuffers.set(userId, []);
|
||||
|
||||
const userName = this.userIdToName.get(userId) || userId;
|
||||
const timestamp = Date.now();
|
||||
const rawFile = resolve(this.transcriptsDir, `chunk-${userName}-${timestamp}.raw`);
|
||||
const wavFile = rawFile.replace('.raw', '.wav');
|
||||
|
||||
const fs = await import('fs');
|
||||
fs.writeFileSync(rawFile, combined);
|
||||
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-f', 's16le',
|
||||
'-ar', String(SAMPLE_RATE),
|
||||
'-ac', String(CHANNELS),
|
||||
'-i', rawFile,
|
||||
'-y', wavFile,
|
||||
]);
|
||||
|
||||
ffmpeg.on('close', async (code) => {
|
||||
unlinkSync(rawFile);
|
||||
|
||||
if (code !== 0) {
|
||||
console.error(`[FFMPEG] Failed with code ${code}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await transcribeWithOnnxAsr(wavFile);
|
||||
unlinkSync(wavFile);
|
||||
|
||||
if (text) {
|
||||
const segment: TranscriptSegment = {
|
||||
speakerId: userId,
|
||||
speakerName: userName,
|
||||
timestamp,
|
||||
text,
|
||||
};
|
||||
this.sessionSegments.push(segment);
|
||||
console.log(`[TRANSCRIPT] ${userName}: "${text}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -170,14 +330,19 @@ class MeetingNotesBot {
|
||||
if (!this.activeSession) return;
|
||||
|
||||
this.activeSession.endTime = new Date();
|
||||
console.log(`[INFO] Session ended. Duration: ${this.getSessionDuration()}`);
|
||||
const duration = this.getSessionDuration();
|
||||
console.log(`[INFO] Session ended. Duration: ${duration}`);
|
||||
|
||||
const connection = getVoiceConnection(this.config.guildId);
|
||||
connection?.destroy();
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
this.connection?.destroy();
|
||||
this.connection = null;
|
||||
|
||||
const transcriptPath = await this.saveTranscript();
|
||||
await this.promptForActions(transcriptPath);
|
||||
|
||||
await this.processTranscript();
|
||||
this.activeSession = null;
|
||||
this.audioBuffers.clear();
|
||||
this.sessionSegments = [];
|
||||
}
|
||||
|
||||
private getSessionDuration(): string {
|
||||
@ -189,44 +354,68 @@ class MeetingNotesBot {
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
private async processTranscript(): Promise<void> {
|
||||
if (!this.activeSession) return;
|
||||
private async saveTranscript(): Promise<string> {
|
||||
if (!this.activeSession) return '';
|
||||
|
||||
const timestamp = this.activeSession.startTime.toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `meeting-${timestamp}.json`;
|
||||
const transcriptPath = resolve(this.config.transcriptsDir, filename);
|
||||
const filepath = resolve(this.transcriptsDir, filename);
|
||||
|
||||
const transcriptData = {
|
||||
const data = {
|
||||
channel: this.activeSession.channelName,
|
||||
startTime: this.activeSession.startTime,
|
||||
endTime: this.activeSession.endTime,
|
||||
duration: this.getSessionDuration(),
|
||||
participants: Object.fromEntries(this.activeSession.participants),
|
||||
rawAudio: Object.fromEntries(
|
||||
Array.from(this.audioBuffers.entries()).map(([id, chunks]) => [
|
||||
id,
|
||||
chunks.length,
|
||||
])
|
||||
),
|
||||
transcript: this.sessionSegments,
|
||||
};
|
||||
|
||||
console.log(`[INFO] Transcript saved to ${transcriptPath}`);
|
||||
console.log('[INFO] Sending prompt to OpenClaw for action confirmation...');
|
||||
await writeFile(filepath, JSON.stringify(data, null, 2));
|
||||
console.log(`[INFO] Transcript saved to ${filepath}`);
|
||||
|
||||
await this.promptOpenClaw(transcriptPath);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
private async promptOpenClaw(transcriptPath: string): Promise<void> {
|
||||
const message = `Meeting ended. Transcript saved to ${transcriptPath}.
|
||||
private async promptForActions(transcriptPath: string): Promise<void> {
|
||||
const promptText = `Meeting recording complete!
|
||||
|
||||
What would you like to do?`;
|
||||
**Channel:** ${this.activeSession?.channelName}
|
||||
**Duration:** ${this.getSessionDuration()}
|
||||
**Transcript:** \`${transcriptPath}\`
|
||||
|
||||
console.log(`[PROMPT] ${message}`);
|
||||
console.log('[INFO] Awaiting user decision via OpenClaw...');
|
||||
Would you like me to generate structured notes and action items from this meeting?`;
|
||||
|
||||
try {
|
||||
await fetch(`${this.openclawGatewayUrl}/api/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channel: 'discord',
|
||||
action: 'send',
|
||||
to: `user:${this.targetUsers[0]}`,
|
||||
message: promptText,
|
||||
components: {
|
||||
text: promptText,
|
||||
blocks: [
|
||||
{
|
||||
type: 'actions',
|
||||
buttons: [
|
||||
{ label: 'Yes - Generate Notes', style: 'success' },
|
||||
{ label: 'No - Just Save Transcript', style: 'secondary' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
console.log('[INFO] Prompt sent to user via OpenClaw');
|
||||
} catch (err) {
|
||||
console.error('[ERROR] Failed to send prompt:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.client.login(this.config.discordToken);
|
||||
await this.client.login(this.discordToken);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
@ -242,8 +431,7 @@ What would you like to do?`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = await loadConfig();
|
||||
const bot = new MeetingNotesBot(config);
|
||||
const bot = new MeetingNotesBot();
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n[INFO] Shutting down...');
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
export interface MeetingConfig {
|
||||
discordToken: string;
|
||||
targetUsers: string[];
|
||||
transcriptsDir: string;
|
||||
openclawGatewayUrl: string;
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
export interface TranscriptSegment {
|
||||
speakerId: string;
|
||||
speakerName: string;
|
||||
timestamp: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MeetingSession {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
participants: Map<string, string>;
|
||||
transcript: TranscriptSegment[];
|
||||
}
|
||||
|
||||
export interface TranscriptionResult {
|
||||
text: string;
|
||||
confidence?: number;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user