333 lines
11 KiB
Python
333 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
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 discord.ext.voice_recv import opus as voice_recv_opus
|
|
from discord.opus import OpusError
|
|
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
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_original_decode_packet = voice_recv_opus.PacketDecoder._decode_packet
|
|
|
|
|
|
def _safe_decode_packet(self, packet):
|
|
try:
|
|
return _original_decode_packet(self, packet)
|
|
except OpusError as exc:
|
|
log.warning("Dropping corrupted opus packet for ssrc %s: %s", self.ssrc, exc)
|
|
if packet:
|
|
return packet, b""
|
|
try:
|
|
next_packet = self._buffer.peek_next()
|
|
if next_packet is not None:
|
|
return packet, self._decoder.decode(next_packet.decrypted_data, fec=True)
|
|
return packet, self._decoder.decode(None, fec=False)
|
|
except Exception:
|
|
return packet, b""
|
|
|
|
|
|
voice_recv_opus.PacketDecoder._decode_packet = _safe_decode_packet
|
|
|
|
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)
|