diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..03a597a --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,68 @@ +# GokiBot deployment notes + +## Python dependencies +Install dependencies from `requirements.txt`: + +```bash +python -m pip install -r requirements.txt +``` + +This bot is pinned to **Pycord** so Discord voice recording sinks are available: + +- `py-cord==2.6.1` (provides `discord.sinks`) + +Additional runtime dependencies used by this bot: + +- `PyNaCl==1.6.1` (voice transport encryption support) +- `python-dotenv==1.2.1` (loads `.env` values) + +## Required for music +Music playback and URL resolution commands depend on `yt-dlp` (installed from `requirements.txt`) and system `ffmpeg`. + +## Required for transcription export +Transcript export uses a local Whisper backend. This project installs `faster-whisper` from `requirements.txt` (recommended backend). + +## Raspberry Pi / Linux system packages +Voice/music features require FFmpeg and Opus on the host: + +```bash +sudo apt update +sudo apt install -y ffmpeg libopus0 +``` + +## Environment variables +At minimum, configure: + +- `DISCORD_TOKEN` + +Optional feature variables used by ticketing and alerts: + +- `TICKET_DEV_USER_ID` +- `TICKET_ARCHIVE_CHANNEL_ID` +- `WESROTH_CHANNEL_ID` + +## Discord bot permissions (for all current features) +When inviting the bot, grant the following **OAuth2 bot permissions** so every command works as implemented today: + +- `View Channels` +- `Read Message History` +- `Send Messages` +- `Send Messages in Threads` +- `Create Private Threads` +- `Manage Threads` +- `Add Reactions` +- `Manage Messages` +- `Connect` +- `Speak` + +### Why these are needed +- Poop logging + consent flows use reactions, reaction removal, and message cleanup. +- Ticketing + transcription create private threads, add collaborators, and post in thread channels. +- Ticket archive export reads prior thread history. +- Music playback and live transcription require voice connection and speaking permissions. + +## Discord privileged intent configuration +Enable **Message Content Intent** in the Discord Developer Portal for this bot. The runtime explicitly sets `intents.message_content = True`. + +## Maintenance note +If new bot features require additional Discord permissions or intents, update this document at the same time as the feature change. diff --git a/poopbot.py b/poopbot.py index 3b82b6d..dd2001a 100644 --- a/poopbot.py +++ b/poopbot.py @@ -5,54 +5,129 @@ import random import sqlite3 import asyncio +import logging +import logging.handlers import urllib.request from urllib.parse import urlparse import json +import tempfile +import shutil +import importlib.util +import io +import wave +import ctypes.util import xml.etree.ElementTree as ET from collections import deque from dataclasses import dataclass -from datetime import datetime, timezone, date, time as dtime +from datetime import datetime, timezone, date, time as dtime, timedelta +from pathlib import Path import time - +import sys import discord -from discord import app_commands from discord.ext import commands, tasks +load_dotenv() # loads variables from .env into the process environment + +logger = logging.getLogger("gokibot") + + +def configure_logging() -> None: + log_level_name = os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO" + log_level = getattr(logging, log_level_name, logging.INFO) + log_dir = Path("logs") + log_dir.mkdir(parents=True, exist_ok=True) + formatter = logging.Formatter( + "%(asctime)s %(levelname)s %(name)s %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S%z", + ) + stdout_handler = logging.StreamHandler() + stdout_handler.setFormatter(formatter) + file_handler = logging.handlers.RotatingFileHandler( + log_dir / "gokibot.log", + maxBytes=5 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.setLevel(log_level) + root_logger.addHandler(stdout_handler) + root_logger.addHandler(file_handler) + logger.info("logging_configured level=%s", logging.getLevelName(log_level)) + + +def interaction_log_context(interaction: discord.Interaction) -> dict[str, object]: + return { + "guild_id": getattr(interaction.guild, "id", None), + "channel_id": getattr(interaction.channel, "id", None), + "user_id": getattr(interaction.user, "id", None), + "interaction": getattr(getattr(interaction, "command", None), "qualified_name", None), + } + + +def register_loop_exception_handler(loop: asyncio.AbstractEventLoop) -> None: + if getattr(loop, "_gokibot_exception_handler_installed", False): + return + default_handler = loop.get_exception_handler() + + def _loop_exception_handler(active_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None: + message = context.get("message", "Unhandled asyncio loop exception") + exception = context.get("exception") + if exception is not None: + logger.exception("loop_exception message=%s context=%r", message, context, exc_info=exception) + else: + logger.error("loop_exception message=%s context=%r", message, context) + if default_handler is not None: + default_handler(active_loop, context) + else: + active_loop.default_exception_handler(context) + + loop.set_exception_handler(_loop_exception_handler) + setattr(loop, "_gokibot_exception_handler_installed", True) + logger.info("loop_exception_handler_registered") + + +configure_logging() try: from zoneinfo import ZoneInfo from zoneinfo import ZoneInfoNotFoundError except ImportError: raise RuntimeError("Python 3.9+ required for zoneinfo") +# Ensure Opus is available for voice/recording on Linux +_OPUS_LOAD_ERROR: str | None = None +if not discord.opus.is_loaded(): + for opus_lib_name in ("libopus.so.0", "libopus.so", ctypes.util.find_library("opus")): + if not opus_lib_name: + continue + try: + discord.opus.load_opus(opus_lib_name) + break + except Exception as e: + _OPUS_LOAD_ERROR = str(e) +if not discord.opus.is_loaded() and _OPUS_LOAD_ERROR: + logger.warning("voice opus_load_failed error=%s", _OPUS_LOAD_ERROR) # ========================= # CONFIG # ========================= -load_dotenv() # loads variables from .env into the process environment - TOKEN = os.getenv("DISCORD_TOKEN") if not TOKEN: raise RuntimeError("DISCORD_TOKEN not found. Check your .env file and WorkingDirectory.") - DB_DIR = "db" CONFIG_DB_PATH = os.path.join(DB_DIR, "poopbot_config.db") CLEANUP_DB_PATH = os.path.join(DB_DIR, "poopbot_cleanup.db") - POOP_EMOJI = "πŸ’©" UNDO_EMOJI = "🧻" - # Deletes ANY non-bot message posted in this channel CLEANUP_CHANNEL_ID = 1419130398683959398 - # Ticketing configuration TICKET_DEV_USER_ID = os.getenv("TICKET_DEV_USER_ID") TICKET_ARCHIVE_CHANNEL_ID = os.getenv("TICKET_ARCHIVE_CHANNEL_ID") - # Daily post time (12:00am Pacific) TZ_NAME = "America/Los_Angeles" - # Rotate button message every N poops per guild ROTATE_EVERY = 10 - WESROTH_HANDLE_URL = "https://www.youtube.com/@WesRoth" WESROTH_CHANNEL_ID = os.getenv("WESROTH_CHANNEL_ID") WESROTH_ALERT_CHANNEL_ID = 1350269523902857369 @@ -73,12 +148,31 @@ "UH OH.", "THIS IS IT.", ] - FETCH_TRACK_INFO_TIMEOUT_SECONDS = 25 FETCH_TRACK_INFO_TIMEOUT_MESSAGE = ( "Timed out while fetching track info from YouTube. Please try again in a moment." ) - +TRANSCRIBE_WINDOW_SECONDS = max(float(os.getenv("TRANSCRIBE_WINDOW_SECONDS", "0.5")), 0.5) +TRANSCRIBE_OVERLAP_SECONDS = max(float(os.getenv("TRANSCRIBE_OVERLAP_SECONDS", "0.5")), 0.0) +if TRANSCRIBE_OVERLAP_SECONDS >= TRANSCRIBE_WINDOW_SECONDS: + TRANSCRIBE_OVERLAP_SECONDS = max(0.0, TRANSCRIBE_WINDOW_SECONDS - 0.1) +TRANSCRIBE_EMIT_INTERVAL_SECONDS = max( + float(os.getenv("TRANSCRIBE_EMIT_INTERVAL_SECONDS", str(max(0.5, TRANSCRIBE_WINDOW_SECONDS - TRANSCRIBE_OVERLAP_SECONDS)))), + 0.25, +) +TRANSCRIBE_MAX_QUEUE_DEPTH = max(int(os.getenv("TRANSCRIBE_MAX_QUEUE_DEPTH", "128")), 1) +TRANSCRIBE_MAX_FAILURES = max(int(os.getenv("TRANSCRIBE_MAX_FAILURES", "3")), 1) +TRANSCRIBE_RECORDING_FAILURE_GRACE_INTERVALS = max( + int(os.getenv("TRANSCRIBE_RECORDING_FAILURE_GRACE_INTERVALS", "2")), + 1, +) +TRANSCRIBE_CONSENT_EMOJI = "βœ…" +TRANSCRIBE_MODEL_SIZE = (os.getenv("TRANSCRIBE_MODEL_SIZE") or os.getenv("WHISPER_MODEL") or "base").strip().lower() or "base" +if TRANSCRIBE_MODEL_SIZE not in {"tiny", "base", "small"}: + TRANSCRIBE_MODEL_SIZE = "base" +TRANSCRIBE_LANGUAGE = (os.getenv("TRANSCRIBE_LANGUAGE") or "en").strip().lower() or "en" +TRANSCRIBE_FALLBACK_AVG_LOGPROB = float(os.getenv("TRANSCRIBE_FALLBACK_AVG_LOGPROB", "-1.2")) +TRANSCRIBE_BEAM_SIZE = max(int(os.getenv("TRANSCRIBE_BEAM_SIZE", "5")), 1) # ========================= # MESSAGES # ========================= @@ -139,7 +233,6 @@ "All clear. Nice work, {user}.", "System reset complete, {user}.", "Poop event recorded. Excellent, {user}.", - "Mission accomplished, but at what cost, {user}.", "Payload delivered… with collateral damage, {user}.", "Successful deployment; splash radius exceeded expectations, {user}.", @@ -158,8 +251,6 @@ "Output achieved; the situation got spicy, {user}.", "Task finished. The final seconds were a gamble, {user}.", "All done, {user}. Nobody’s calling that a clean run.", - - # Dota 2 "Space created, {user}.", @@ -204,22 +295,18 @@ "{user} β€” POOPTASTROPHE!", "{user} β€” POOPOCALYPSE!", "{user} β€” POOPIONAIRE!", - ] - UNDO_MSGS = [ "Rollback complete, {user}.", "Okay {user}, I removed your last poop.", "Wiped from history, {user}.", "Deleted one (1) poop from the timeline, {user}.", ] - WESROTH_NS = { "atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015", "media": "http://search.yahoo.com/mrss/", } - # ========================= # TIMEZONE # ========================= @@ -231,51 +318,948 @@ f" python -m pip install tzdata\n" f"Then restart." ) from e - - def current_year_local() -> int: return datetime.now(LOCAL_TZ).year - - # ========================= # DISCORD SETUP # ========================= intents = discord.Intents.default() intents.reactions = True intents.message_content = True # needed for cleanup logging - +intents.voice_states = True # needed to track voice-channel joins/leaves during transcription +intents.members = True # needed to discover/add members to transcript thread and DM consent prompts bot = commands.Bot(command_prefix="!", intents=intents) - # serialize DB writes to avoid sqlite "database is locked" db_write_lock = asyncio.Lock() - - - @dataclass class QueueTrack: title: str source_url: str duration_seconds: int requested_by: int - - class GuildMusicState: def __init__(self): self.queue: deque[QueueTrack] = deque() self.current_track: QueueTrack | None = None self.track_started_at: datetime | None = None self.lock = asyncio.Lock() - - music_states: dict[int, GuildMusicState] = {} - - +class GuildTranscriptionSession: + def __init__(self, guild_id: int, voice_channel_id: int, transcript_thread_id: int): + self.guild_id = guild_id + self.voice_channel_id = voice_channel_id + self.transcript_thread_id = transcript_thread_id + self.started_at = datetime.now(timezone.utc) + self.temp_dir = Path(tempfile.mkdtemp(prefix=f"gokibot_transcribe_{guild_id}_")) + self.aliases_by_user: dict[int, str] = {} + self.consented_user_ids: set[int] = set() + self.prompted_user_ids: set[int] = set() + self.closed = False + self.loop_task: asyncio.Task | None = None + self.worker_task: asyncio.Task | None = None + self.active_sink: object | None = None + self.capture_done = asyncio.Event() + self.recording_failure_count = 0 + self.engine_name: str | None = None + self.engine_instance: object | None = None + self.capture_index = 0 + self.chunk_index = 0 + self.chunk_queue: asyncio.Queue[int] = asyncio.Queue(maxsize=TRANSCRIBE_MAX_QUEUE_DEPTH) + self.transcription_inference_lock = asyncio.Semaphore(1) + self.chunk_meta: list[dict[str, object]] = [] + self.chunk_meta_by_id: dict[int, dict[str, object]] = {} + self.chunk_transcripts: dict[int, list[dict[str, object]]] = {} + self.pending_live_entries: list[dict[str, object]] = [] + self.pending_live_lock = asyncio.Lock() + self.last_phrase_by_user: dict[int, str] = {} + self.user_last_frame: dict[int, int] = {} + self.user_window_start_frame: dict[int, int] = {} + self.chunk_meta_path = self.temp_dir / "chunk_metadata.jsonl" + self.finalized = False + self.no_consented_users_warning_interval = 10 + self.latest_consent_message_id: int | None = None + self.latest_consent_jump_url: str | None = None + + +transcription_sessions: dict[int, GuildTranscriptionSession] = {} +transcription_consent_prompts: dict[int, tuple[int, int, int]] = {} def get_music_state(guild_id: int) -> GuildMusicState: state = music_states.get(guild_id) if state is None: state = GuildMusicState() music_states[guild_id] = state return state +def get_transcription_session(guild_id: int) -> GuildTranscriptionSession | None: + return transcription_sessions.get(guild_id) +def remove_transcription_session(guild_id: int): + session = transcription_sessions.pop(guild_id, None) + if session is None: + return + stale_prompt_ids = [ + message_id + for message_id, (prompt_guild_id, prompt_thread_id, _prompt_user_id) in transcription_consent_prompts.items() + if prompt_guild_id == guild_id and prompt_thread_id == session.transcript_thread_id + ] + for message_id in stale_prompt_ids: + transcription_consent_prompts.pop(message_id, None) + session.engine_name = None + session.engine_instance = None + if session.finalized: + shutil.rmtree(session.temp_dir, ignore_errors=True) +def resolve_display_name(guild: discord.Guild | None, user_id: int, aliases_by_user: dict[int, str]) -> str: + alias = aliases_by_user.get(user_id) + if alias: + return alias + if guild is not None: + member = guild.get_member(user_id) + if member is not None: + return member.display_name + return str(user_id) +def can_record_voice() -> tuple[bool, str]: + if not hasattr(discord, "sinks") or not hasattr(discord.sinks, "WaveSink"): + return False, "This runtime is missing discord voice sinks support." + if importlib.util.find_spec("nacl") is None: + return False, "PyNaCl is not installed in this Python environment." + if not discord.opus.is_loaded(): + return False, "Opus is not loaded (missing libopus on host)." + return True, "" +def get_whisper_transcriber() -> tuple[str | None, object | None]: + if importlib.util.find_spec("faster_whisper") is not None: + from faster_whisper import WhisperModel + model_name = TRANSCRIBE_MODEL_SIZE + model = WhisperModel(model_name, device="cpu", compute_type="int8") + return "faster_whisper", model + if importlib.util.find_spec("whisper") is not None: + import whisper + model_name = TRANSCRIBE_MODEL_SIZE + model = whisper.load_model(model_name) + return "whisper", model + return None, None +def transcribe_audio_file(engine_name: str, engine: object, file_path: Path) -> list[dict[str, object]]: + utterances: list[dict[str, object]] = [] + if engine_name == "faster_whisper": + def _collect_segments(segments_iterable: object) -> list[dict[str, object]]: + collected: list[dict[str, object]] = [] + for seg in segments_iterable: + phrase = (seg.text or "").strip() + if not phrase: + continue + confidence = getattr(seg, "avg_logprob", None) + collected.append({ + "start": float(getattr(seg, "start", 0.0) or 0.0), + "text": phrase, + "confidence": float(confidence) if isinstance(confidence, (int, float)) else None, + }) + return collected + transcribe_kwargs = { + "vad_filter": True, + "task": "transcribe", + "condition_on_previous_text": True, + "beam_size": TRANSCRIBE_BEAM_SIZE, + "vad_parameters": {"min_silence_duration_ms": 500}, + } + segments, info = engine.transcribe(str(file_path), language=TRANSCRIBE_LANGUAGE, **transcribe_kwargs) + utterances = _collect_segments(segments) + confidence_values = [item["confidence"] for item in utterances if isinstance(item.get("confidence"), float)] + avg_logprob = (sum(confidence_values) / len(confidence_values)) if confidence_values else None + if avg_logprob is not None and avg_logprob < TRANSCRIBE_FALLBACK_AVG_LOGPROB: + logger.info( + "transcribe_fallback_to_auto_language file=%s avg_logprob=%.3f threshold=%.3f detected_language=%s", + file_path, + avg_logprob, + TRANSCRIBE_FALLBACK_AVG_LOGPROB, + getattr(info, "language", None), + ) + retry_segments, retry_info = engine.transcribe(str(file_path), language=None, **transcribe_kwargs) + retry_utterances = _collect_segments(retry_segments) + retry_confidence_values = [item["confidence"] for item in retry_utterances if isinstance(item.get("confidence"), float)] + retry_avg_logprob = (sum(retry_confidence_values) / len(retry_confidence_values)) if retry_confidence_values else None + if retry_avg_logprob is not None and (avg_logprob is None or retry_avg_logprob > avg_logprob): + logger.info( + "transcribe_fallback_selected_auto_language file=%s previous_avg=%.3f fallback_avg=%.3f fallback_language=%s", + file_path, + avg_logprob, + retry_avg_logprob, + getattr(retry_info, "language", None), + ) + return retry_utterances + return utterances + if engine_name == "whisper": + result = engine.transcribe(str(file_path), fp16=False, language=TRANSCRIBE_LANGUAGE, task="transcribe") + segments = result.get("segments") if isinstance(result, dict) else None + if isinstance(segments, list): + for seg in segments: + if not isinstance(seg, dict): + continue + phrase = str(seg.get("text") or "").strip() + if not phrase: + continue + confidence = seg.get("avg_logprob") + utterances.append({ + "start": float(seg.get("start") or 0.0), + "text": phrase, + "confidence": float(confidence) if isinstance(confidence, (int, float)) else None, + }) + else: + text_value = str((result or {}).get("text") or "").strip() if isinstance(result, dict) else "" + if text_value: + utterances.append({"start": 0.0, "text": text_value, "confidence": None}) + return utterances + + +def normalize_phrase_for_overlap(value: str) -> str: + normalized = " ".join(value.lower().split()) + return "".join(ch for ch in normalized if ch.isalnum() or ch.isspace()).strip() + + +def remove_overlap_duplicate_text(previous_text: str | None, new_text: str) -> str: + if not new_text: + return "" + candidate = new_text.strip() + if not candidate: + return "" + previous = normalize_phrase_for_overlap(previous_text or "") + current = normalize_phrase_for_overlap(candidate) + if not previous or not current: + return candidate + if current == previous or current in previous: + return "" + if previous in current: + return candidate + prev_tokens = previous.split() + curr_tokens = current.split() + max_overlap = min(len(prev_tokens), len(curr_tokens)) + overlap_tokens = 0 + for length in range(max_overlap, 0, -1): + if prev_tokens[-length:] == curr_tokens[:length]: + overlap_tokens = length + break + if overlap_tokens <= 0: + return candidate + original_tokens = candidate.split() + if overlap_tokens >= len(original_tokens): + return "" + return " ".join(original_tokens[overlap_tokens:]).strip() + + +def normalize_transcript_display_name(name: str) -> str: + compact = " ".join(name.split()).strip() + return compact[:64] + + +def find_active_transcription_thread(guild: discord.Guild, session: GuildTranscriptionSession) -> discord.Thread | None: + thread = guild.get_thread(session.transcript_thread_id) + if thread is not None: + return thread + fetched = guild.get_channel(session.transcript_thread_id) + if isinstance(fetched, discord.Thread): + return fetched + return None + + +async def prompt_transcription_consent( + guild: discord.Guild, + session: GuildTranscriptionSession, + transcript_thread: discord.Thread, + members: list[discord.Member], +): + non_consented: list[discord.Member] = [] + for member in members: + if member.id in session.consented_user_ids: + logger.info( + "transcribe_consent_prompt_skip guild_id=%s thread_id=%s user_id=%s prompt_reason=already_consented", + guild.id, + transcript_thread.id, + member.id, + ) + continue + if member.id in session.prompted_user_ids: + logger.info( + "transcribe_consent_prompt_skip guild_id=%s thread_id=%s user_id=%s prompt_reason=session_already_prompted", + guild.id, + transcript_thread.id, + member.id, + ) + continue + non_consented.append(member) + if not non_consented: + logger.info( + "transcribe_consent_prompt_none guild_id=%s thread_id=%s prompt_reason=session_no_eligible_members", + guild.id, + transcript_thread.id, + ) + return + for member in non_consented: + try: + dm_channel = member.dm_channel or await member.create_dm() + consent_message = await dm_channel.send( + "Transcription is running in this channel, Are you ok with being transcribed for this session only?" + ) + await consent_message.add_reaction(TRANSCRIBE_CONSENT_EMOJI) + except (discord.Forbidden, discord.HTTPException): + logger.exception( + "transcribe_consent_dm_failed guild_id=%s thread_id=%s user_id=%s", + guild.id, + transcript_thread.id, + member.id, + ) + continue + session.prompted_user_ids.add(member.id) + session.latest_consent_message_id = consent_message.id + session.latest_consent_jump_url = consent_message.jump_url + transcription_consent_prompts[consent_message.id] = (guild.id, session.transcript_thread_id, member.id) + logger.info( + "transcribe_consent_prompt_dm_sent guild_id=%s thread_id=%s user_id=%s prompt_reason=session_new", + guild.id, + transcript_thread.id, + member.id, + ) + + +async def sync_voice_channel_members_for_transcription( + guild: discord.Guild, + voice_channel: discord.VoiceChannel, + session: GuildTranscriptionSession, + transcript_thread: discord.Thread, +): + members: list[discord.Member] = [] + logger.info( + "transcribe_sync_members_start guild_id=%s voice_channel_id=%s channel_member_count=%s", + guild.id, + voice_channel.id, + len(getattr(voice_channel, "members", []) or []), + ) + for member in voice_channel.members: + if member.bot: + continue + members.append(member) + try: + await transcript_thread.add_user(member) + logger.info("transcribe_thread_add_user_ok guild_id=%s thread_id=%s user_id=%s", guild.id, transcript_thread.id, member.id) + except (discord.Forbidden, discord.HTTPException): + logger.exception("transcribe_thread_add_user_failed guild_id=%s thread_id=%s user_id=%s", guild.id, transcript_thread.id, member.id) + await transcript_thread.send( + f"⚠️ I couldn't add {member.mention} to this transcript thread. " + "Grant **Manage Threads** for this text channel so I can add members for consent." + ) + if member.id in session.prompted_user_ids: + logger.info( + "transcribe_consent_dm_skip guild_id=%s user_id=%s prompt_reason=session_already_prompted", + guild.id, + member.id, + ) + continue + logger.info( + "transcribe_sync_members_done guild_id=%s discovered_members=%s consented_members=%s", + guild.id, + [member.id for member in members], + sorted(session.consented_user_ids), + ) + await prompt_transcription_consent(guild, session, transcript_thread, members) + + +def slice_timestamp_label(started_at: datetime, seconds_offset: float) -> str: + ts = started_at + timedelta(seconds=max(seconds_offset, 0.0)) + return ts.astimezone(LOCAL_TZ).strftime("%H:%M:%S") + + +def persist_chunk_metadata(session: GuildTranscriptionSession, chunk: dict[str, object]) -> None: + with session.chunk_meta_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(chunk, ensure_ascii=False) + "\n") + + +def capture_chunk_from_sink_audio( + session: GuildTranscriptionSession, + user_id: int, + audio_obj: object, + capture_dir: Path, + *, + sample_rate: int, + sample_width: int, + channels: int, +) -> dict[str, object] | None: + file_obj = getattr(audio_obj, "file", None) + if file_obj is None: + return None + try: + prev_pos = file_obj.tell() if hasattr(file_obj, "tell") else None + if hasattr(file_obj, "seek"): + file_obj.seek(0) + payload = file_obj.read() if hasattr(file_obj, "read") else None + if prev_pos is not None and hasattr(file_obj, "seek"): + file_obj.seek(prev_pos) + except Exception: + return None + if not isinstance(payload, (bytes, bytearray)): + return None + logger.info( + "transcribe_chunk_debug user_id=%s payload_type=%s payload_len=%s head=%s", + user_id, + type(payload).__name__, + len(payload), + bytes(payload[:8]).hex() if len(payload) >= 8 else None, + ) + try: + wav_in = wave.open(io.BytesIO(payload), "rb") + except wave.Error as error: + logger.info( + "transcribe_chunk_wave_open_fallback_pcm user_id=%s payload_len=%s error=%s sample_rate=%s channels=%s sample_width=%s", + user_id, + len(payload), + error, + sample_rate, + channels, + sample_width, + ) + frame_size = max(sample_width * channels, 1) + framerate = max(sample_rate, 1) + total_frames = len(payload) // frame_size + params = (channels, sample_width, framerate, 0, "NONE", "not compressed") + overlap_frames = max(int(TRANSCRIBE_OVERLAP_SECONDS * framerate), 0) + window_frames = max(int(TRANSCRIBE_WINDOW_SECONDS * framerate), 1) + last_window_start = session.user_window_start_frame.get(user_id) + if last_window_start is None: + next_start = 0 + else: + step_frames = max(window_frames - overlap_frames, 1) + next_start = max(last_window_start + step_frames, 0) + if total_frames - next_start < window_frames: + logger.info( + "transcribe_chunk_too_short user_id=%s total_frames=%s next_start=%s needed=%s framerate=%s", + user_id, + total_frames, + next_start, + window_frames, + framerate, + ) + return None + start_byte = next_start * frame_size + end_byte = (next_start + window_frames) * frame_size + frames = bytes(payload[start_byte:end_byte]) + else: + with wav_in: + framerate = wav_in.getframerate() or 1 + total_frames = wav_in.getnframes() + params = wav_in.getparams() + overlap_frames = max(int(TRANSCRIBE_OVERLAP_SECONDS * framerate), 0) + window_frames = max(int(TRANSCRIBE_WINDOW_SECONDS * framerate), 1) + last_window_start = session.user_window_start_frame.get(user_id) + if last_window_start is None: + next_start = 0 + else: + step_frames = max(window_frames - overlap_frames, 1) + next_start = max(last_window_start + step_frames, 0) + if total_frames - next_start < window_frames: + logger.info( + "transcribe_chunk_too_short user_id=%s total_frames=%s next_start=%s needed=%s framerate=%s", + user_id, + total_frames, + next_start, + window_frames, + framerate, + ) + return None + wav_in.setpos(next_start) + frames = wav_in.readframes(window_frames) + session.user_last_frame[user_id] = total_frames + session.user_window_start_frame[user_id] = next_start + chunk_start = next_start / max(framerate, 1) + chunk_end = (next_start + window_frames) / max(framerate, 1) + session.chunk_index += 1 + chunk_file = capture_dir / f"chunk_{session.chunk_index:06d}_u{user_id}.wav" + with wave.open(str(chunk_file), "wb") as wav_out: + wav_out.setparams(params) + wav_out.writeframes(frames) + chunk = { + "chunk_id": session.chunk_index, + "user_id": user_id, + "start_offset": round(chunk_start, 3), + "end_offset": round(chunk_end, 3), + "capture_emitted_at": time.perf_counter(), + "file_path": str(chunk_file), + "queued": False, + "transcribed": False, + } + session.chunk_meta.append(chunk) + session.chunk_meta_by_id[session.chunk_index] = chunk + persist_chunk_metadata(session, chunk) + return chunk + + +def current_total_frames_for_user_audio(audio_obj: object, *, sample_rate: int, sample_width: int, channels: int) -> int | None: + file_obj = getattr(audio_obj, "file", None) + if file_obj is None: + return None + try: + prev_pos = file_obj.tell() if hasattr(file_obj, "tell") else None + if hasattr(file_obj, "seek"): + file_obj.seek(0) + payload = file_obj.read() if hasattr(file_obj, "read") else None + if prev_pos is not None and hasattr(file_obj, "seek"): + file_obj.seek(prev_pos) + except Exception: + return None + if not isinstance(payload, (bytes, bytearray)): + return None + try: + with wave.open(io.BytesIO(payload), "rb") as wav_in: + return int(wav_in.getnframes() or 0) + except wave.Error: + frame_size = max(sample_width * channels, 1) + return len(payload) // frame_size + + +def initialize_user_capture_cursor(session: GuildTranscriptionSession, guild: discord.Guild, user_id: int) -> None: + sink = session.active_sink + sink_audio_data = getattr(sink, "audio_data", None) + if not isinstance(sink_audio_data, dict): + return + vc = guild.voice_client + decoder = getattr(vc, "decoder", None) if vc is not None else None + sample_rate = int(getattr(decoder, "SAMPLING_RATE", 48000) or 48000) + channels = int(getattr(decoder, "CHANNELS", 2) or 2) + sample_size = int(getattr(decoder, "SAMPLE_SIZE", channels * 2) or channels * 2) + sample_width = max(sample_size // max(channels, 1), 1) + for source_user_id, audio_obj in sink_audio_data.items(): + normalized_user_id = None + if isinstance(source_user_id, int): + normalized_user_id = source_user_id + elif isinstance(source_user_id, str) and source_user_id.isdigit(): + normalized_user_id = int(source_user_id) + elif hasattr(source_user_id, "id") and isinstance(getattr(source_user_id, "id"), int): + normalized_user_id = int(getattr(source_user_id, "id")) + if normalized_user_id != user_id: + continue + total_frames = current_total_frames_for_user_audio( + audio_obj, + sample_rate=sample_rate, + sample_width=sample_width, + channels=channels, + ) + if total_frames is None: + return + session.user_last_frame[user_id] = total_frames + session.user_window_start_frame[user_id] = total_frames + return + + +async def apply_transcription_consent( + *, + guild: discord.Guild, + session: GuildTranscriptionSession, + member: discord.Member, + message_id: int, + channel_id: int, +) -> None: + clean_name = normalize_transcript_display_name(member.display_name) + session.consented_user_ids.add(member.id) + if clean_name: + session.aliases_by_user[member.id] = clean_name + initialize_user_capture_cursor(session, guild, member.id) + logger.info( + "transcribe_consent_reaction_accepted guild_id=%s message_id=%s channel_id=%s user_id=%s consented_count=%s", + guild.id, + message_id, + channel_id, + member.id, + len(session.consented_user_ids), + ) + thread = find_active_transcription_thread(guild, session) + if thread is not None: + try: + await thread.add_user(member) + except (discord.Forbidden, discord.HTTPException): + pass + await thread.send(f"Activated transcription for {member.mention}") + + +async def flush_active_recording_buffers(session: GuildTranscriptionSession, guild_id: int) -> int: + sink = session.active_sink + sink_audio_data = getattr(sink, "audio_data", None) + if not isinstance(sink_audio_data, dict): + return 0 + + logger.info( + "transcribe_sink_keys key_types=%s keys=%s consented=%s", + [type(key).__name__ for key in sink_audio_data.keys()], + list(sink_audio_data.keys()), + sorted(session.consented_user_ids), + ) + + guild = bot.get_guild(guild_id) + vc = guild.voice_client if guild is not None else None + decoder = getattr(vc, "decoder", None) + sample_rate = int(getattr(decoder, "SAMPLING_RATE", 48000) or 48000) + channels = int(getattr(decoder, "CHANNELS", 2) or 2) + sample_size = int(getattr(decoder, "SAMPLE_SIZE", channels * 2) or channels * 2) + sample_width = max(sample_size // max(channels, 1), 1) + voice_channel = guild.get_channel(session.voice_channel_id) if guild is not None else None + + session.capture_index += 1 + capture_dir = session.temp_dir / f"capture_{session.capture_index:05d}" + capture_dir.mkdir(parents=True, exist_ok=True) + produced = 0 + if not session.consented_user_ids: + voice_member_count = 0 + if guild is not None: + voice_member_count = len(getattr(voice_channel, "members", []) or []) + warning_interval = max(1, session.no_consented_users_warning_interval) + if session.capture_index % warning_interval == 0: + logger.warning( + "transcribe_capture_skipped_no_consented_users guild_id=%s capture=%s voice_member_count=%s", + guild_id, + session.capture_index, + voice_member_count, + ) + for user_id, audio_obj in sink_audio_data.items(): + normalized_user_id = None + if isinstance(user_id, int): + normalized_user_id = user_id + elif isinstance(user_id, str) and user_id.isdigit(): + normalized_user_id = int(user_id) + elif hasattr(user_id, "id") and isinstance(getattr(user_id, "id"), int): + normalized_user_id = int(getattr(user_id, "id")) + + if normalized_user_id is None or normalized_user_id not in session.consented_user_ids: + continue + chunk = capture_chunk_from_sink_audio( + session, + normalized_user_id, + audio_obj, + capture_dir, + sample_rate=sample_rate, + sample_width=sample_width, + channels=channels, + ) + if chunk is None: + continue + produced += 1 + try: + session.chunk_queue.put_nowait(int(chunk["chunk_id"])) + chunk["queued"] = True + except asyncio.QueueFull: + dropped_chunk_id: int | None = None + try: + dropped_chunk_id = session.chunk_queue.get_nowait() + session.chunk_queue.task_done() + except asyncio.QueueEmpty: + pass + try: + session.chunk_queue.put_nowait(int(chunk["chunk_id"])) + chunk["queued"] = True + except asyncio.QueueFull: + pass + logger.warning( + "transcribe_queue_backpressure guild_id=%s chunk_id=%s dropped_chunk_id=%s queue_depth=%s", + guild_id, + chunk["chunk_id"], + dropped_chunk_id, + session.chunk_queue.qsize(), + ) + logger.info( + "transcribe_capture_flushed guild_id=%s capture=%s produced_chunks=%s queue_size=%s consented_count=%s", + guild_id, + session.capture_index, + produced, + session.chunk_queue.qsize(), + len(session.consented_user_ids), + ) + return produced + + +def build_transcript_lines_for_chunk( + guild: discord.Guild | None, + session: GuildTranscriptionSession, + chunk: dict[str, object], + utterances: list[dict[str, object]], +) -> list[dict[str, object]]: + user_id = int(chunk["user_id"]) + speaker_name = resolve_display_name(guild, user_id, session.aliases_by_user) + base_offset = float(chunk["start_offset"]) + emitted_at = float(chunk.get("capture_emitted_at") or 0.0) + lines: list[dict[str, object]] = [] + for utterance in utterances: + phrase = str(utterance.get("text") or "").strip() + if not phrase: + continue + deduped_phrase = remove_overlap_duplicate_text(session.last_phrase_by_user.get(user_id), phrase) + if not deduped_phrase: + continue + session.last_phrase_by_user[user_id] = phrase + rel_start = float(utterance.get("start") or 0.0) + absolute_start = base_offset + rel_start + stamp = slice_timestamp_label(session.started_at, absolute_start) + lines.append({ + "absolute_start": absolute_start, + "chunk_id": int(chunk["chunk_id"]), + "line": f"[{stamp}] [{speaker_name}] {deduped_phrase}", + }) + lines.sort(key=lambda item: item["absolute_start"]) + return lines + + +async def try_post_live_lines( + guild: discord.Guild | None, + session: GuildTranscriptionSession, + entries: list[dict[str, object]], +) -> bool: + if not entries: + return True + if guild is None: + async with session.pending_live_lock: + session.pending_live_entries.extend(entries) + return False + thread = find_active_transcription_thread(guild, session) + if thread is None: + async with session.pending_live_lock: + session.pending_live_entries.extend(entries) + return False + try: + for entry in entries: + await thread.send(str(entry["line"])) + return True + except Exception: + logger.exception("transcribe_live_post_failed guild_id=%s", session.guild_id) + async with session.pending_live_lock: + session.pending_live_entries.extend(entries) + return False + + +async def transcription_worker_loop(guild_id: int): + logger.info("transcribe_worker_started guild_id=%s", guild_id) + while True: + session = get_transcription_session(guild_id) + if session is None: + logger.info("transcribe_worker_exit guild_id=%s reason=session_missing", guild_id) + return + if session.closed and session.chunk_queue.empty(): + logger.info("transcribe_worker_exit guild_id=%s reason=session_closed", guild_id) + return + try: + chunk_id = await asyncio.wait_for(session.chunk_queue.get(), timeout=1.0) + except asyncio.TimeoutError: + continue + chunk = session.chunk_meta_by_id.get(chunk_id) + if chunk is None or chunk.get("transcribed"): + session.chunk_queue.task_done() + continue + engine_name = session.engine_name + engine = session.engine_instance + if engine_name is None or engine is None: + session.chunk_queue.task_done() + continue + try: + inference_queue_size_start = session.chunk_queue.qsize() + inference_started_at = time.perf_counter() + logger.info( + "transcribe_inference_started guild_id=%s chunk_id=%s queue_size=%s", + guild_id, + chunk_id, + inference_queue_size_start, + ) + async with session.transcription_inference_lock: + utterances = await asyncio.to_thread( + transcribe_audio_file, + engine_name, + engine, + Path(str(chunk["file_path"])), + ) + inference_duration_seconds = time.perf_counter() - inference_started_at + inference_queue_size_end = session.chunk_queue.qsize() + logger.info( + "transcribe_inference_finished guild_id=%s chunk_id=%s duration_seconds=%.3f queue_size=%s backlog_growth=%s", + guild_id, + chunk_id, + inference_duration_seconds, + inference_queue_size_end, + inference_queue_size_end - inference_queue_size_start, + ) + lines = build_transcript_lines_for_chunk(bot.get_guild(guild_id), session, chunk, utterances) + session.chunk_transcripts[chunk_id] = lines + chunk["transcribed"] = True + if lines: + async with session.pending_live_lock: + session.pending_live_entries.extend(lines) + except Exception: + logger.exception("transcribe_chunk_failed guild_id=%s chunk_id=%s", guild_id, chunk_id) + finally: + session.chunk_queue.task_done() + + +async def transcription_live_loop(guild_id: int): + logger.info("transcribe_live_loop_started guild_id=%s interval_seconds=%s", guild_id, TRANSCRIBE_EMIT_INTERVAL_SECONDS) + while True: + await asyncio.sleep(TRANSCRIBE_EMIT_INTERVAL_SECONDS) + session = get_transcription_session(guild_id) + if session is None or session.closed: + logger.info("transcribe_live_loop_exit guild_id=%s", guild_id) + return + guild = bot.get_guild(guild_id) + vc = guild.voice_client if guild is not None else None + if vc is None or not vc.is_connected(): + continue + if not getattr(vc, "recording", False): + session.recording_failure_count += 1 + voice_state = { + "connected": vc.is_connected(), + "recording": bool(getattr(vc, "recording", False)), + "channel_id": getattr(getattr(vc, "channel", None), "id", None), + } + logger.warning( + "transcribe_recording_inactive guild_id=%s voice_state=%s recording_failure_count=%s queue_depth=%s grace_intervals=%s max_failures=%s", + guild_id, + voice_state, + session.recording_failure_count, + session.chunk_queue.qsize(), + TRANSCRIBE_RECORDING_FAILURE_GRACE_INTERVALS, + TRANSCRIBE_MAX_FAILURES, + ) + if session.recording_failure_count <= TRANSCRIBE_RECORDING_FAILURE_GRACE_INTERVALS: + continue + if session.recording_failure_count > TRANSCRIBE_MAX_FAILURES: + await teardown_transcription_session_for_recording_failure( + guild, + session, + ( + "voice_client_connected_but_not_recording " + f"after_consecutive_failures={session.recording_failure_count} " + f"queue_depth={session.chunk_queue.qsize()}" + ), + ) + return + continue + if session.recording_failure_count > 0: + logger.info( + "transcribe_recording_recovered guild_id=%s recording_failure_count=%s queue_depth=%s", + guild_id, + session.recording_failure_count, + session.chunk_queue.qsize(), + ) + session.recording_failure_count = 0 + await flush_active_recording_buffers(session, guild_id) + emit_cutoff = max( + 0.0, + (datetime.now(timezone.utc) - session.started_at).total_seconds() - TRANSCRIBE_EMIT_INTERVAL_SECONDS, + ) + pending_to_send: list[dict[str, object]] = [] + async with session.pending_live_lock: + still_pending: list[dict[str, object]] = [] + for entry in session.pending_live_entries: + if float(entry.get("absolute_start") or 0.0) <= emit_cutoff: + pending_to_send.append(entry) + else: + still_pending.append(entry) + session.pending_live_entries = still_pending + if pending_to_send: + pending_to_send.sort(key=lambda item: (float(item.get("absolute_start") or 0.0), int(item.get("chunk_id") or 0))) + await try_post_live_lines(guild, session, pending_to_send) + + +async def finalize_transcription_session(guild: discord.Guild, session: GuildTranscriptionSession, vc: discord.VoiceClient | None) -> Path: + session.closed = True + if vc is not None and vc.is_connected() and getattr(vc, "recording", False): + try: + vc.stop_recording() + except Exception: + logger.exception("transcribe_stop_recording_failed guild_id=%s", guild.id) + await asyncio.wait_for(session.capture_done.wait(), timeout=60) + await flush_active_recording_buffers(session, guild.id) + if not session.chunk_queue.empty(): + await session.chunk_queue.join() + + engine_name = session.engine_name + engine = session.engine_instance + if engine_name is not None and engine is not None: + for chunk in session.chunk_meta: + if chunk.get("transcribed"): + continue + try: + chunk_id = int(chunk["chunk_id"]) + inference_queue_size_start = session.chunk_queue.qsize() + inference_started_at = time.perf_counter() + logger.info( + "transcribe_final_inference_started guild_id=%s chunk_id=%s queue_size=%s", + guild.id, + chunk_id, + inference_queue_size_start, + ) + async with session.transcription_inference_lock: + utterances = await asyncio.to_thread( + transcribe_audio_file, + engine_name, + engine, + Path(str(chunk["file_path"])), + ) + inference_duration_seconds = time.perf_counter() - inference_started_at + inference_queue_size_end = session.chunk_queue.qsize() + logger.info( + "transcribe_final_inference_finished guild_id=%s chunk_id=%s duration_seconds=%.3f queue_size=%s backlog_growth=%s", + guild.id, + chunk_id, + inference_duration_seconds, + inference_queue_size_end, + inference_queue_size_end - inference_queue_size_start, + ) + lines = build_transcript_lines_for_chunk(guild, session, chunk, utterances) + session.chunk_transcripts[chunk_id] = lines + chunk["transcribed"] = True + except Exception: + logger.exception("transcribe_final_pass_failed guild_id=%s chunk_id=%s", guild.id, chunk.get("chunk_id")) + + all_lines: list[dict[str, object]] = [] + for chunk in session.chunk_meta: + all_lines.extend(session.chunk_transcripts.get(int(chunk["chunk_id"]), [])) + all_lines.sort(key=lambda item: float(item.get("absolute_start", 0.0))) + transcript_file = session.temp_dir / "final_transcript.txt" + transcript_text = "\n".join(item["line"] for item in all_lines) if all_lines else "(no transcribed speech)" + transcript_file.write_text(transcript_text, encoding="utf-8") + + thread = find_active_transcription_thread(guild, session) + if thread is not None: + await thread.send("βœ… Final transcript complete.", file=discord.File(str(transcript_file), filename="final_transcript.txt")) + + session.finalized = True + return transcript_file + + +async def teardown_transcription_session_for_recording_failure( + guild: discord.Guild | None, + session: GuildTranscriptionSession, + failure_reason: str, +): + guild_id = session.guild_id + session.closed = True + current_task = asyncio.current_task() + for task_name, task in (("loop_task", session.loop_task), ("worker_task", session.worker_task)): + if task is None or task.done() or task is current_task: + continue + task.cancel() + try: + await task + except asyncio.CancelledError: + logger.info("transcribe_teardown_task_cancelled guild_id=%s task=%s", guild_id, task_name) + except Exception: + logger.exception("transcribe_teardown_task_cancel_failed guild_id=%s task=%s", guild_id, task_name) + if guild is not None: + vc = guild.voice_client + if vc is not None and vc.is_connected(): + try: + await vc.disconnect(force=True) + except (discord.HTTPException, discord.ClientException): + logger.exception("transcribe_teardown_disconnect_failed guild_id=%s", guild_id) + thread = find_active_transcription_thread(guild, session) + if thread is not None: + try: + await thread.send("πŸ›‘ Transcription session ended due to recording failure. Please start `/gtranscribe` again.") + except Exception: + logger.exception("transcribe_teardown_thread_notify_failed guild_id=%s", guild_id) + remove_transcription_session(guild_id) + logger.error( + "transcribe_recording_failure guild_id=%s failure_count=%s action=torn_down reason=%s", + guild_id, + session.recording_failure_count, + failure_reason, + ) def format_duration(duration_seconds: int) -> str: @@ -284,39 +1268,26 @@ def format_duration(duration_seconds: int) -> str: if hours: return f"{hours:d}:{mins:02d}:{secs:02d}" return f"{mins:d}:{secs:02d}" - - def parse_duration_seconds(value: object) -> int: if isinstance(value, (int, float)): return max(int(value), 0) - if not isinstance(value, str): return 0 - text = value.strip() if not text: return 0 - if text.isdigit(): return int(text) - parts = text.split(":") if not all(part.isdigit() for part in parts): return 0 - total = 0 for part in parts: total = (total * 60) + int(part) return total - - def log_music_timing(step: str, phase: str, started_at: float, **fields: object): elapsed = time.perf_counter() - started_at - details = " ".join(f"{key}={value!r}" for key, value in fields.items()) - suffix = f" {details}" if details else "" - print(f"[music] {step} {phase} elapsed={elapsed:.2f}s{suffix}") - - + logger.info("music_timing step=%s phase=%s elapsed_s=%.2f details=%r", step, phase, elapsed, fields) def pick_track_info(info: dict[str, object]) -> dict[str, object]: entries = info.get("entries") if isinstance(entries, list): @@ -325,13 +1296,10 @@ def pick_track_info(info: dict[str, object]) -> dict[str, object]: return entry raise RuntimeError("No playable track found for that query.") return info - - def extract_stream_url(info: dict[str, object]) -> str: direct_url = str(info.get("url") or "").strip() if direct_url: return direct_url - requested_formats = info.get("requested_formats") if isinstance(requested_formats, list): for fmt in requested_formats: @@ -342,15 +1310,12 @@ def extract_stream_url(info: dict[str, object]) -> str: continue if str(fmt.get("vcodec") or "") == "none": return format_url - formats = info.get("formats") if not isinstance(formats, list): raise RuntimeError("yt-dlp did not provide an audio stream URL.") - def _is_hls_protocol(fmt: dict[str, object]) -> bool: protocol = str(fmt.get("protocol") or "").lower() return "m3u8" in protocol or protocol == "http_dash_segments" - best_audio_url = "" best_audio_score = -1.0 best_hls_audio_url = "" @@ -365,21 +1330,17 @@ def _is_hls_protocol(fmt: dict[str, object]) -> bool: continue if not fallback_url: fallback_url = format_url - is_hls = _is_hls_protocol(fmt) if not is_hls and not fallback_non_hls_url: fallback_non_hls_url = format_url - is_audio_only = str(fmt.get("vcodec") or "") == "none" if not is_audio_only: continue - bitrate = fmt.get("abr") or fmt.get("tbr") or 0 try: score = float(bitrate) except (TypeError, ValueError): score = 0.0 - if is_hls: if score >= best_hls_audio_score: best_hls_audio_score = score @@ -388,7 +1349,6 @@ def _is_hls_protocol(fmt: dict[str, object]) -> bool: if score >= best_audio_score: best_audio_score = score best_audio_url = format_url - if best_audio_url: return best_audio_url if fallback_non_hls_url: @@ -398,8 +1358,6 @@ def _is_hls_protocol(fmt: dict[str, object]) -> bool: if fallback_url: return fallback_url raise RuntimeError("yt-dlp returned an empty stream URL.") - - def parse_tracks_from_info(info: dict[str, object], source: str) -> list[QueueTrack]: entries = info.get("entries") if isinstance(entries, list): @@ -407,12 +1365,10 @@ def parse_tracks_from_info(info: dict[str, object], source: str) -> list[QueueTr for entry in entries: if not isinstance(entry, dict): continue - title = str(entry.get("title") or "Unknown title") duration_seconds = parse_duration_seconds(entry.get("duration")) if duration_seconds <= 0: duration_seconds = parse_duration_seconds(entry.get("duration_string")) - webpage_url = str(entry.get("webpage_url") or entry.get("url") or "").strip() if not webpage_url: entry_id = str(entry.get("id") or "").strip() @@ -420,7 +1376,6 @@ def parse_tracks_from_info(info: dict[str, object], source: str) -> list[QueueTr webpage_url = f"https://www.youtube.com/watch?v={entry_id}" else: webpage_url = source - tracks.append( QueueTrack( title=title, @@ -429,11 +1384,9 @@ def parse_tracks_from_info(info: dict[str, object], source: str) -> list[QueueTr requested_by=0, ) ) - if tracks: return tracks raise RuntimeError("No playable tracks found for that playlist.") - track_info = pick_track_info(info) title = str(track_info.get("title") or "Unknown title") duration_seconds = parse_duration_seconds(track_info.get("duration")) @@ -441,9 +1394,7 @@ def parse_tracks_from_info(info: dict[str, object], source: str) -> list[QueueTr duration_seconds = parse_duration_seconds(track_info.get("duration_string")) if duration_seconds <= 0: duration_seconds = parse_duration_seconds(info.get("duration_string")) - webpage_url = str(track_info.get("webpage_url") or info.get("webpage_url") or source) - return [ QueueTrack( title=title, @@ -452,8 +1403,6 @@ def parse_tracks_from_info(info: dict[str, object], source: str) -> list[QueueTr requested_by=0, ) ] - - async def fetch_tracks(source: str) -> list[QueueTrack]: info_proc = await asyncio.create_subprocess_exec( "yt-dlp", @@ -468,15 +1417,11 @@ async def fetch_tracks(source: str) -> list[QueueTrack]: if info_proc.returncode != 0: err = info_stderr.decode("utf-8", errors="ignore").strip() or "yt-dlp failed" raise RuntimeError(err) - try: info = json.loads(info_stdout.decode("utf-8", errors="ignore")) except Exception as exc: raise RuntimeError("Unable to read track metadata.") from exc - return parse_tracks_from_info(info, source) - - async def resolve_stream_url(source_url: str) -> str: stream_proc = await asyncio.create_subprocess_exec( "yt-dlp", @@ -492,24 +1437,18 @@ async def resolve_stream_url(source_url: str) -> str: if stream_proc.returncode != 0: err = stream_stderr.decode("utf-8", errors="ignore").strip() or "yt-dlp failed" raise RuntimeError(err) - try: info = json.loads(stream_stdout.decode("utf-8", errors="ignore")) except Exception as exc: raise RuntimeError("Unable to read playback stream URL.") from exc - return extract_stream_url(pick_track_info(info)) - - def is_youtube_url(value: str) -> bool: try: parsed = urlparse(value) except ValueError: return False - if parsed.scheme not in {"http", "https"}: return False - hostname = (parsed.hostname or "").lower() youtube_hosts = { "youtube.com", @@ -522,6 +1461,55 @@ def is_youtube_url(value: str) -> bool: return hostname in youtube_hosts +def build_transcribe_permission_error( + interaction: discord.Interaction, + voice_channel: discord.VoiceChannel, +) -> str | None: + if interaction.guild is None or interaction.channel is None or bot.user is None: + return None + bot_member = interaction.guild.get_member(bot.user.id) + if bot_member is None: + return ( + "❌ I couldn't verify my server permissions. Ensure I am a member of this server and retry `/gtranscribe`." + ) + + text_perms = interaction.channel.permissions_for(bot_member) + voice_perms = voice_channel.permissions_for(bot_member) + missing_text: list[str] = [] + missing_voice: list[str] = [] + + text_requirements = [ + ("view_channel", "View Channel"), + ("send_messages", "Send Messages"), + ("create_private_threads", "Create Private Threads"), + ("send_messages_in_threads", "Send Messages in Threads"), + ("manage_threads", "Manage Threads (needed to add users to the transcript thread)"), + ("add_reactions", "Add Reactions (needed for consent flow)"), + ("read_message_history", "Read Message History (needed for reactions)"), + ] + voice_requirements = [ + ("connect", "Connect"), + ("speak", "Speak"), + ("use_voice_activation", "Use Voice Activity (needed to stay connected)"), + ] + for attr_name, label in text_requirements: + if not getattr(text_perms, attr_name, False): + missing_text.append(label) + for attr_name, label in voice_requirements: + if not getattr(voice_perms, attr_name, False): + missing_voice.append(label) + + if not missing_text and not missing_voice: + return None + + lines = ["❌ Cannot start `/gtranscribe` because required permissions are missing."] + if missing_text: + lines.append(f"- **Grant in text channel {interaction.channel.mention}:** {', '.join(missing_text)}") + if missing_voice: + lines.append(f"- **Grant in voice channel {voice_channel.mention}:** {', '.join(missing_voice)}") + return "\n".join(lines) + + async def ensure_voice_channel(interaction: discord.Interaction) -> discord.VoiceChannel | None: if interaction.guild is None: return None @@ -533,33 +1521,123 @@ async def ensure_voice_channel(interaction: discord.Interaction) -> discord.Voic return member.voice.channel +async def wait_for_voice_client_ready(vc: discord.VoiceClient, timeout_seconds: float = 120.0) -> bool: + """Wait for the voice websocket handshake to be ready for recording/playback.""" + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + connected = vc.is_connected() + connected_event = getattr(vc, "_connected", None) + connected_event_set = ( + connected_event is not None and hasattr(connected_event, "is_set") and connected_event.is_set() + ) + + ws = getattr(vc, "ws", None) + ws_poll_ready = ws is not None and callable(getattr(ws, "poll_event", None)) + + if connected and (ws_poll_ready or connected_event_set): + return True + + if not connected: + return False + + await asyncio.sleep(0.1) + return False + + +def describe_voice_client_state(vc: discord.VoiceClient) -> str: + connected_event = getattr(vc, "_connected", None) + connected_event_set = ( + connected_event.is_set() if connected_event is not None and hasattr(connected_event, "is_set") else None + ) + ws = getattr(vc, "ws", None) + ws_ready = ws is not None and callable(getattr(ws, "poll_event", None)) + channel = getattr(vc, "channel", None) + return ( + f"connected={vc.is_connected()} " + f"event_set={connected_event_set} " + f"ws={type(ws).__name__ if ws is not None else None} " + f"ws_ready={ws_ready} " + f"channel_id={getattr(channel, 'id', None)}" + ) + +def describe_transcription_session_state(session: GuildTranscriptionSession | None) -> str: + if session is None: + return "session=none" + return ( + f"temp_dir={session.temp_dir} " + f"captures={session.capture_index} " + f"consented={len(session.consented_user_ids)} " + f"thread_id={session.transcript_thread_id}" + ) + + +def build_interaction_log_context( + interaction: discord.Interaction, + vc: discord.VoiceClient | None = None, + session: GuildTranscriptionSession | None = None, +) -> dict[str, object]: + context = interaction_log_context(interaction) + context["voice_state"] = describe_voice_client_state(vc) if vc is not None else "voice_client=none" + if session is not None: + context["transcription_session"] = describe_transcription_session_state(session) + return context + + + async def play_next_track(guild: discord.Guild): voice_client = guild.voice_client if voice_client is None: return + if not voice_client.is_connected(): + channel = getattr(voice_client, "channel", None) + if isinstance(channel, discord.VoiceChannel): + try: + await voice_client.disconnect(force=True) + except (discord.ClientException, discord.HTTPException): + pass + try: + voice_client = await channel.connect(timeout=15.0, reconnect=True) + except (discord.ClientException, discord.HTTPException, asyncio.TimeoutError): + logger.exception( + "music_voice_reconnect_failed guild_id=%s channel_id=%s state=%s", + guild.id, + channel.id, + describe_voice_client_state(voice_client), + ) + return + else: + return + + ready = await wait_for_voice_client_ready(voice_client, timeout_seconds=15.0) + if not ready: + logger.warning("music_voice_not_ready guild_id=%s state=%s", guild.id, describe_voice_client_state(voice_client)) + return state = get_music_state(guild.id) async with state.lock: if voice_client.is_playing() or voice_client.is_paused(): return - if not state.queue: state.current_track = None state.track_started_at = None await voice_client.disconnect(force=True) return - next_track = state.queue.popleft() state.current_track = next_track state.track_started_at = datetime.now(timezone.utc) - + music_context = { + "guild_id": guild.id, + "channel_id": getattr(getattr(voice_client, "channel", None), "id", None), + "user_id": next_track.requested_by, + "interaction": "music_playback", + "voice_state": describe_voice_client_state(voice_client), + } try: stream_url = await resolve_stream_url(next_track.source_url) - except RuntimeError as exc: - print(f"Failed to resolve stream URL for '{next_track.title}': {exc}") + except RuntimeError: + logger.exception("music_stream_resolve_failed track=%s context=%r", next_track.title, music_context) await play_next_track(guild) return - ffmpeg_source = discord.FFmpegPCMAudio( stream_url, before_options=( @@ -571,20 +1649,21 @@ async def play_next_track(guild: discord.Guild): ), options="-vn -loglevel warning -af aresample=async=1:min_hard_comp=0.100:first_pts=0", ) - def _after_playback(play_error: Exception | None): if play_error: - print(f"Playback error: {play_error}") + logger.exception("music_playback_error context=%r", music_context, exc_info=play_error) fut = asyncio.run_coroutine_threadsafe(play_next_track(guild), bot.loop) try: fut.result() - except Exception as exc: - print(f"Failed to start next track: {exc}") - - print(f"[music] voice_client.play start track='{next_track.title}'") - voice_client.play(ffmpeg_source, after=_after_playback) - - + except Exception: + logger.exception("music_next_track_start_failed context=%r", music_context) + logger.info("music_play_start track=%s context=%r", next_track.title, music_context) + try: + voice_client.play(ffmpeg_source, after=_after_playback) + except discord.ClientException: + logger.exception("music_play_start_failed context=%r", music_context) + await asyncio.sleep(0.5) + await play_next_track(guild) # ========================= # DATABASE HELPERS # ========================= @@ -592,37 +1671,27 @@ def _apply_sqlite_pragmas(conn: sqlite3.Connection): conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA synchronous=NORMAL;") conn.execute("PRAGMA busy_timeout=10000;") # ms - - def db_config() -> sqlite3.Connection: os.makedirs(DB_DIR, exist_ok=True) conn = sqlite3.connect(CONFIG_DB_PATH, timeout=10) conn.row_factory = sqlite3.Row _apply_sqlite_pragmas(conn) return conn - - def db_cleanup() -> sqlite3.Connection: os.makedirs(DB_DIR, exist_ok=True) conn = sqlite3.connect(CLEANUP_DB_PATH, timeout=10) conn.row_factory = sqlite3.Row _apply_sqlite_pragmas(conn) return conn - - def db_path_for_year(year: int) -> str: os.makedirs(DB_DIR, exist_ok=True) return os.path.join(DB_DIR, f"poopbot_{year}.db") - - def db_year(year: int) -> sqlite3.Connection: path = db_path_for_year(year) conn = sqlite3.connect(path, timeout=10) conn.row_factory = sqlite3.Row _apply_sqlite_pragmas(conn) return conn - - def init_config_db(): with db_config() as conn: conn.execute(""" @@ -664,6 +1733,16 @@ def init_config_db(): PRIMARY KEY (ticket_id, user_id) ); """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS transcription_consent ( + guild_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + display_name TEXT NOT NULL, + consented_at_utc TEXT NOT NULL, + expires_at_utc TEXT NOT NULL, + PRIMARY KEY (guild_id, user_id) + ); + """) columns = { row["name"] for row in conn.execute("PRAGMA table_info(tickets)").fetchall() @@ -672,8 +1751,6 @@ def init_config_db(): conn.execute("ALTER TABLE tickets ADD COLUMN archive_thread_id INTEGER;") if "closed_at_utc" not in columns: conn.execute("ALTER TABLE tickets ADD COLUMN closed_at_utc TEXT;") - - def init_cleanup_db(): with db_cleanup() as conn: conn.execute(""" @@ -688,8 +1765,6 @@ def init_cleanup_db(): created_at_utc TEXT NOT NULL ); """) - - def init_year_db(year: int): with db_year(year) as conn: conn.execute(""" @@ -711,8 +1786,6 @@ def init_year_db(year: int): """) conn.execute("CREATE INDEX IF NOT EXISTS idx_events_user_time ON events(user_id, timestamp_utc);") conn.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);") - - # ---- config/state (persistent) ---- def gset(guild_id: int, key: str, value: str): with db_config() as conn: @@ -720,40 +1793,28 @@ def gset(guild_id: int, key: str, value: str): INSERT INTO guild_state(guild_id, key, value) VALUES(?, ?, ?) ON CONFLICT(guild_id, key) DO UPDATE SET value=excluded.value """, (guild_id, key, value)) - - def gget(guild_id: int, key: str) -> str | None: with db_config() as conn: row = conn.execute(""" SELECT value FROM guild_state WHERE guild_id=? AND key=? """, (guild_id, key)).fetchone() return row["value"] if row else None - - def gget_int(guild_id: int, key: str, default: int = 0) -> int: v = gget(guild_id, key) try: return int(v) if v is not None else default except ValueError: return default - - def gset_int(guild_id: int, key: str, value: int): gset(guild_id, key, str(value)) - - def set_ticket_target(guild_id: int, user_id: int, channel_id: int): gset(guild_id, f"ticket_target_{user_id}", str(channel_id)) - - def get_ticket_target(guild_id: int, user_id: int) -> int | None: value = gget(guild_id, f"ticket_target_{user_id}") try: return int(value) if value else None except ValueError: return None - - def get_ticket_dev_user_id() -> int | None: if not TICKET_DEV_USER_ID: return None @@ -761,8 +1822,6 @@ def get_ticket_dev_user_id() -> int | None: return int(TICKET_DEV_USER_ID) except ValueError: return None - - def get_ticket_archive_channel_id() -> int | None: if not TICKET_ARCHIVE_CHANNEL_ID: return None @@ -770,8 +1829,6 @@ def get_ticket_archive_channel_id() -> int | None: return int(TICKET_ARCHIVE_CHANNEL_ID) except ValueError: return None - - async def create_ticket_request(guild_id: int, requester_id: int, requester_name: str) -> int: created_at = datetime.now(timezone.utc).isoformat() async with db_write_lock: @@ -784,8 +1841,6 @@ async def create_ticket_request(guild_id: int, requester_id: int, requester_name VALUES (?, ?, ?, NULL, NULL, ?, 'open') """, (guild_id, requester_id, requester_name, created_at)) return int(cur.lastrowid) - - async def update_ticket_request(ticket_id: int, channel_id: int, thread_id: int): async with db_write_lock: with db_config() as conn: @@ -794,8 +1849,6 @@ async def update_ticket_request(ticket_id: int, channel_id: int, thread_id: int) SET channel_id=?, thread_id=? WHERE ticket_id=? """, (channel_id, thread_id, ticket_id)) - - async def close_ticket_request(ticket_id: int, archive_thread_id: int): closed_at = datetime.now(timezone.utc).isoformat() async with db_write_lock: @@ -805,8 +1858,6 @@ async def close_ticket_request(ticket_id: int, archive_thread_id: int): SET archive_thread_id=?, closed_at_utc=?, status='closed' WHERE ticket_id=? """, (archive_thread_id, closed_at, ticket_id)) - - async def add_ticket_collaborator(ticket_id: int, user_id: int, added_by_id: int): added_at = datetime.now(timezone.utc).isoformat() async with db_write_lock: @@ -820,8 +1871,6 @@ async def add_ticket_collaborator(ticket_id: int, user_id: int, added_by_id: int added_by_id=excluded.added_by_id, added_at_utc=excluded.added_at_utc """, (ticket_id, user_id, added_by_id, added_at)) - - def set_guild_channel(guild_id: int, channel_id: int): with db_config() as conn: conn.execute(""" @@ -829,13 +1878,9 @@ def set_guild_channel(guild_id: int, channel_id: int): VALUES(?, ?, 1) ON CONFLICT(guild_id) DO UPDATE SET channel_id=excluded.channel_id, enabled=1 """, (guild_id, channel_id)) - - def disable_guild(guild_id: int): with db_config() as conn: conn.execute("UPDATE guild_config SET enabled=0 WHERE guild_id=?", (guild_id,)) - - def get_enabled_guilds(): with db_config() as conn: return conn.execute(""" @@ -843,8 +1888,6 @@ def get_enabled_guilds(): FROM guild_config WHERE enabled=1 """).fetchall() - - def get_guild_config(guild_id: int): with db_config() as conn: return conn.execute(""" @@ -852,8 +1895,6 @@ def get_guild_config(guild_id: int): FROM guild_config WHERE enabled=1 AND guild_id=? """, (guild_id,)).fetchone() - - def get_ticket_by_thread_id(thread_id: int): with db_config() as conn: return conn.execute(""" @@ -862,8 +1903,6 @@ def get_ticket_by_thread_id(thread_id: int): FROM tickets WHERE thread_id=? """, (thread_id,)).fetchone() - - # ========================= # EVENT LOGGING (yearly) # ========================= @@ -871,8 +1910,6 @@ def now_utc_local(): utc = datetime.now(timezone.utc) local = utc.astimezone(LOCAL_TZ) return utc, local - - def _fetch_url_text(url: str) -> str: req = urllib.request.Request( url, @@ -885,8 +1922,6 @@ def _fetch_url_text(url: str) -> str: ) with urllib.request.urlopen(req, timeout=20) as response: return response.read().decode("utf-8") - - def _extract_channel_id_from_handle_page(html: str) -> str | None: marker = '"channelId":"' idx = html.find(marker) @@ -897,22 +1932,18 @@ def _extract_channel_id_from_handle_page(html: str) -> str | None: if end == -1: return None return html[start:end] - - async def resolve_wesroth_channel_id() -> str | None: if WESROTH_CHANNEL_ID: return WESROTH_CHANNEL_ID try: html = await asyncio.to_thread(_fetch_url_text, WESROTH_HANDLE_URL) except OSError as exc: - print(f"Failed to fetch WesRoth channel page: {exc}") + logger.exception("wesroth_channel_fetch_failed") return None channel_id = _extract_channel_id_from_handle_page(html) if not channel_id: - print("Failed to parse WesRoth channel id from handle page.") + logger.warning("wesroth_channel_id_parse_failed") return channel_id - - def _parse_wesroth_feed(xml_text: str) -> list[dict]: root = ET.fromstring(xml_text) entries = [] @@ -939,8 +1970,6 @@ def _parse_wesroth_feed(xml_text: str) -> list[dict]: "duration_seconds": duration_seconds, }) return entries - - async def fetch_wesroth_latest_today() -> dict | None: channel_id = await resolve_wesroth_channel_id() if not channel_id: @@ -949,12 +1978,11 @@ async def fetch_wesroth_latest_today() -> dict | None: try: xml_text = await asyncio.to_thread(_fetch_url_text, feed_url) except OSError as exc: - print(f"Failed to fetch WesRoth feed: {exc}") + logger.exception("wesroth_feed_fetch_failed") return None entries = _parse_wesroth_feed(xml_text) if not entries: return None - today_local = datetime.now(LOCAL_TZ).date() todays_entries = [] for entry in entries: @@ -969,13 +1997,9 @@ async def fetch_wesroth_latest_today() -> dict | None: continue entry["published_dt"] = published_dt todays_entries.append(entry) - if not todays_entries: return None - return max(todays_entries, key=lambda item: item["published_dt"]) - - async def log_event( event_type: str, user_id: int, @@ -989,9 +2013,7 @@ async def log_event( utc, local = now_utc_local() event_year = local.year init_year_db(event_year) - event_id = str(uuid.uuid4()) - async with db_write_lock: with db_year(event_year) as conn: conn.execute(""" @@ -1012,10 +2034,7 @@ async def log_event( guild_id, channel_id, message_id, target_event_id, note )) - return event_id - - async def log_cleanup_message(message: discord.Message): created_at = message.created_at.astimezone(timezone.utc) async with db_write_lock: @@ -1035,23 +2054,19 @@ async def log_cleanup_message(message: discord.Message): message.content, created_at.isoformat() )) - - print( - "Cleanup message stored:", - f"user={message.author} ({message.author.id})", - f"channel={message.channel.id}", - f"time={created_at.isoformat()}", - f"text={message.content!r}" + logger.info( + "cleanup_message_stored user=%s user_id=%s channel_id=%s created_at=%s text=%r", + message.author, + message.author.id, + message.channel.id, + created_at.isoformat(), + message.content, ) - - def find_last_active_poop_event_id(user_id: int, year: int) -> str | None: """Most recent POOP in the given year that has NOT been undone by that same user.""" init_year_db(year) - start_local = datetime(year, 1, 1, 0, 0, 0, tzinfo=LOCAL_TZ) end_local = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=LOCAL_TZ) - with db_year(year) as conn: poops = conn.execute(""" SELECT event_id @@ -1063,12 +2078,9 @@ def find_last_active_poop_event_id(user_id: int, year: int) -> str | None: ORDER BY timestamp_local DESC LIMIT 500 """, (user_id, start_local.isoformat(), end_local.isoformat())).fetchall() - if not poops: return None - poop_ids = [r["event_id"] for r in poops] - undone = conn.execute(f""" SELECT target_event_id FROM events @@ -1076,22 +2088,16 @@ def find_last_active_poop_event_id(user_id: int, year: int) -> str | None: AND user_id=? AND target_event_id IN ({",".join("?" * len(poop_ids))}) """, (user_id, *poop_ids)).fetchall() - undone_set = {r["target_event_id"] for r in undone} - for pid in poop_ids: if pid not in undone_set: return pid - return None - - # ========================= # BUTTON POSTING (per guild) # ========================= async def post_button_for_guild(guild_id: int, channel_id: int): channel = await bot.fetch_channel(channel_id) - # delete previous active button message old_message_id = gget(guild_id, "active_message_id") if old_message_id: @@ -1101,7 +2107,6 @@ async def post_button_for_guild(guild_id: int, channel_id: int): await old_msg.delete() except (discord.NotFound, discord.Forbidden, discord.HTTPException): pass - local_now = datetime.now(LOCAL_TZ) msg = await channel.send( f"πŸ’© **Click here to log a poop** β€” {local_now.strftime('%Y-%m-%d')} (Pacific)\n" @@ -1112,12 +2117,9 @@ async def post_button_for_guild(guild_id: int, channel_id: int): ) await msg.add_reaction(POOP_EMOJI) await msg.add_reaction(UNDO_EMOJI) - gset(guild_id, "active_message_id", str(msg.id)) gset(guild_id, "active_date_local", local_now.date().isoformat()) gset_int(guild_id, "poops_since_post", 0) - - @tasks.loop(time=dtime(hour=0, minute=0, tzinfo=LOCAL_TZ)) async def daily_midnight_pacific(): today_local = datetime.now(LOCAL_TZ).date().isoformat() @@ -1131,39 +2133,34 @@ async def daily_midnight_pacific(): await post_button_for_guild(gid, cid) except (discord.Forbidden, discord.NotFound, discord.HTTPException): continue - - @tasks.loop(minutes=WESROTH_POLL_MINUTES) async def wesroth_upload_watch(): latest = await fetch_wesroth_latest_today() if not latest: return - last_video_id = gget(0, "wesroth_last_video_id") if last_video_id == latest["video_id"]: return - channel = bot.get_channel(WESROTH_ALERT_CHANNEL_ID) if channel is None: try: channel = await bot.fetch_channel(WESROTH_ALERT_CHANNEL_ID) except (discord.Forbidden, discord.NotFound, discord.HTTPException): return - caption = random.choice(WESROTH_CAPTIONS) await channel.send(f"{caption}\n{latest['link']}") - gset(0, "wesroth_last_video_id", latest["video_id"]) gset(0, "wesroth_last_post_date_local", datetime.now(LOCAL_TZ).date().isoformat()) - - # ========================= # MESSAGE CLEANUP CHANNEL # ========================= @bot.event async def on_message(message: discord.Message): + if message.author.bot: + await bot.process_commands(message) + return # delete any non-bot message in the cleanup channel - if message.channel.id == CLEANUP_CHANNEL_ID and not message.author.bot: + if message.channel.id == CLEANUP_CHANNEL_ID: try: await log_cleanup_message(message) await message.delete() @@ -1171,9 +2168,33 @@ async def on_message(message: discord.Message): pass # still allow commands processing elsewhere; this message is gone anyway return - await bot.process_commands(message) - +@bot.event +async def on_voice_state_update(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): + if member.bot or member.guild is None: + return + session = get_transcription_session(member.guild.id) + if session is None or session.closed: + return + if after.channel is None or after.channel.id != session.voice_channel_id: + return + if before.channel is not None and before.channel.id == session.voice_channel_id: + return + thread = find_active_transcription_thread(member.guild, session) + if thread is not None: + try: + await thread.add_user(member) + except (discord.Forbidden, discord.HTTPException): + pass + if member.id in session.prompted_user_ids: + logger.info( + "transcribe_voice_join_skip_prompt guild_id=%s user_id=%s prompt_reason=session_already_prompted", + member.guild.id, + member.id, + ) + return + if thread is not None: + await prompt_transcription_consent(member.guild, session, thread, [member]) # ========================= # REACTIONS @@ -1182,20 +2203,63 @@ async def on_message(message: discord.Message): async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): if payload.user_id == bot.user.id: return + emoji = str(payload.emoji) + consent_prompt = transcription_consent_prompts.get(payload.message_id) + if emoji == TRANSCRIBE_CONSENT_EMOJI and consent_prompt is not None: + prompt_guild_id, _prompt_thread_id, prompt_user_id = consent_prompt + if payload.user_id != prompt_user_id: + return + session = get_transcription_session(prompt_guild_id) + logger.info( + "transcribe_consent_reaction_received guild_id=%s message_id=%s channel_id=%s user_id=%s session_present=%s", + prompt_guild_id, + payload.message_id, + payload.channel_id, + payload.user_id, + session is not None, + ) + if session is None or session.closed: + logger.info( + "transcribe_consent_reaction_rejected_session_missing_or_closed guild_id=%s message_id=%s channel_id=%s user_id=%s session_present=%s session_closed=%s", + prompt_guild_id, + payload.message_id, + payload.channel_id, + payload.user_id, + session is not None, + session.closed if session is not None else None, + ) + return + guild = bot.get_guild(prompt_guild_id) + member = guild.get_member(payload.user_id) if guild is not None else None + if guild is None or member is None or member.bot: + logger.info( + "transcribe_consent_reaction_rejected_member_invalid guild_id=%s message_id=%s channel_id=%s user_id=%s guild_present=%s member_present=%s member_is_bot=%s", + prompt_guild_id, + payload.message_id, + payload.channel_id, + payload.user_id, + guild is not None, + member is not None, + member.bot if member is not None else None, + ) + return + await apply_transcription_consent( + guild=guild, + session=session, + member=member, + message_id=payload.message_id, + channel_id=payload.channel_id, + ) + return if payload.guild_id is None: return - active_message_id = gget(payload.guild_id, "active_message_id") if not active_message_id or str(payload.message_id) != active_message_id: return - - emoji = str(payload.emoji) - channel = await bot.fetch_channel(payload.channel_id) message = await channel.fetch_message(payload.message_id) user = await bot.fetch_user(payload.user_id) mention = f"<@{payload.user_id}>" - if emoji == POOP_EMOJI: await log_event( event_type="POOP", @@ -1205,25 +2269,19 @@ async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): channel_id=payload.channel_id, message_id=payload.message_id ) - await channel.send(random.choice(CONGRATS).format(user=mention)) - # remove reaction so they can click again try: await message.remove_reaction(payload.emoji, user) except discord.Forbidden: pass - count = gget_int(payload.guild_id, "poops_since_post", 0) + 1 gset_int(payload.guild_id, "poops_since_post", count) - if count >= ROTATE_EVERY: cfg = get_guild_config(payload.guild_id) if cfg: await post_button_for_guild(payload.guild_id, int(cfg["channel_id"])) - return - if emoji == UNDO_EMOJI: year = current_year_local() target = find_last_active_poop_event_id(payload.user_id, year) @@ -1240,15 +2298,11 @@ async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): target_event_id=target ) await channel.send(random.choice(UNDO_MSGS).format(user=mention)) - try: await message.remove_reaction(payload.emoji, user) except discord.Forbidden: pass - return - - # ========================= # STATS HELPERS # ========================= @@ -1262,22 +2316,16 @@ def circular_mean_time(minutes_list: list[float]) -> float | None: if mean_angle < 0: mean_angle += 2 * math.pi return mean_angle * 1440.0 / (2 * math.pi) - - def fmt_minutes_as_time(mins: float) -> str: mins = mins % 1440.0 h = int(mins // 60) m = int(mins % 60) return f"{h:02d}:{m:02d}" - - def _net_poop_rows_for_year(user_id: int, year: int): """Return list of (event_id, date_local, time_local, timestamp_local) for POOP not undone.""" init_year_db(year) - start_local = datetime(year, 1, 1, 0, 0, 0, tzinfo=LOCAL_TZ) end_local = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=LOCAL_TZ) - with db_year(year) as conn: poops = conn.execute(""" SELECT event_id, date_local, time_local, timestamp_local @@ -1287,12 +2335,9 @@ def _net_poop_rows_for_year(user_id: int, year: int): AND timestamp_local >= ? AND timestamp_local < ? """, (user_id, start_local.isoformat(), end_local.isoformat())).fetchall() - if not poops: return [] - poop_ids = [r["event_id"] for r in poops] - undone = conn.execute(f""" SELECT target_event_id FROM events @@ -1300,61 +2345,45 @@ def _net_poop_rows_for_year(user_id: int, year: int): AND user_id=? AND target_event_id IN ({",".join("?" * len(poop_ids))}) """, (user_id, *poop_ids)).fetchall() - undone_set = {r["target_event_id"] for r in undone} - net = [r for r in poops if r["event_id"] not in undone_set] return net - - def get_user_year_stats(user_id: int, year: int): net = _net_poop_rows_for_year(user_id, year) if not net: return 0, [], 0 - active_count = len(net) - first_active_date = min(date.fromisoformat(r["date_local"]) for r in net) today_local = datetime.now(LOCAL_TZ).date() days_elapsed = (today_local - first_active_date).days + 1 # inclusive - times = [] for r in net: hh, mm, ss = r["time_local"].split(":") minutes = int(hh) * 60 + int(mm) + (int(ss) / 60.0) times.append(minutes) - return active_count, times, days_elapsed - - def get_latest_poop(user_id: int, year: int) -> str | None: net = _net_poop_rows_for_year(user_id, year) if not net: return None latest = max(net, key=lambda r: r["timestamp_local"]) return latest["timestamp_local"] - - def get_max_poops_in_one_day(user_id: int, year: int) -> tuple[int, str | None]: net = _net_poop_rows_for_year(user_id, year) if not net: return 0, None - counts: dict[str, int] = {} for r in net: d = r["date_local"] counts[d] = counts.get(d, 0) + 1 - best_date = max(counts, key=lambda d: counts[d]) return counts[best_date], best_date - - # ========================= # COMMANDS (slash) # ========================= -@bot.tree.command(name="setpoopchannel", description="Set the poop logging channel for this server.") -@app_commands.checks.has_permissions(administrator=True) -@app_commands.guild_only() +@bot.slash_command(name="setpoopchannel", description="Set the poop logging channel for this server.") +@discord.default_permissions(administrator=True) +@discord.guild_only() async def setpoopchannel(interaction: discord.Interaction): if interaction.guild is None or interaction.channel is None: return @@ -1362,21 +2391,17 @@ async def setpoopchannel(interaction: discord.Interaction): await interaction.response.send_message( f"βœ… Poop channel set to {interaction.channel.mention} for this server." ) - - -@bot.tree.command(name="disablepoop", description="Disable poop posting for this server.") -@app_commands.checks.has_permissions(administrator=True) -@app_commands.guild_only() +@bot.slash_command(name="disablepoop", description="Disable poop posting for this server.") +@discord.default_permissions(administrator=True) +@discord.guild_only() async def disablepoop(interaction: discord.Interaction): if interaction.guild is None: return disable_guild(interaction.guild.id) await interaction.response.send_message("πŸ›‘ Poop posting disabled for this server.") - - -@bot.tree.command(name="debugpoop", description="Force-create a new poop button message.") -@app_commands.checks.has_permissions(administrator=True) -@app_commands.guild_only() +@bot.slash_command(name="debugpoop", description="Force-create a new poop button message.") +@discord.default_permissions(administrator=True) +@discord.guild_only() async def debugpoop(interaction: discord.Interaction): """Force-create a new poop button message in this guild's configured channel.""" if interaction.guild is None: @@ -1389,29 +2414,22 @@ async def debugpoop(interaction: discord.Interaction): return await post_button_for_guild(interaction.guild.id, int(cfg["channel_id"])) await interaction.response.send_message("πŸ§ͺ Debug: recreated poop button.") - - -@bot.tree.command(name="poopstats", description="Show your poop stats for the current year.") -@app_commands.guild_only() +@bot.slash_command(name="poopstats", description="Show your poop stats for the current year.") +@discord.guild_only() async def poopstats(interaction: discord.Interaction): user_id = interaction.user.id year = current_year_local() - total, times, days_elapsed = get_user_year_stats(user_id, year) avg_per_day = (total / days_elapsed) if days_elapsed else 0.0 - mean_minutes = circular_mean_time(times) mean_time_str = fmt_minutes_as_time(mean_minutes) if mean_minutes is not None else "N/A" - latest = get_latest_poop(user_id, year) max_day_count, max_day_date = get_max_poops_in_one_day(user_id, year) - latest_str = ( datetime.fromisoformat(latest).strftime("%Y-%m-%d %H:%M") if latest else "N/A" ) max_day_str = f"{max_day_count} on {max_day_date}" if max_day_date else "N/A" - await interaction.response.send_message( f"**{interaction.user.mention} β€” {year} Poop Stats**\n" f"- Total poops: **{total}**\n" @@ -1420,10 +2438,8 @@ async def poopstats(interaction: discord.Interaction): f"- Latest poop: **{latest_str}**\n" f"- Most poops in one day: **{max_day_str}**" ) - - -@bot.tree.command(name="featurerequest", description="Start a feature request ticket.") -@app_commands.guild_only() +@bot.slash_command(name="featurerequest", description="Start a feature request ticket.") +@discord.guild_only() async def featurerequest(interaction: discord.Interaction): if interaction.guild is None or interaction.channel is None: await interaction.response.send_message( @@ -1431,10 +2447,8 @@ async def featurerequest(interaction: discord.Interaction): ephemeral=True ) return - dev_user_id = get_ticket_dev_user_id() dev_member = interaction.guild.get_member(dev_user_id) if dev_user_id else None - ticket_id = await create_ticket_request( guild_id=interaction.guild.id, requester_id=interaction.user.id, @@ -1448,10 +2462,8 @@ async def featurerequest(interaction: discord.Interaction): await ticket_target.add_user(interaction.user) if dev_member: await ticket_target.add_user(dev_member) - await update_ticket_request(ticket_id, interaction.channel.id, ticket_target.id) set_ticket_target(interaction.guild.id, interaction.user.id, ticket_target.id) - dev_mention = dev_member.mention if dev_member else "" mention_line = " ".join(part for part in [interaction.user.mention, dev_mention] if part) prompt_lines = [ @@ -1468,26 +2480,22 @@ async def featurerequest(interaction: discord.Interaction): f"βœ… Created ticket #{ticket_id} in {ticket_target.mention}.", ephemeral=True ) +@bot.slash_command(name="collab", description="Add a collaborator to the current ticket thread.") +@discord.guild_only() - -@bot.tree.command(name="collab", description="Add a collaborator to the current ticket thread.") -@app_commands.guild_only() -@app_commands.describe(user="User to add to the ticket thread.") -async def collab(interaction: discord.Interaction, user: discord.Member): +async def collab(interaction: discord.Interaction, user: discord.Option(discord.Member, "User to add to the ticket thread.")): if interaction.guild is None: await interaction.response.send_message( "This command can only be used in a server.", ephemeral=True ) return - if not isinstance(interaction.channel, discord.Thread): await interaction.response.send_message( "Please use this command inside a ticket thread.", ephemeral=True ) return - ticket = get_ticket_by_thread_id(interaction.channel.id) if not ticket: await interaction.response.send_message( @@ -1495,7 +2503,6 @@ async def collab(interaction: discord.Interaction, user: discord.Member): ephemeral=True ) return - try: await interaction.channel.add_user(user) except (discord.Forbidden, discord.HTTPException): @@ -1504,23 +2511,18 @@ async def collab(interaction: discord.Interaction, user: discord.Member): ephemeral=True ) return - await add_ticket_collaborator(ticket["ticket_id"], user.id, interaction.user.id) await interaction.response.send_message( f"βœ… Added {user.mention} to this ticket thread." ) - - -@bot.tree.command(name="closeticket", description="Close the current ticket thread.") -@app_commands.guild_only() +@bot.slash_command(name="closeticket", description="Close the current ticket thread.") +@discord.guild_only() async def closeticket(interaction: discord.Interaction): if interaction.guild is None: return - dev_user_id = get_ticket_dev_user_id() if dev_user_id is None or interaction.user.id != dev_user_id: return - ticket = get_ticket_by_thread_id(interaction.channel.id) if not ticket: await interaction.response.send_message( @@ -1534,7 +2536,6 @@ async def closeticket(interaction: discord.Interaction): ephemeral=True ) return - archive_channel_id = get_ticket_archive_channel_id() if archive_channel_id is None: await interaction.response.send_message( @@ -1542,24 +2543,19 @@ async def closeticket(interaction: discord.Interaction): ephemeral=True ) return - await interaction.response.send_message( "πŸ”’ This ticket has been closed and will be archived and deleted in 24h." ) - archive_channel = await bot.fetch_channel(archive_channel_id) thread_name = f"ticket-{ticket['ticket_id']}-archive" archive_thread = await archive_channel.create_thread( name=thread_name, type=discord.ChannelType.private_thread ) - dev_member = archive_channel.guild.get_member(dev_user_id) if dev_member: await archive_thread.add_user(dev_member) - await archive_thread.send(f"**Ticket #{ticket['ticket_id']} archive**") - allowed_ids = {ticket["requester_id"], dev_user_id} async for message in interaction.channel.history(oldest_first=True, limit=None): if message.author.id not in allowed_ids: @@ -1573,57 +2569,48 @@ async def closeticket(interaction: discord.Interaction): if not content: continue await archive_thread.send(f"**{message.author.display_name}:** {content}") - await close_ticket_request(ticket["ticket_id"], archive_thread.id) - - - - def is_dev_user(user_id: int) -> bool: dev_user_id = get_ticket_dev_user_id() return bool(dev_user_id and user_id == dev_user_id) +@discord.guild_only() +@bot.slash_command(name="gplay", description="Queue and play audio from a YouTube link or search term.") - -@app_commands.guild_only() -@bot.tree.command(name="gplay", description="Queue and play audio from a YouTube link or search term.") -@app_commands.describe(youtube_link="A YouTube URL or search text.") -async def gplay(interaction: discord.Interaction, youtube_link: str): +async def gplay(interaction: discord.Interaction, youtube_link: discord.Option(str, "A YouTube URL or search text.")): if interaction.guild is None: await interaction.response.send_message("This command only works in a server.", ephemeral=True) return - voice_channel = await ensure_voice_channel(interaction) + vc = interaction.guild.voice_client + cmd_context = build_interaction_log_context(interaction, vc=vc) + logger.info("music_command_start name=gplay context=%r", cmd_context) if voice_channel is None: await interaction.response.send_message( "You must be in a voice channel to use this command.", ephemeral=True ) return - - await interaction.response.defer(ephemeral=True, thinking=True) - + await interaction.response.defer(ephemeral=True) + await interaction.followup.send("Working…", ephemeral=True) if interaction.guild.voice_client and interaction.guild.voice_client.channel != voice_channel: await interaction.followup.send( f"You must be in {interaction.guild.voice_client.channel.mention} to control playback.", ephemeral=True ) return - source = youtube_link.strip() if not source: await interaction.followup.send("Please provide a YouTube link or search query.", ephemeral=True) return - if not is_youtube_url(source): source = f"ytsearch1:{source}" - if interaction.guild.voice_client is None: try: await voice_channel.connect() - except discord.DiscordException as exc: - await interaction.followup.send(f"Could not join voice channel: {exc}", ephemeral=True) + except discord.DiscordException: + logger.exception("music_voice_connect_failed context=%r", build_interaction_log_context(interaction, vc=interaction.guild.voice_client)) + await interaction.followup.send("Could not join voice channel.", ephemeral=True) return - fetch_started_at = time.perf_counter() try: tracks = await asyncio.wait_for( @@ -1635,21 +2622,19 @@ async def gplay(interaction: discord.Interaction, youtube_link: str): log_music_timing("fetch_track_info", "timeout", fetch_started_at, source=source) await interaction.followup.send(FETCH_TRACK_INFO_TIMEOUT_MESSAGE, ephemeral=True) return - except RuntimeError as exc: - await interaction.followup.send(f"Could not fetch audio: {exc}", ephemeral=True) + except RuntimeError: + logger.exception("music_fetch_tracks_failed context=%r source=%r", build_interaction_log_context(interaction, vc=interaction.guild.voice_client), source) + await interaction.followup.send("Could not fetch audio.", ephemeral=True) return - for track in tracks: track.requested_by = interaction.user.id - state = get_music_state(interaction.guild.id) async with state.lock: starting_queue_size = len(state.queue) state.queue.extend(tracks) first_queue_position = starting_queue_size + 1 - + logger.info("music_tracks_queued count=%s first_position=%s context=%r", len(tracks), first_queue_position, build_interaction_log_context(interaction, vc=interaction.guild.voice_client)) await play_next_track(interaction.guild) - if len(tracks) == 1: track = tracks[0] await interaction.followup.send( @@ -1660,7 +2645,6 @@ async def gplay(interaction: discord.Interaction, youtube_link: str): ephemeral=True ) return - total_seconds = sum(track.duration_seconds for track in tracks) first_track = tracks[0] await interaction.followup.send( @@ -1672,15 +2656,12 @@ async def gplay(interaction: discord.Interaction, youtube_link: str): ), ephemeral=True ) - - -@app_commands.guild_only() -@bot.tree.command(name="gqueue", description="Show the current playback queue.") +@discord.guild_only() +@bot.slash_command(name="gqueue", description="Show the current playback queue.") async def gqueue(interaction: discord.Interaction): if interaction.guild is None: await interaction.response.send_message("This command only works in a server.", ephemeral=True) return - voice_channel = await ensure_voice_channel(interaction) if voice_channel is None: await interaction.response.send_message( @@ -1688,21 +2669,19 @@ async def gqueue(interaction: discord.Interaction): ephemeral=True ) return - vc = interaction.guild.voice_client + logger.info("music_command_start name=gqueue context=%r", build_interaction_log_context(interaction, vc=vc)) if vc is not None and vc.channel != voice_channel: await interaction.response.send_message( f"You must be in {vc.channel.mention} to view this queue.", ephemeral=True ) return - state = get_music_state(interaction.guild.id) async with state.lock: current_track = state.current_track started_at = state.track_started_at queued_tracks = list(state.queue) - lines = ["**Goki Queue**"] if current_track: elapsed = 0 @@ -1717,7 +2696,6 @@ async def gqueue(interaction: discord.Interaction): ) else: lines.append("Now playing: *(nothing)*") - if queued_tracks: lines.append("\n**Up next:**") for i, track in enumerate(queued_tracks, start=1): @@ -1726,17 +2704,13 @@ async def gqueue(interaction: discord.Interaction): ) else: lines.append("\nQueue is empty.") - await interaction.response.send_message("\n".join(lines), ephemeral=True) - - -@app_commands.guild_only() -@bot.tree.command(name="gskip", description="Skip the currently playing track.") +@discord.guild_only() +@bot.slash_command(name="gskip", description="Skip the currently playing track.") async def gskip(interaction: discord.Interaction): if interaction.guild is None: await interaction.response.send_message("This command only works in a server.", ephemeral=True) return - voice_channel = await ensure_voice_channel(interaction) if voice_channel is None: await interaction.response.send_message( @@ -1744,27 +2718,225 @@ async def gskip(interaction: discord.Interaction): ephemeral=True ) return - vc = interaction.guild.voice_client + logger.info("music_command_start name=gskip context=%r", build_interaction_log_context(interaction, vc=vc)) if vc is None or not vc.is_connected(): await interaction.response.send_message("Nothing is playing right now.", ephemeral=True) return - if vc.channel != voice_channel: await interaction.response.send_message( f"You must be in {vc.channel.mention} to skip tracks.", ephemeral=True ) return - if not vc.is_playing() and not vc.is_paused(): await interaction.response.send_message("Nothing is currently playing.", ephemeral=True) return - + logger.info("music_command_execute name=gskip context=%r", build_interaction_log_context(interaction, vc=vc)) vc.stop() await interaction.response.send_message("⏭️ Skipped current track.", ephemeral=True) +@discord.guild_only() +@bot.slash_command(name="gtranscribe", description="Capture voice now, with deferred transcription and guaranteed final transcript at session end.") +async def gtranscribe(interaction: discord.Interaction): + logger.info("transcribe_command_start context=%r", build_interaction_log_context(interaction, vc=getattr(getattr(interaction, "guild", None), "voice_client", None))) + if interaction.guild is None or interaction.channel is None: + await interaction.response.send_message("This command only works in a server.", ephemeral=True) + return + can_record, record_error = can_record_voice() + if not can_record: + await interaction.response.send_message( + f"Voice recording is unavailable: {record_error} Install dependencies with `pip install -r requirements.txt` and ensure system Opus is installed (for Debian/Ubuntu: `sudo apt install libopus0`).", + ephemeral=True, + ) + return + voice_channel = await ensure_voice_channel(interaction) + if voice_channel is None: + await interaction.response.send_message( + "You must be in a voice channel to start transcription.", + ephemeral=True, + ) + return + if get_transcription_session(interaction.guild.id) is not None: + await interaction.response.send_message( + "A transcription session is already active in this server. Use `/gendsession` first.", + ephemeral=True, + ) + return + permission_error = build_transcribe_permission_error(interaction, voice_channel) + if permission_error is not None: + await interaction.response.send_message(permission_error, ephemeral=True) + return + engine_init_started = time.monotonic() + engine_name, engine_instance = get_whisper_transcriber() + if engine_name is None or engine_instance is None: + await interaction.response.send_message( + "Whisper transcription engine is unavailable. Install one of: `pip install faster-whisper` (recommended) or `pip install openai-whisper`, then retry `/gtranscribe`.", + ephemeral=True, + ) + return + logger.info( + "transcribe_engine_initialized guild_id=%s engine=%s duration_ms=%.2f", + interaction.guild.id, + engine_name, + (time.monotonic() - engine_init_started) * 1000, + ) + await interaction.response.defer(ephemeral=True) + + start_label = datetime.now(LOCAL_TZ).strftime("%Y-%m-%d %H:%M") + try: + transcript_thread = await interaction.channel.create_thread( + name=f"transcript-{start_label}", + type=discord.ChannelType.private_thread, + ) + except (discord.Forbidden, discord.HTTPException): + logger.exception("transcribe_create_thread_failed context=%r", build_interaction_log_context(interaction, vc=getattr(interaction.guild, "voice_client", None))) + await interaction.followup.send( + "I couldn't create the transcript thread. Grant **Create Private Threads**, **Send Messages in Threads**, and **Manage Threads** in this text channel, then retry `/gtranscribe`.", + ephemeral=True, + ) + return + await transcript_thread.send( + ( + f"Transcription started for {voice_channel.mention}.\n" + "I will DM each participant a consent prompt for this session.\n" + "Use /gsetname to set your name for this session." + ) + ) + + vc = interaction.guild.voice_client + connected_here = False + if vc is not None and vc.channel != voice_channel: + await interaction.followup.send( + f"I am already connected to {vc.channel.mention}. Disconnect or move me first.", + ephemeral=True, + ) + return + if vc is None or not vc.is_connected(): + try: + vc = await voice_channel.connect(timeout=15.0, reconnect=True) + connected_here = True + except (discord.ClientException, discord.HTTPException, asyncio.TimeoutError): + logger.exception("transcribe_voice_connect_failed context=%r", build_interaction_log_context(interaction, vc=interaction.guild.voice_client)) + await interaction.followup.send( + "I couldn't join your voice channel. Confirm I have **Connect/Speak** permissions and that dependencies are installed (`pip install -r requirements.txt`).", + ephemeral=True, + ) + return + if vc is None: + await interaction.followup.send("I couldn't initialize a voice client.", ephemeral=True) + return + ready = await wait_for_voice_client_ready(vc) + if not ready: + if connected_here: + try: + await vc.disconnect(force=True) + except (discord.HTTPException, discord.ClientException): + pass + await interaction.followup.send( + "I connected to voice, but Discord voice setup took too long. Please try `/gtranscribe` again.", + ephemeral=True, + ) + return + if getattr(vc, "recording", False): + await interaction.followup.send("I am already recording in this server.", ephemeral=True) + return + + session = GuildTranscriptionSession(interaction.guild.id, voice_channel.id, transcript_thread.id) + session.engine_name = engine_name + session.engine_instance = engine_instance + transcription_sessions[interaction.guild.id] = session + await sync_voice_channel_members_for_transcription(interaction.guild, voice_channel, session, transcript_thread) + logger.info( + "transcribe_session_initialized guild_id=%s voice_channel_id=%s thread_id=%s consented_users=%s", + interaction.guild.id, + voice_channel.id, + transcript_thread.id, + sorted(session.consented_user_ids), + ) + + async def _capture_finished(sink: object, channel: object, *_: object): + logger.info( + "transcribe_capture_callback_start guild_id=%s sink_type=%s", + interaction.guild.id, + type(sink).__name__, + ) + session.capture_done.set() + + try: + sink = discord.sinks.WaveSink() + session.active_sink = sink + vc.start_recording(sink, _capture_finished, None) + logger.info( + "transcribe_start_recording_ok guild_id=%s voice_channel_id=%s thread_id=%s", + interaction.guild.id, + voice_channel.id, + transcript_thread.id, + ) + except Exception as exc: + logger.exception("transcribe_start_recording_failed error_type=%s context=%r", type(exc).__name__, build_interaction_log_context(interaction, vc=vc, session=session)) + remove_transcription_session(interaction.guild.id) + if connected_here: + try: + await vc.disconnect(force=True) + except (discord.HTTPException, discord.ClientException): + pass + await interaction.followup.send( + f"I joined voice but couldn't start recording: `{type(exc).__name__}`. Ensure dependencies are installed (`pip install -r requirements.txt`) and system Opus is available (`libopus0`/`libopus.so`).", + ephemeral=True, + ) + return -@bot.tree.command(name="gokibothelp", description="Show all available GokiBot commands.") + session.loop_task = asyncio.create_task(transcription_live_loop(interaction.guild.id)) + session.worker_task = asyncio.create_task(transcription_worker_loop(interaction.guild.id)) + await interaction.followup.send( + ( + f"πŸŽ™οΈ Transcription capture started in {voice_channel.mention}. " + "I will DM participants for session consent. " + "Live posts may lag; a complete transcript is guaranteed when `/gendsession` is used." + ), + ephemeral=True, + ) + + +@discord.guild_only() +@bot.slash_command(name="gendsession", description="Stop capture, finalize deferred transcription, and disconnect from voice.") +async def gendsession(interaction: discord.Interaction): + if interaction.guild is None: + await interaction.response.send_message("This command only works in a server.", ephemeral=True) + return + session = get_transcription_session(interaction.guild.id) + if session is None: + await interaction.response.send_message( + "No active transcription session in this server.", + ephemeral=True, + ) + return + vc = interaction.guild.voice_client + response_open = True + try: + await interaction.response.defer(ephemeral=True) + except discord.NotFound: + response_open = False + logger.warning("gendsession_interaction_expired guild_id=%s", interaction.guild.id) + try: + await finalize_transcription_session(interaction.guild, session, vc) + except asyncio.TimeoutError: + logger.warning("transcribe_finalization_timeout guild_id=%s", interaction.guild.id) + if session.loop_task is not None: + session.loop_task.cancel() + if session.worker_task is not None: + session.worker_task.cancel() + if vc is not None and vc.is_connected(): + try: + await vc.disconnect(force=True) + except (discord.HTTPException, discord.ClientException): + pass + remove_transcription_session(interaction.guild.id) + if response_open: + await interaction.followup.send("βœ… Transcription capture ended. Final transcript generated.", ephemeral=True) + + +@bot.slash_command(name="gokibothelp", description="Show all available GokiBot commands.") async def gokibothelp(interaction: discord.Interaction): command_lines = [ "**GokiBot Commands**", @@ -1774,9 +2946,11 @@ async def gokibothelp(interaction: discord.Interaction): "- `/gplay ` β€” Queue and play YouTube audio.", "- `/gqueue` β€” Show the current playback queue.", "- `/gskip` β€” Skip the currently playing track.", + "- `/gtranscribe` β€” Start durable voice capture (live transcript posts may lag).", + "- `/gsetname ` β€” Set your transcript display name for the current session", + "- `/gendsession` β€” Stop capture, finalize, and post the complete transcript.", "- `/gokibothelp` β€” Show this help message." ] - if is_dev_user(interaction.user.id): command_lines.extend([ "", @@ -1786,29 +2960,24 @@ async def gokibothelp(interaction: discord.Interaction): "- `/debugpoop` β€” Force-create a new poop button (admin only).", "- `/closeticket` β€” Close the current ticket thread (dev only)." ]) - await interaction.response.send_message("\n".join(command_lines), ephemeral=True) - - # ========================= # STARTUP # ========================= @bot.event async def on_ready(): + register_loop_exception_handler(asyncio.get_running_loop()) init_config_db() init_year_db(current_year_local()) init_cleanup_db() - try: - await bot.tree.sync() + await bot.sync_commands() except (discord.HTTPException, discord.Forbidden): pass - if not daily_midnight_pacific.is_running(): daily_midnight_pacific.start() if not wesroth_upload_watch.is_running(): wesroth_upload_watch.start() - # If configured guilds haven't posted today, post immediately today_local = datetime.now(LOCAL_TZ).date().isoformat() for row in get_enabled_guilds(): @@ -1820,11 +2989,13 @@ async def on_ready(): await post_button_for_guild(gid, cid) except (discord.Forbidden, discord.NotFound, discord.HTTPException): continue - - print(f"Logged in as {bot.user} (id={bot.user.id})") - - + logger.info( + "discord_runtime discord_version=%s python_version=%s opus_loaded=%s", + discord.__version__, + sys.version.split()[0], + discord.opus.is_loaded(), + ) + logger.info("bot_ready user=%s user_id=%s", bot.user, bot.user.id) if not TOKEN or TOKEN == "PUT_TOKEN_HERE_FOR_TESTING": raise RuntimeError("Set DISCORD_TOKEN_POOPBOT env var or paste token into TOKEN.") - bot.run(TOKEN) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..890f002 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +py-cord[voice]==2.6.1 +python-dotenv>=1.2.1 +PyNaCl==1.6.1 +yt-dlp>=2024.0.0 +faster-whisper