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