From 216df68f0b7a07023a7c5232c868c1c44a6aacf7 Mon Sep 17 00:00:00 2001 From: Pheby Date: Mon, 8 Jun 2026 01:45:10 +0000 Subject: [PATCH] feat: initial discord meeting bot --- .dockerignore | 8 ++ .env.example | 2 + .gitignore | 6 + DEPLOYMENT.md | 76 +++++++++++ Dockerfile | 23 ++++ bot.py | 306 ++++++++++++++++++++++++++++++++++++++++++ config.py | 33 +++++ docker-compose.yml | 15 +++ helpers.py | 58 ++++++++ openrouter_client.py | 105 +++++++++++++++ requirements.txt | 6 + tests/test_helpers.py | 54 ++++++++ voice.py | 33 +++++ 13 files changed, 725 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 bot.py create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 helpers.py create mode 100644 openrouter_client.py create mode 100644 requirements.txt create mode 100644 tests/test_helpers.py create mode 100644 voice.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6f4bdbf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +.pytest_cache/ +recordings/ +config.json +.env +.git/ +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6554701 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DISCORD_BOT_TOKEN=*** +OPENROUTER_API_KEY=*** diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0b4893 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +.env +__pycache__/ +*.pyc +recordings/ +config.json diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..32c8d4c --- /dev/null +++ b/DEPLOYMENT.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..99b8fd9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..6e0d5a7 --- /dev/null +++ b/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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..4785148 --- /dev/null +++ b/config.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..664db9b --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..3ec6a87 --- /dev/null +++ b/helpers.py @@ -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 diff --git a/openrouter_client.py b/openrouter_client.py new file mode 100644 index 0000000..1b0fbd0 --- /dev/null +++ b/openrouter_client.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d67fc73 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..96720d8 --- /dev/null +++ b/tests/test_helpers.py @@ -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>." + ) diff --git a/voice.py b/voice.py new file mode 100644 index 0000000..2ddf2d1 --- /dev/null +++ b/voice.py @@ -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