feat: initial discord meeting bot
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
recordings/
|
||||||
|
config.json
|
||||||
|
.env
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DISCORD_BOT_TOKEN=***
|
||||||
|
OPENROUTER_API_KEY=***
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
recordings/
|
||||||
|
config.json
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Discord Meeting Summary Bot — Deployment
|
||||||
|
|
||||||
|
## Local testing
|
||||||
|
|
||||||
|
1. Create a `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||||
|
OPENROUTER_API_KEY=your_openrouter_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run locally with Python:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/data/discord-meeting-bot
|
||||||
|
source .venv/bin/activate
|
||||||
|
python3 bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. In Discord:
|
||||||
|
- `/set_output #your-channel`
|
||||||
|
- `/join`
|
||||||
|
- talk in voice
|
||||||
|
- `/leave`
|
||||||
|
|
||||||
|
## Docker / CasaOS
|
||||||
|
|
||||||
|
This repository now includes:
|
||||||
|
- `Dockerfile`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `.dockerignore`
|
||||||
|
|
||||||
|
### Build and run with Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/data/discord-meeting-bot
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistent data
|
||||||
|
|
||||||
|
The compose file stores:
|
||||||
|
- `config.json` — saved channel settings
|
||||||
|
- `recordings/` — temporary meeting audio
|
||||||
|
|
||||||
|
### CasaOS setup
|
||||||
|
|
||||||
|
In CasaOS, create a custom app from the compose file or use the Docker custom app UI and point it at this repo.
|
||||||
|
|
||||||
|
Use a persistent directory like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/DATA/AppData/discord-meeting-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount it so the container sees:
|
||||||
|
- `/app/config.json`
|
||||||
|
- `/app/recordings`
|
||||||
|
|
||||||
|
Make sure the container gets these environment variables:
|
||||||
|
- `DISCORD_BOT_TOKEN`
|
||||||
|
- `OPENROUTER_API_KEY`
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
Check the container logs if something fails:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The bot only responds to commands in the configured output channel, except `/set_output`.
|
||||||
|
- `/join` requires the user to be in a voice channel.
|
||||||
|
- `/leave` stops recording and posts the summary to the configured output channel.
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.13-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt ./requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN useradd -m -u 1000 botuser \
|
||||||
|
&& chown -R botuser:botuser /app
|
||||||
|
|
||||||
|
USER botuser
|
||||||
|
|
||||||
|
CMD ["python", "bot.py"]
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
from discord.ext import commands, voice_recv
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
import config
|
||||||
|
from helpers import chunk_message, command_channel_error
|
||||||
|
from openrouter_client import summarize, transcribe
|
||||||
|
from voice import MeetingRecorder
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||||
|
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY")
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.voice_states = True
|
||||||
|
|
||||||
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
|
recorders: dict[int, MeetingRecorder] = {}
|
||||||
|
processing: set[int] = set()
|
||||||
|
commands_synced = False
|
||||||
|
|
||||||
|
|
||||||
|
@bot.event
|
||||||
|
async def on_ready():
|
||||||
|
global commands_synced
|
||||||
|
print(f"Logged in as {bot.user}")
|
||||||
|
if not commands_synced:
|
||||||
|
try:
|
||||||
|
synced = await bot.tree.sync()
|
||||||
|
commands_synced = True
|
||||||
|
print(f"Synced {len(synced)} commands")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Sync error: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_text_channel(channel_id: int | None):
|
||||||
|
if not channel_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
channel = bot.get_channel(channel_id)
|
||||||
|
if channel is None:
|
||||||
|
try:
|
||||||
|
channel = await bot.fetch_channel(channel_id)
|
||||||
|
except discord.DiscordException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
async def send_chunked(channel, text: str):
|
||||||
|
for chunk in chunk_message(text, limit=1900):
|
||||||
|
await channel.send(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_command_channel(interaction: discord.Interaction) -> str | None:
|
||||||
|
guild_id = interaction.guild_id
|
||||||
|
if guild_id is None:
|
||||||
|
return "❌ This command can only be used in a server."
|
||||||
|
|
||||||
|
allowed_channel_id = await config.get_output_channel(guild_id)
|
||||||
|
return command_channel_error(interaction.channel_id, allowed_channel_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_file_ready(file_path: str, attempts: int = 20, delay: float = 0.25) -> bool:
|
||||||
|
last_size = -1
|
||||||
|
stable_count = 0
|
||||||
|
|
||||||
|
for _ in range(attempts):
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
size = os.path.getsize(file_path)
|
||||||
|
if size > 0 and size == last_size:
|
||||||
|
stable_count += 1
|
||||||
|
if stable_count >= 2:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
stable_count = 0
|
||||||
|
last_size = size
|
||||||
|
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def process_recording(
|
||||||
|
file_path: str,
|
||||||
|
guild_id: int,
|
||||||
|
fallback_channel_id: int | None,
|
||||||
|
error: Exception | None,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
target_channel = await resolve_text_channel(
|
||||||
|
await config.get_output_channel(guild_id) or fallback_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if error is not None:
|
||||||
|
if target_channel:
|
||||||
|
await send_chunked(target_channel, f"❌ Recording failed: {error}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not await wait_for_file_ready(file_path):
|
||||||
|
if target_channel:
|
||||||
|
await target_channel.send(
|
||||||
|
"⚠️ Recording finished, but the audio file was not finalized in time."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
transcript = await transcribe(file_path)
|
||||||
|
summary = await summarize(transcript)
|
||||||
|
|
||||||
|
if target_channel:
|
||||||
|
await send_chunked(target_channel, f"📋 **Meeting Summary**\n\n{summary}")
|
||||||
|
except Exception as exc:
|
||||||
|
error_channel = await resolve_text_channel(fallback_channel_id)
|
||||||
|
if error_channel:
|
||||||
|
await send_chunked(error_channel, f"❌ Meeting summary failed: {exc}")
|
||||||
|
finally:
|
||||||
|
processing.discard(guild_id)
|
||||||
|
recorders.pop(guild_id, None)
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
session_dir = Path(file_path).parent
|
||||||
|
if session_dir.exists() and not any(session_dir.iterdir()):
|
||||||
|
session_dir.rmdir()
|
||||||
|
guild_dir = session_dir.parent
|
||||||
|
if guild_dir.exists() and not any(guild_dir.iterdir()):
|
||||||
|
guild_dir.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_after_callback(file_path: str, guild_id: int, fallback_channel_id: int | None):
|
||||||
|
def _after(error: Exception | None):
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
process_recording(file_path, guild_id, fallback_channel_id, error),
|
||||||
|
bot.loop,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _consume_future(fut):
|
||||||
|
try:
|
||||||
|
fut.result()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Post-recording processing failed: {exc}")
|
||||||
|
|
||||||
|
future.add_done_callback(_consume_future)
|
||||||
|
|
||||||
|
return _after
|
||||||
|
|
||||||
|
|
||||||
|
@app_commands.guild_only()
|
||||||
|
@bot.tree.command(name="set_output", description="Set the text channel for meeting summaries")
|
||||||
|
@app_commands.describe(channel="The text channel to post summaries to")
|
||||||
|
async def set_output(interaction: discord.Interaction, channel: discord.TextChannel):
|
||||||
|
guild_id = interaction.guild_id
|
||||||
|
if guild_id is None:
|
||||||
|
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await config.set_output_channel(guild_id, channel.id)
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"✅ Output channel set to {channel.mention}",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app_commands.guild_only()
|
||||||
|
@bot.tree.command(name="join", description="Join your voice channel and start recording")
|
||||||
|
async def join(interaction: discord.Interaction):
|
||||||
|
guild_id = interaction.guild_id
|
||||||
|
guild = interaction.guild
|
||||||
|
if guild_id is None or guild is None:
|
||||||
|
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if guild_id in processing:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"⚠️ I'm still processing the previous recording for this server.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_error = await ensure_command_channel(interaction)
|
||||||
|
if channel_error:
|
||||||
|
await interaction.response.send_message(channel_error, ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_voice = getattr(interaction.user, "voice", None)
|
||||||
|
if not user_voice or not user_voice.channel:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"❌ You need to be in a voice channel first.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if guild.voice_client:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"⚠️ I'm already connected in this server. Use `/leave` first.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||||
|
|
||||||
|
voice_client = None
|
||||||
|
try:
|
||||||
|
session_dir = Path("recordings") / str(guild_id) / str(uuid.uuid4())
|
||||||
|
audio_path = session_dir / "meeting.wav"
|
||||||
|
after_callback = make_after_callback(str(audio_path), guild_id, interaction.channel_id)
|
||||||
|
|
||||||
|
voice_client = await user_voice.channel.connect(cls=voice_recv.VoiceRecvClient)
|
||||||
|
recorder = MeetingRecorder(voice_client, str(audio_path))
|
||||||
|
await recorder.start(after_callback)
|
||||||
|
recorders[guild_id] = recorder
|
||||||
|
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"🎙️ Joined **{user_voice.channel.name}** and started recording. Use `/leave` to stop.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if voice_client and voice_client.is_connected():
|
||||||
|
try:
|
||||||
|
await voice_client.disconnect(force=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await interaction.followup.send(f"❌ Failed to start recording: {exc}", ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app_commands.guild_only()
|
||||||
|
@bot.tree.command(name="leave", description="Stop recording, transcribe, summarize, and post to output channel")
|
||||||
|
async def leave(interaction: discord.Interaction):
|
||||||
|
guild_id = interaction.guild_id
|
||||||
|
guild = interaction.guild
|
||||||
|
if guild_id is None or guild is None:
|
||||||
|
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
recorder = recorders.get(guild_id)
|
||||||
|
voice_client = guild.voice_client
|
||||||
|
|
||||||
|
channel_error = await ensure_command_channel(interaction)
|
||||||
|
if channel_error:
|
||||||
|
await interaction.response.send_message(channel_error, ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not voice_client or not recorder:
|
||||||
|
await interaction.response.send_message("❌ I'm not recording in this server right now.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
processing.add(guild_id)
|
||||||
|
await recorder.stop()
|
||||||
|
recorders.pop(guild_id, None)
|
||||||
|
await voice_client.disconnect(force=True)
|
||||||
|
await interaction.followup.send(
|
||||||
|
"🎧 Stopped recording. I'm transcribing and summarizing now — I'll post the result in the configured output channel.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
processing.discard(guild_id)
|
||||||
|
await interaction.followup.send(f"❌ Failed to stop recording: {exc}", ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app_commands.guild_only()
|
||||||
|
@bot.tree.command(name="status", description="Check bot state")
|
||||||
|
async def status(interaction: discord.Interaction):
|
||||||
|
guild_id = interaction.guild_id
|
||||||
|
guild = interaction.guild
|
||||||
|
if guild_id is None or guild is None:
|
||||||
|
await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
vc = guild.voice_client
|
||||||
|
recorder = recorders.get(guild_id)
|
||||||
|
channel_error = await ensure_command_channel(interaction)
|
||||||
|
if channel_error:
|
||||||
|
await interaction.response.send_message(channel_error, ephemeral=True)
|
||||||
|
return
|
||||||
|
lines = ["**📊 Bot Status**"]
|
||||||
|
lines.append(f"Voice: {'Connected' if vc else 'Disconnected'}")
|
||||||
|
lines.append(f"Recording: {'Yes' if recorder and recorder.recording else 'No'}")
|
||||||
|
lines.append(f"Processing summary: {'Yes' if guild_id in processing else 'No'}")
|
||||||
|
ch_id = await config.get_output_channel(guild_id)
|
||||||
|
lines.append(f"Output channel: {'<#' + str(ch_id) + '>' if ch_id else 'Not set'}")
|
||||||
|
await interaction.response.send_message("\n".join(lines), ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if not TOKEN:
|
||||||
|
print("ERROR: DISCORD_BOT_TOKEN not set. Create a .env file.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
if not OPENROUTER_KEY:
|
||||||
|
print("ERROR: OPENROUTER_API_KEY not set. Create a .env file.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
bot.run(TOKEN)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import json
|
||||||
|
import aiofiles
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
CONFIG_PATH = Path("config.json")
|
||||||
|
|
||||||
|
|
||||||
|
async def load_config() -> dict:
|
||||||
|
if not CONFIG_PATH.exists():
|
||||||
|
return {}
|
||||||
|
async with aiofiles.open(CONFIG_PATH, "r") as f:
|
||||||
|
data = await f.read()
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
|
||||||
|
async def save_config(config: dict) -> None:
|
||||||
|
async with aiofiles.open(CONFIG_PATH, "w") as f:
|
||||||
|
await f.write(json.dumps(config, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_output_channel(guild_id: int) -> Optional[int]:
|
||||||
|
config = await load_config()
|
||||||
|
return config.get(str(guild_id), {}).get("output_channel_id")
|
||||||
|
|
||||||
|
|
||||||
|
async def set_output_channel(guild_id: int, channel_id: int) -> None:
|
||||||
|
config = await load_config()
|
||||||
|
guild_key = str(guild_id)
|
||||||
|
guild_config = config.get(guild_key, {})
|
||||||
|
guild_config["output_channel_id"] = channel_id
|
||||||
|
config[guild_key] = guild_config
|
||||||
|
await save_config(config)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
discord-meeting-bot:
|
||||||
|
build: .
|
||||||
|
container_name: discord-meeting-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./config.json:/app/config.json
|
||||||
|
- ./recordings:/app/recordings
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_message(text: str, limit: int = 1900) -> list[str]:
|
||||||
|
if len(text) <= limit:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
chunks: list[str] = []
|
||||||
|
remaining = text
|
||||||
|
while remaining:
|
||||||
|
if len(remaining) <= limit:
|
||||||
|
chunks.append(remaining)
|
||||||
|
break
|
||||||
|
|
||||||
|
split_at = remaining.rfind("\n", 0, limit)
|
||||||
|
if split_at <= 0:
|
||||||
|
split_at = limit
|
||||||
|
|
||||||
|
chunks.append(remaining[:split_at])
|
||||||
|
remaining = remaining[split_at:]
|
||||||
|
if remaining.startswith("\n"):
|
||||||
|
remaining = remaining[1:]
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_error(body: Any, fallback: str) -> str:
|
||||||
|
if isinstance(body, dict):
|
||||||
|
err = body.get("error")
|
||||||
|
if isinstance(err, dict):
|
||||||
|
msg = err.get("message")
|
||||||
|
if isinstance(msg, str) and msg.strip():
|
||||||
|
return msg.strip()
|
||||||
|
|
||||||
|
msg = body.get("message")
|
||||||
|
if isinstance(msg, str) and msg.strip():
|
||||||
|
return msg.strip()
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def build_transcript_block(display_name: str, transcript: str) -> str:
|
||||||
|
clean_name = display_name.strip() or "Unknown User"
|
||||||
|
clean_transcript = transcript.strip()
|
||||||
|
return f"[{clean_name}]\n{clean_transcript}"
|
||||||
|
|
||||||
|
|
||||||
|
def command_channel_error(current_channel_id: int | None, allowed_channel_id: int | None) -> str | None:
|
||||||
|
if allowed_channel_id is None:
|
||||||
|
return "⚠️ This bot doesn't have a command channel yet. Use `/set_output` first."
|
||||||
|
|
||||||
|
if current_channel_id != allowed_channel_id:
|
||||||
|
return f"⚠️ This command only works in <#{allowed_channel_id}>."
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from helpers import summarize_error
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _api_key() -> str:
|
||||||
|
return os.getenv("OPENROUTER_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> dict[str, str]:
|
||||||
|
api_key = _api_key()
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("OPENROUTER_API_KEY not set")
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe(audio_path: str) -> str:
|
||||||
|
"""Send a WAV file to OpenRouter's whisper model and return transcript text."""
|
||||||
|
async with httpx.AsyncClient(timeout=300) as client:
|
||||||
|
with open(audio_path, "rb") as audio_file:
|
||||||
|
files = {
|
||||||
|
"file": (os.path.basename(audio_path), audio_file, "audio/wav"),
|
||||||
|
"model": (None, "openai/whisper-large-v3"),
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
f"{OPENROUTER_BASE}/audio/transcriptions",
|
||||||
|
headers=_auth_headers(),
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = summarize_error(_safe_json(resp), fallback=resp.text)
|
||||||
|
raise RuntimeError(f"OpenRouter transcription failed: {detail}") from exc
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
text = data.get("text", "")
|
||||||
|
if not text.strip():
|
||||||
|
raise RuntimeError("OpenRouter transcription returned empty text")
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def summarize(transcript: str) -> str:
|
||||||
|
"""Send transcript to DeepSeek via OpenRouter and return structured meeting summary."""
|
||||||
|
prompt = f"""You are a meeting summarizer. Given the following meeting transcript, produce a concise, well-structured summary with:
|
||||||
|
|
||||||
|
1. **Overview** — 2-3 sentences covering what was discussed
|
||||||
|
2. **Key Decisions** — decisions that were made
|
||||||
|
3. **Action Items** — who needs to do what (use bullet points)
|
||||||
|
4. **Next Steps / Deadlines** — any dates, timelines, or follow-ups mentioned
|
||||||
|
|
||||||
|
If speaker labels are present, preserve them where helpful in action items.
|
||||||
|
|
||||||
|
Transcript:
|
||||||
|
{transcript[:16000]}"""
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": "@preset/cheap-deepseek-v4-flash",
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 1500,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = _auth_headers()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=180) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{OPENROUTER_BASE}/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = summarize_error(_safe_json(resp), fallback=resp.text)
|
||||||
|
raise RuntimeError(f"OpenRouter summarization failed: {detail}") from exc
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
try:
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError, TypeError) as exc:
|
||||||
|
raise RuntimeError("Unexpected OpenRouter summarization response shape") from exc
|
||||||
|
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
raise RuntimeError("OpenRouter summarization returned empty content")
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(resp: httpx.Response) -> Any:
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
discord.py[voice]>=2.4.0
|
||||||
|
discord-ext-voice-recv>=0.5.2
|
||||||
|
aiofiles>=24.1.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
pytest>=9.0.0
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from helpers import (
|
||||||
|
build_transcript_block,
|
||||||
|
chunk_message,
|
||||||
|
command_channel_error,
|
||||||
|
summarize_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunk_message_splits_long_text_under_limit():
|
||||||
|
text = "A" * 4500
|
||||||
|
chunks = chunk_message(text, limit=1900)
|
||||||
|
assert len(chunks) == 3
|
||||||
|
assert all(len(c) <= 1900 for c in chunks)
|
||||||
|
assert "".join(chunks) == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunk_message_keeps_short_text_single_chunk():
|
||||||
|
text = "hello world"
|
||||||
|
assert chunk_message(text, limit=1900) == [text]
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_error_prefers_json_message():
|
||||||
|
body = {"error": {"message": "bad request"}}
|
||||||
|
assert summarize_error(body, fallback="x") == "bad request"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_error_falls_back_to_string():
|
||||||
|
assert summarize_error({"unexpected": True}, fallback="fallback") == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_transcript_block_labels_transcript():
|
||||||
|
block = build_transcript_block("Chris", "Discussed deadlines")
|
||||||
|
assert block == "[Chris]\nDiscussed deadlines"
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_channel_error_allows_matching_channel():
|
||||||
|
assert command_channel_error(current_channel_id=123, allowed_channel_id=123) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_channel_error_blocks_when_not_configured():
|
||||||
|
assert command_channel_error(current_channel_id=123, allowed_channel_id=None) == (
|
||||||
|
"⚠️ This bot doesn't have a command channel yet. Use `/set_output` first."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_channel_error_blocks_wrong_channel():
|
||||||
|
assert command_channel_error(current_channel_id=123, allowed_channel_id=456) == (
|
||||||
|
"⚠️ This command only works in <#456>."
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from discord.ext import voice_recv
|
||||||
|
|
||||||
|
|
||||||
|
class MeetingRecorder:
|
||||||
|
"""Wrapper around discord-ext-voice-recv's listen/stop_listening API."""
|
||||||
|
|
||||||
|
def __init__(self, voice_client: voice_recv.VoiceRecvClient, output_path: str):
|
||||||
|
self.vc = voice_client
|
||||||
|
self.output_path = output_path
|
||||||
|
self.recording = False
|
||||||
|
self.sink: voice_recv.WaveSink | None = None
|
||||||
|
|
||||||
|
async def start(self, after_callback: Callable[[Exception | None], None]) -> None:
|
||||||
|
if self.vc.is_listening():
|
||||||
|
raise RuntimeError("Voice client is already listening")
|
||||||
|
|
||||||
|
Path(self.output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.sink = voice_recv.WaveSink(self.output_path)
|
||||||
|
self.vc.listen(self.sink, after=after_callback)
|
||||||
|
self.recording = True
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if not self.recording:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.vc.is_listening():
|
||||||
|
self.vc.stop_listening()
|
||||||
|
self.recording = False
|
||||||
Reference in New Issue
Block a user