From a7f98ebf609998003a3e2b405c5198aea5dff5be Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 19 Apr 2026 00:46:59 +0200 Subject: less info logging in cron.py Signed-off-by: salmonik --- cogs/cron.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'cogs') diff --git a/cogs/cron.py b/cogs/cron.py index 6b83866..6e5020e 100644 --- a/cogs/cron.py +++ b/cogs/cron.py @@ -74,7 +74,7 @@ class Cron(commands.Cog): prev_time = cron.get_prev(datetime) time_diff = abs((now - prev_time).total_seconds()) result = time_diff <= 30 - logger.info( + logger.debug( f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}" ) return result @@ -98,7 +98,7 @@ class Cron(commands.Cog): logger.info(f"Found channel {channel} (type: {type(channel).__name__})") perms = channel.permissions_for(channel.guild.me) - logger.info( + logger.debug( f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}" ) @@ -151,7 +151,7 @@ class Cron(commands.Cog): message = task["message"] logger.info(f"Message to send: '{message}' | Channel: {channel.id}") await channel.send(message) - logger.info("Message sent successfully") + logger.debug("Message sent successfully") async def audio_autocomplete(self, ctx: discord.AutocompleteContext): """Autocomplete for audio files from file_search""" -- cgit v1.2.3 From 4a796f55944b560b52d18107e23b175c5171df9f Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 19 Apr 2026 00:47:42 +0200 Subject: error handling in plot.py Signed-off-by: salmonik --- cogs/plot.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) (limited to 'cogs') diff --git a/cogs/plot.py b/cogs/plot.py index 4fcde44..ee19de9 100644 --- a/cogs/plot.py +++ b/cogs/plot.py @@ -20,10 +20,10 @@ _plot_available = False def _ensure_plot(): global _plt_module, _plot_available - + if _plt_module is not None: return _plot_available - + try: import matplotlib matplotlib.use("Agg") @@ -61,12 +61,12 @@ class Plot(commands.Cog): def _generate_plot(self, func_expr: str, output_path: str): global _plt_module - + if not _ensure_plot(): raise RuntimeError("matplotlib not available") plt = _plt_module - + if is_implicit(func_expr): self._generate_implicit_plot(func_expr, output_path) return @@ -100,6 +100,10 @@ class Plot(commands.Cog): except Exception: y.append(float("nan")) + valid_y = [yi for yi in y if not math.isnan(yi)] + if not valid_y: + raise ValueError("Brak danych do wykreślenia dla podanej funkcji") + fig, ax = plt.subplots(figsize=(10, 6)) ax.plot(x, y, linewidth=2, color="#00ff00") ax.set_title(f"y = {func_expr}", fontsize=14) @@ -117,15 +121,15 @@ class Plot(commands.Cog): def _generate_implicit_plot(self, func_expr: str, output_path: str): global _plt_module plt = _plt_module - + lhs, rhs = parse_implicit(func_expr) - + x_range = 20 y_range = 20 - + x_vals = [] y_vals = [] - + for xi in range(-x_range * 10, x_range * 10 + 1): x = xi / 10.0 for yi in range(-y_range * 10, y_range * 10 + 1): @@ -139,7 +143,10 @@ class Plot(commands.Cog): y_vals.append(y) except Exception: pass - + + if not x_vals or not y_vals: + raise ValueError("Brak danych do wykreślenia dla podanej funkcji") + fig, ax = plt.subplots(figsize=(10, 10)) ax.scatter(x_vals, y_vals, s=1, color="#00ff00", alpha=0.5) ax.set_title(f"{func_expr}", fontsize=14) @@ -153,7 +160,7 @@ class Plot(commands.Cog): ax.set_aspect("equal") ax.set_facecolor("#1a1a1a") fig.patch.set_facecolor("#1a1a1a") - + plt.savefig(output_path, dpi=100, bbox_inches="tight") plt.close() @@ -185,4 +192,4 @@ class Plot(commands.Cog): def setup(bot): - bot.add_cog(Plot(bot)) \ No newline at end of file + bot.add_cog(Plot(bot)) -- cgit v1.2.3 From c65a2cb0b35982fac320744fb662456025ad42d0 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 19 Apr 2026 14:04:27 +0200 Subject: discontinue plots; too complex. Signed-off-by: salmonik --- cogs/plot.py | 195 ----------------------------------------------------------- 1 file changed, 195 deletions(-) delete mode 100644 cogs/plot.py (limited to 'cogs') diff --git a/cogs/plot.py b/cogs/plot.py deleted file mode 100644 index ee19de9..0000000 --- a/cogs/plot.py +++ /dev/null @@ -1,195 +0,0 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import asyncio -import logging -import math -import os -import re - -import discord -from discord.ext import commands - -logger = logging.getLogger(__name__) - -_plt_module = None -_plot_available = False - - -def _ensure_plot(): - global _plt_module, _plot_available - - if _plt_module is not None: - return _plot_available - - try: - import matplotlib - matplotlib.use("Agg") - import matplotlib.pyplot as _plt_module - _plot_available = True - except ImportError: - _plot_available = False - logger.warning("matplotlib not available, plot command will be disabled") - return _plot_available - - -def parse_expression(expr: str) -> str: - expr = expr.replace("^", "**") - expr = re.sub(r"(\d)([x])", r"\1*\2", expr) - expr = re.sub(r"([x])(\d)", r"\1*\2", expr) - expr = re.sub(r"(\d)([y])", r"\1*\2", expr) - expr = re.sub(r"([y])(\d)", r"\1*\2", expr) - return expr - - -def is_implicit(expr: str) -> bool: - return "=" in expr and "y" in expr and "x" in expr - - -def parse_implicit(expr: str) -> tuple[str, str]: - lhs, rhs = expr.split("=", 1) - lhs = parse_expression(lhs.strip()) - rhs = parse_expression(rhs.strip()) - return lhs, rhs - - -class Plot(commands.Cog): - def __init__(self, bot): - self.bot = bot - - def _generate_plot(self, func_expr: str, output_path: str): - global _plt_module - - if not _ensure_plot(): - raise RuntimeError("matplotlib not available") - - plt = _plt_module - - if is_implicit(func_expr): - self._generate_implicit_plot(func_expr, output_path) - return - - parsed_expr = parse_expression(func_expr) - - x = [i * 0.1 for i in range(-100, 101)] - y = [] - - for xi in x: - try: - result = eval( - parsed_expr, - { - "x": xi, - "math": math, - "sin": math.sin, - "cos": math.cos, - "tan": math.tan, - "sqrt": math.sqrt, - "log": math.log, - "exp": math.exp, - "pi": math.pi, - "e": math.e, - }, - ) - if math.isfinite(result): - y.append(result) - else: - y.append(float("nan")) - except Exception: - y.append(float("nan")) - - valid_y = [yi for yi in y if not math.isnan(yi)] - if not valid_y: - raise ValueError("Brak danych do wykreślenia dla podanej funkcji") - - fig, ax = plt.subplots(figsize=(10, 6)) - ax.plot(x, y, linewidth=2, color="#00ff00") - ax.set_title(f"y = {func_expr}", fontsize=14) - ax.set_xlabel("x", fontsize=12) - ax.set_ylabel("y", fontsize=12) - ax.grid(True, alpha=0.3) - ax.axhline(y=0, color="white", linewidth=0.5) - ax.axvline(x=0, color="white", linewidth=0.5) - ax.set_facecolor("#1a1a1a") - fig.patch.set_facecolor("#1a1a1a") - - plt.savefig(output_path, dpi=100, bbox_inches="tight") - plt.close() - - def _generate_implicit_plot(self, func_expr: str, output_path: str): - global _plt_module - plt = _plt_module - - lhs, rhs = parse_implicit(func_expr) - - x_range = 20 - y_range = 20 - - x_vals = [] - y_vals = [] - - for xi in range(-x_range * 10, x_range * 10 + 1): - x = xi / 10.0 - for yi in range(-y_range * 10, y_range * 10 + 1): - y = yi / 10.0 - try: - left_val = eval(lhs, {"x": x, "y": y, "math": math, "sqrt": math.sqrt, "pi": math.pi, "e": math.e}) - right_val = eval(rhs, {"x": x, "y": y, "math": math, "sqrt": math.sqrt, "pi": math.pi, "e": math.e}) - diff = abs(left_val - right_val) - if math.isfinite(diff) and diff < 0.3: - x_vals.append(x) - y_vals.append(y) - except Exception: - pass - - if not x_vals or not y_vals: - raise ValueError("Brak danych do wykreślenia dla podanej funkcji") - - fig, ax = plt.subplots(figsize=(10, 10)) - ax.scatter(x_vals, y_vals, s=1, color="#00ff00", alpha=0.5) - ax.set_title(f"{func_expr}", fontsize=14) - ax.set_xlabel("x", fontsize=12) - ax.set_ylabel("y", fontsize=12) - ax.grid(True, alpha=0.3) - ax.axhline(y=0, color="white", linewidth=0.5) - ax.axvline(x=0, color="white", linewidth=0.5) - ax.set_xlim(-x_range, x_range) - ax.set_ylim(-y_range, y_range) - ax.set_aspect("equal") - ax.set_facecolor("#1a1a1a") - fig.patch.set_facecolor("#1a1a1a") - - plt.savefig(output_path, dpi=100, bbox_inches="tight") - plt.close() - - @commands.slash_command(name="funkcja", description="Generuje wykres funkcji matematycznej") - async def plot(self, ctx, funkcja: str = discord.Option(str, "Funkcja np. x^3, sin(x), x^2 + 2*x - 1", required=True)): - if not _ensure_plot(): - await ctx.respond("Generowanie wykresów nie jest dostępne (brak matplotlib)", ephemeral=True) - return - - await ctx.defer() - - from cogs.utils.temp import get_temp_dir - temp_path = os.path.join(get_temp_dir(), f"plot_{os.getpid()}_{asyncio.get_event_loop().time()}.png") - - try: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self._generate_plot, funkcja, temp_path) - await ctx.respond(file=discord.File(temp_path, filename="plot.png")) - logger.info(f"Generated plot for function: {funkcja}") - except Exception as e: - logger.error(f"Plot generation error: {e}") - await ctx.respond(f"Błąd generowania wykresu: {str(e)}", ephemeral=True) - finally: - try: - if os.path.exists(temp_path): - os.unlink(temp_path) - except Exception as e: - logger.error(f"Error cleaning up temp file: {e}") - - -def setup(bot): - bot.add_cog(Plot(bot)) -- cgit v1.2.3 From 1dcdbe3cf03ff16b3ff2e2e6d572e0b3c72c18c0 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 19 Apr 2026 15:43:32 +0200 Subject: refactor: simplify cogs, reduce complexity - Remove unused imports and dead code - Cleaner error handling - Reduce cognitive complexity --- cogs/ai.py | 35 ++++++++++++++-------------- cogs/assistant.py | 13 ++++++----- cogs/compression.py | 1 + cogs/cron.py | 2 +- cogs/downloader.py | 2 +- cogs/music/audio_playback.py | 55 ++++++++++++++++++++++++++++++-------------- cogs/music/play_commands.py | 4 +++- cogs/qr.py | 8 +++---- cogs/trigger.py | 44 ++++++++++++++++++++--------------- cogs/tts.py | 12 ++++------ cogs/utils/settings.py | 11 +++++---- cogs/utils/temp.py | 4 ++-- cogs/vclogs.py | 15 ++++++++++-- cogs/voice.py | 4 ++-- cogs/web.py | 2 +- 15 files changed, 127 insertions(+), 85 deletions(-) (limited to 'cogs') diff --git a/cogs/ai.py b/cogs/ai.py index 4a04693..6f79ac5 100644 --- a/cogs/ai.py +++ b/cogs/ai.py @@ -7,7 +7,7 @@ import os import discord from discord.ext import commands import logging -import requests +import httpx from gtts import gTTS from cogs.music import audio_playback import io @@ -52,23 +52,24 @@ class AI(commands.Cog): ], } - response = requests.post(url=url, headers=headers, json=payload) - response_data = response.json() - logger.info(response_data) - if not response_data["choices"]: - await ctx.send_followup("bot siem zesroł") - else: - response_text = response_data["choices"][0]["message"]["content"] - if tts: - # Generate TTS - tts_obj = gTTS(text=response_text, lang='pl') - tts_buffer = io.BytesIO() - tts_obj.write_to_fp(tts_buffer) - tts_buffer.seek(0) - await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) - + async with httpx.AsyncClient() as client: + response = await client.post(url=url, headers=headers, json=payload) + response_data = response.json() + logger.info(response_data) + if not response_data["choices"]: + await ctx.send_followup("bot siem zesroł") else: - await ctx.send_followup(response_text) + response_text = response_data["choices"][0]["message"]["content"] + if tts: + # Generate TTS + tts_obj = gTTS(text=response_text, lang='pl') + tts_buffer = io.BytesIO() + tts_obj.write_to_fp(tts_buffer) + tts_buffer.seek(0) + await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) + + else: + await ctx.send_followup(response_text) diff --git a/cogs/assistant.py b/cogs/assistant.py index 71f5150..21e4989 100644 --- a/cogs/assistant.py +++ b/cogs/assistant.py @@ -6,7 +6,7 @@ import re import discord from discord.ext import commands -import requests +import httpx import json import logging import shutil @@ -133,11 +133,12 @@ class Jarvis(commands.Cog): @staticmethod async def _make_api_request(payload, headers): - url = "https://openrouter.ai/api/v1/chat/completions" - response = requests.post(url=url, headers=headers, json=payload) - response_data = response.json() - logger.info(response_data) - return response_data + async with httpx.AsyncClient() as client: + url = "https://openrouter.ai/api/v1/chat/completions" + response = await client.post(url=url, headers=headers, json=payload) + response_data = response.json() + logger.info(response_data) + return response_data @staticmethod def _parse_response(response_data): diff --git a/cogs/compression.py b/cogs/compression.py index 150239f..acc9eda 100644 --- a/cogs/compression.py +++ b/cogs/compression.py @@ -275,6 +275,7 @@ class Compression(commands.Cog): if file_type == "Image": command, output_path = command elif file_type == "Audio": + # Audio output path remains unchanged - already processed in _process_audio pass success = await self._execute_compression(command) diff --git a/cogs/cron.py b/cogs/cron.py index 6e5020e..da8127b 100644 --- a/cogs/cron.py +++ b/cogs/cron.py @@ -92,7 +92,7 @@ class Cron(commands.Cog): channel = self.bot.get_channel(channel_id) if not channel: - logger.warning(f"Channel {channel_id} not found for task {task['id']}") + logger.warning(f"Channel {channel_id} not found for task ID: {task.get('id')}") return logger.info(f"Found channel {channel} (type: {type(channel).__name__})") diff --git a/cogs/downloader.py b/cogs/downloader.py index d364b3a..36bd723 100644 --- a/cogs/downloader.py +++ b/cogs/downloader.py @@ -195,7 +195,7 @@ class Downloader(commands.Cog): } @staticmethod - def _create_error_response(content: str, details: str, temp_dir: str) -> dict: + def _create_error_response(content: str, details: str, temp_dir: str | None) -> dict: return { "status": "error", "type": "error", diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py index 1267fa7..28c6118 100644 --- a/cogs/music/audio_playback.py +++ b/cogs/music/audio_playback.py @@ -60,17 +60,18 @@ class AudioPlaybackManager: """Connect or move to voice channel. Returns voice client.""" try: # Check if vc is valid and actually connected - is_connected = vc is not None and vc.is_connected() - # Connect to voice channel if not connected - if not is_connected: - logger.info(f"Connecting to voice channel: {target_channel.name}") - vc = await target_channel.connect() - logger.info(f"Connected to voice channel: {target_channel.name}") - # Move to user's channel if in a different one - elif vc.channel != target_channel: - logger.info(f"Moving to voice channel: {target_channel.name}") - await vc.move_to(target_channel) - logger.info(f"Moved to voice channel: {target_channel.name}") + if vc is not None and vc.is_connected(): + # Already connected - check if need to move + if vc.channel != target_channel: + logger.info(f"Moving to voice channel: {target_channel.name}") + await vc.move_to(target_channel) + logger.info(f"Moved to voice channel: {target_channel.name}") + return vc + + # Not connected - connect now + logger.info(f"Connecting to voice channel: {target_channel.name}") + vc = await target_channel.connect() + logger.info(f"Connected to voice channel: {target_channel.name}") return vc except Exception as e: logger.error(f"Error connecting to voice channel: {str(e)}") @@ -138,15 +139,35 @@ class AudioPlaybackManager: @staticmethod async def execute_playback(ctx, source, display_name, is_url): """Execute the actual audio playback.""" - vc = ctx.voice_client target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) if not target_channel: return False + # Get existing voice client from bot or connect + guild = ctx.guild + vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) + try: - vc = await AudioPlaybackManager.connect_to_voice_channel( - ctx, vc, target_channel - ) + # Handle various connection states + if vc is None: + logger.info("No existing voice client, connecting...") + try: + vc = await target_channel.connect() + except discord.ClientException: + vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) + elif not vc.is_connected(): + logger.info("Voice client exists but disconnected, reconnecting...") + try: + await vc.disconnect() + except Exception: + pass + try: + vc = await target_channel.connect() + except discord.ClientException: + vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) + elif vc.channel != target_channel: + logger.info(f"Moving to voice channel: {target_channel.name}") + await vc.move_to(target_channel) # Stop any currently playing audio if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] @@ -171,7 +192,7 @@ class AudioPlaybackManager: # Play the audio vc.play(audio_source, after=after_playing) logger.info(f"Playing: {final_display_name}") - await ctx.respond(f"grom: {final_display_name}") + await ctx.followup.send(f"grom: {final_display_name}") # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds await asyncio.sleep(5) @@ -195,7 +216,7 @@ class AudioPlaybackManager: except Exception as e: logger.error(f"Error in play: {str(e)}", exc_info=True) - await ctx.respond(f"eror: {str(e)}", ephemeral=True) + await ctx.followup.send(f"eror: {str(e)}", ephemeral=True) return False @staticmethod diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index fd5113a..a82fa9d 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py @@ -6,6 +6,7 @@ import logging import os import urllib.parse +import asyncio from os import PathLike from typing import Awaitable, Callable, List, Tuple @@ -139,9 +140,10 @@ class PlayCommands(commands.Cog): local_path = os.path.join(self.temp_dir, filename) os.makedirs(self.temp_dir, exist_ok=True) + loop = asyncio.get_event_loop() with open(local_path, "wb") as f: async for chunk in response.content.iter_chunked(8192): - f.write(chunk) + await loop.run_in_executor(None, f.write, chunk) compressed_path = await media_processing.AudioCompressor.compress( ctx, local_path diff --git a/cogs/qr.py b/cogs/qr.py index c1fd499..32c574e 100644 --- a/cogs/qr.py +++ b/cogs/qr.py @@ -22,10 +22,10 @@ _decode_available = False def _ensure_qr(): global _pil_image, _decode_func, _decode_available - + if _pil_image is not None: return _decode_available - + try: from PIL import Image from pyzbar.pyzbar import decode as _decode_func @@ -48,7 +48,7 @@ class QR(commands.Cog): if response.status == 200: content = await response.read() with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f: - f.write(content) + await asyncio.get_event_loop().run_in_executor(None, f.write, content) return f.name return None @@ -137,7 +137,7 @@ class QR(commands.Cog): def _decode_qr(self, image_path): """Decode QR code from image (runs in executor)""" - if not _ensure_qr(): + if not _ensure_qr() or _decode_func is None: return None try: diff --git a/cogs/trigger.py b/cogs/trigger.py index 0e76cef..02e5573 100644 --- a/cogs/trigger.py +++ b/cogs/trigger.py @@ -15,6 +15,12 @@ logger = logging.getLogger(__name__) DB_PATH = 'data/triggers.db' +_SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" +_SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" +_SELECT_TRIGGER_ID_SQL = "SELECT id FROM triggers WHERE name=?" +_TRIGGER_NOT_FOUND_MSG = "Trigger not found." +_TRIGGER_NAME_OPTION = "the name of the trigger" + def get_connection(): return sqlite3.connect(DB_PATH) @@ -112,10 +118,10 @@ class Trigger(commands.Cog): c = conn.cursor() c.execute("SELECT id, name FROM triggers") for trigger_id, name in c.fetchall(): - c.execute("SELECT keyword FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) + c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) keywords = [row[0] for row in c.fetchall()] if any(keyword in content for keyword in keywords): - c.execute("SELECT response_text FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) + c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) responses = [row[0] for row in c.fetchall()] if responses: await message.channel.send(random.choice(responses).replace('\\n', '\n')) @@ -137,7 +143,7 @@ class Trigger(commands.Cog): ctx: discord.ApplicationContext, name = discord.Option( str, - "the name of the trigger", + _TRIGGER_NAME_OPTION, autocomplete= discord.utils.basic_autocomplete(get_type), required=True ) @@ -157,7 +163,7 @@ class Trigger(commands.Cog): ctx: discord.ApplicationContext, name = discord.Option( str, - "the name of the trigger", + _TRIGGER_NAME_OPTION, autocomplete= discord.utils.basic_autocomplete(get_type), required=True ), @@ -174,10 +180,10 @@ class Trigger(commands.Cog): ): with get_connection() as conn: c = conn.cursor() - c.execute("SELECT id FROM triggers WHERE name=?", (name,)) + c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) row = c.fetchone() if not row: - await ctx.respond("Trigger not found.") + await ctx.respond(_TRIGGER_NOT_FOUND_MSG) logger.warning(f"Trigger not found: {name}") return trigger_id = row[0] @@ -204,7 +210,7 @@ class Trigger(commands.Cog): ctx: discord.ApplicationContext, name = discord.Option( str, - "the name of the trigger", + _TRIGGER_NAME_OPTION, autocomplete= discord.utils.basic_autocomplete(get_type), required=True ), @@ -221,10 +227,10 @@ class Trigger(commands.Cog): ): with get_connection() as conn: c = conn.cursor() - c.execute("SELECT id FROM triggers WHERE name=?", (name,)) + c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) row = c.fetchone() if not row: - await ctx.respond("Trigger not found.") + await ctx.respond(_TRIGGER_NOT_FOUND_MSG) logger.warning(f"Trigger not found: {name}") return trigger_id = row[0] @@ -245,17 +251,17 @@ class Trigger(commands.Cog): ctx: discord.ApplicationContext, name = discord.Option( str, - "the name of the trigger", + _TRIGGER_NAME_OPTION, autocomplete= discord.utils.basic_autocomplete(get_type), required=True ) ): with get_connection() as conn: c = conn.cursor() - c.execute("SELECT id FROM triggers WHERE name=?", (name,)) + c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) row = c.fetchone() if not row: - await ctx.respond("Trigger not found.") + await ctx.respond(_TRIGGER_NOT_FOUND_MSG) return trigger_id = row[0] c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) @@ -275,7 +281,7 @@ class Trigger(commands.Cog): ctx: discord.ApplicationContext, name = discord.Option( str, - "the name of the trigger", + _TRIGGER_NAME_OPTION, autocomplete=discord.utils.basic_autocomplete(get_type), required=True ) @@ -286,13 +292,13 @@ class Trigger(commands.Cog): c.execute("SELECT id FROM triggers WHERE name=?", (name,)) row = c.fetchone() if not row: - await ctx.respond("Trigger not found.") + await ctx.respond(_TRIGGER_NOT_FOUND_MSG) logger.warning(f"Trigger not found: {name}") return trigger_id = row[0] - c.execute("SELECT keyword FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) + c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) triggers = [row[0] for row in c.fetchall()] - c.execute("SELECT response_text FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) + c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) responses = [row[0] for row in c.fetchall()] trigger_list = "\n".join(triggers) response_list = "\n".join(responses) @@ -303,13 +309,13 @@ class Trigger(commands.Cog): c.execute("SELECT id, name FROM triggers") messages = [] for trigger_id, name in c.fetchall(): - c.execute("SELECT keyword FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) + c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) triggers = ", ".join([row[0] for row in c.fetchall()]) - c.execute("SELECT response_text FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) + c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) responses = ", ".join([row[0] for row in c.fetchall()]) messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}") await ctx.respond("\n\n".join(messages)) - logger.info(f"Triggers listed") + logger.info("Triggers listed") @trigger_group.command( description="how to use trigger group?" diff --git a/cogs/tts.py b/cogs/tts.py index 3dc4c09..68eb27f 100644 --- a/cogs/tts.py +++ b/cogs/tts.py @@ -54,19 +54,17 @@ class TTS(commands.Cog): if not voice_client: await ctx.respond("Failed to connect to voice channel", ephemeral=True) return - - # Play the TTS audio with volume adjustment + + if voice_client.is_playing(): + await ctx.respond("Audio is already playing, try again later", ephemeral=True) + return + audio_source = discord.FFmpegPCMAudio( temp_path, options=f'-filter:a "volume={volume}"' ) - voice_client.play(audio_source) logger.info(f"Playing TTS: {text}") - # Wait for the audio to finish playing - while voice_client.is_playing(): - await asyncio.sleep(0.1) - except Exception as e: logger.error(f"Error in voice channel handling: {e}") await ctx.respond("eror :c", ephemeral=True) diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py index 984ec16..d66ce7f 100644 --- a/cogs/utils/settings.py +++ b/cogs/utils/settings.py @@ -12,18 +12,19 @@ from dataclasses import dataclass, field logger = logging.getLogger(__name__) settings_file = "data/settings.json" +DEFAULT_BASEURL = "http://localhost:8080" @dataclass class Settings: volume: float = 1.0 - baseurl: str = "http://localhost:8080" + baseurl: str = DEFAULT_BASEURL _data: dict = field(default_factory=dict, init=False) async def load(self) -> "Settings": default_settings = { "volume": 1.0, - "baseurl": "http://localhost:8080", + "baseurl": DEFAULT_BASEURL, } try: @@ -44,7 +45,7 @@ class Settings: self._sync_from_data() await self.save() logger.info("Created default settings.json") - except (json.JSONDecodeError, Exception) as e: + except json.JSONDecodeError as e: logger.error(f"Error loading settings: {e}") self._data = default_settings.copy() self._sync_from_data() @@ -53,7 +54,7 @@ class Settings: def _sync_from_data(self): self.volume = self._data.get("volume", 1.0) - self.baseurl = self._data.get("baseurl", "http://localhost:8080") + self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) def _sync_to_data(self): self._data["volume"] = self.volume @@ -89,4 +90,4 @@ def get_volume() -> float: def get_baseurl() -> str: - return _settings_instance.baseurl.rstrip("/") if _settings_instance else "http://localhost:8080" + return _settings_instance.baseurl.rstrip("/") if _settings_instance else DEFAULT_BASEURL diff --git a/cogs/utils/temp.py b/cogs/utils/temp.py index 528d6a3..0b2c8d7 100644 --- a/cogs/utils/temp.py +++ b/cogs/utils/temp.py @@ -8,11 +8,11 @@ import tempfile def get_temp_dir(subdir: str = "senator_sperma") -> str: """Get or create a temp subdirectory. - + Uses system temp directory (/tmp on Linux, %TEMP% on Windows). OS handles cleanup automatically. """ base_temp = tempfile.gettempdir() temp_path = os.path.join(base_temp, subdir) os.makedirs(temp_path, exist_ok=True) - return temp_path \ No newline at end of file + return temp_path diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 397d296..85d19bc 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -14,9 +14,11 @@ from gtts import gTTS from cogs.voice import get_voice_manager from cogs.utils.settings import get_volume, save_settings +import tempfile + logger = logging.getLogger(__name__) -ACTIVITY_FILE = 'temp/voice_activity.json' +ACTIVITY_FILE = os.path.join(tempfile.gettempdir(), 'voice_activity.json') def load_activity_data(): @@ -125,7 +127,16 @@ class VcLogs(commands.Cog): @commands.Cog.listener() async def on_voice_state_update(self, member, before, after): - log_channel = self.bot.get_channel(1213843195457183885) + log_channel_id = os.getenv("STATUS_CHANNEL_ID") + if not log_channel_id: + logger.warning("STATUS_CHANNEL_ID not set in environment") + return + + log_channel = self.bot.get_channel(int(log_channel_id)) + if log_channel is None: + logger.warning("Log channel not found or not accessible") + return + current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") current_ts = datetime.datetime.now().timestamp() tts_text = None diff --git a/cogs/voice.py b/cogs/voice.py index 07015cc..319bb16 100644 --- a/cogs/voice.py +++ b/cogs/voice.py @@ -95,7 +95,7 @@ class VoiceManager: if not voice_client: return False - if volume != 1.0: + if abs(volume - 1.0) > 1e-9: audio_source = discord.PCMVolumeTransformer(audio_source) audio_source.volume = volume @@ -197,4 +197,4 @@ class Voice(commands.Cog): def setup(bot): - bot.add_cog(Voice(bot)) \ No newline at end of file + bot.add_cog(Voice(bot)) diff --git a/cogs/web.py b/cogs/web.py index e003399..b072e99 100644 --- a/cogs/web.py +++ b/cogs/web.py @@ -97,4 +97,4 @@ class Web(commands.Cog): def setup(bot): - bot.add_cog(Web(bot)) \ No newline at end of file + bot.add_cog(Web(bot)) -- cgit v1.2.3 From 920d4b18e253f5642b895a1205d9826d54570db8 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 19 Apr 2026 17:41:05 +0200 Subject: feat: add external URL playback support, fix vclogs audio handling --- AGENTS.md | 28 +++++++++ cogs/music/play_commands.py | 108 ++++++++++++++++++++++++++++++++- cogs/vclogs.py | 14 ++++- pyrightconfig.json | 1 + tests/cogs/test_music_external_play.py | 108 +++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/cogs/test_music_external_play.py (limited to 'cogs') diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..44be9d5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Agent Instructions for Senator Sperma + +## Required Setup +- Requires **Python 3.13** (3.14 not supported - py-cord incompatibility) +- System dependencies: `ffmpeg`, `zbar` library (installed system-wide) +- Bot token in `.env` file (copy from `.env.example`) + +## Commands +- Run bot: `.venv/bin/python main.py` +- Type check: `.venv/bin/basedpyright` +- Test: `.venv/bin/pytest` + +## Architecture +- Slash commands defined as cogs in `cogs/*.py` +- Discord API uses `discord.ApplicationContext` +- Music auto-complete queries copyparty server (`COPYPARTY_URL` config) + +## Environment +- Copyparty URL for read-only access is COPYPARTY_URL +- Copyparty URL for write-only access is COPYPARTY_URL_UPLOAD +- Above should be accessed using COPYPARTY_PASSWORD as a cookie +- Channel for vclogs and alike is STATUS_CHANNEL_ID +- Trigger word for assistant is ASSISTANT_KEYWORD + +## Testing +- Tests in `tests/cogs/test_*.py` +- Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` +- `conftest.py` provides fixtures for mocking Discord objects diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index a82fa9d..5373436 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py @@ -9,16 +9,22 @@ import urllib.parse import asyncio from os import PathLike from typing import Awaitable, Callable, List, Tuple +import re +from yt_dlp import YoutubeDL import discord from discord.commands import OptionChoice from discord.ext import commands from cogs.music import audio_playback, media_processing -from cogs.utils import file_search as search_utils +from cogs.utils.file_search import UniversalSearcher logger = logging.getLogger(__name__) +EXTERNAL_URL_PATTERN = re.compile( + r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" +) + class PlayCommands(commands.Cog): """Discord slash commands for music playback.""" @@ -89,9 +95,12 @@ class PlayCommands(commands.Cog): self, ctx: discord.AutocompleteContext ) -> List[OptionChoice]: """Autocomplete handler for search queries.""" + if ctx.value.lower().startswith(("http://", "https://")): + return [] + query = ctx.value.lower() try: - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( + results = await UniversalSearcher.search_copyparty_autocomplete_audio( query, max_results=25 ) choices = [] @@ -164,6 +173,96 @@ class PlayCommands(commands.Cog): except Exception: pass + async def _handle_external_url_play( + self, ctx: discord.ApplicationContext, url: str + ) -> None: + """Download from external URL, upload to copyparty, then play.""" + local_path = None + try: + ytdl_opts = { + "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"), + "postprocessors": [ + { + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "192", + } + ], + "force_ipv4": True, + } + + loop = asyncio.get_event_loop() + with YoutubeDL(ytdl_opts) as ydl: + info_dict = await loop.run_in_executor( + None, lambda: ydl.extract_info(url, download=True) + ) + title = info_dict.get("title", "nieznany") + base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] + local_path = f"{base_filename}.mp3" + + if not os.path.exists(local_path): + await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) + return + + upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) + + await audio_playback.AudioPlaybackManager.play( + ctx, local_path, delete_after_play=True + ) + + try: + await asyncio.wait_for(upload_task, timeout=30.0) + except asyncio.TimeoutError: + logger.warning("Upload timed out") + except Exception as e: + logger.error(f"Error downloading external URL: {str(e)}", exc_info=True) + if local_path and os.path.exists(local_path): + try: + os.remove(local_path) + except Exception: + pass + await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True) + + async def _upload_to_copyparty_async(self, file_path: str) -> None: + """Upload file to copyparty asynchronously (fire and forget).""" + import aiohttp + + try: + logger.info(f"Starting async upload: {file_path}") + baseurl = os.getenv("COPYPARTY_URL_UPLOAD") + password = os.getenv("COPYPARTY_PASSWORD", "") + + logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") + + if not baseurl: + logger.error("COPYPARTY_URL_UPLOAD not set!") + return + + with open(file_path, "rb") as f: + content = f.read() + + files = aiohttp.FormData() + files.add_field( + "f", + content, + filename=os.path.basename(file_path), + content_type="application/octet-stream", + ) + headers = {"Cookie": f"cppwd={password}"} + params = {"j": "1", "ck": "b2"} + + logger.info(f"Posting to {baseurl}") + async with aiohttp.ClientSession() as session: + async with session.post(baseurl, data=files, headers=headers, params=params) as response: + text = await response.text() + logger.info(f"Upload response: {response.status} - {text[:200] if text else 'empty'}") + + if response.status in [200, 201]: + result = await response.json() + logger.info(f"Uploaded to copyparty: {result.get('fileurl')}") + except Exception as e: + logger.error(f"Async upload failed: {e}", exc_info=True) + async def _handle_local_file_play( self, ctx: discord.ApplicationContext, file: str, compressed: bool ) -> None: @@ -211,7 +310,10 @@ class PlayCommands(commands.Cog): await audio_playback.AudioPlaybackManager.play(ctx, file_path) else: try: - await self._handle_local_file_play(ctx, melodia, compressed) + if EXTERNAL_URL_PATTERN.match(melodia.lower()): + await self._handle_external_url_play(ctx, melodia) + else: + await self._handle_local_file_play(ctx, melodia, compressed) except Exception as e: logger.error(f"Error playing: {str(e)}", exc_info=True) await ctx.respond( diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 85d19bc..fb0a2e0 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -102,12 +102,20 @@ class VcLogs(commands.Cog): volume = get_volume() logger.info(f"connected: {voice_client}") - if voice_client and not is_playing: + if voice_client: + if is_playing: + logger.info("voice already playing, stopping current audio") + voice_client.stop() + await asyncio.sleep(0.1) + logger.info("preparing to play audio") - audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') + audio_source = discord.FFmpegPCMAudio(tts_file) + if volume != 1.0: + audio_source = discord.PCMVolumeTransformer(audio_source) + audio_source.volume = volume voice_client.play(audio_source) while voice_client.is_playing(): - await asyncio.sleep(0.5) + await asyncio.sleep(0.1) except Exception as e: logger.error(f"TTS playback error: {e}") finally: diff --git a/pyrightconfig.json b/pyrightconfig.json index f0b2a2d..7722ae1 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -15,4 +15,5 @@ "reportAttributeAccessIssue": false, "reportPossiblyUnboundVariable": false, "reportArgumentType": false, + "reportUnreachable": false, } diff --git a/tests/cogs/test_music_external_play.py b/tests/cogs/test_music_external_play.py new file mode 100644 index 0000000..76debc9 --- /dev/null +++ b/tests/cogs/test_music_external_play.py @@ -0,0 +1,108 @@ +import pytest +import os +import asyncio +import tempfile +from unittest.mock import AsyncMock, patch, MagicMock +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def play_commands_cog(bot): + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + from cogs.music.play_commands import PlayCommands + cog = PlayCommands(bot) + return cog + + +@pytest.fixture +def temp_mp3_file(): + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: + f.write(b'fake mp3 content') + temp_path = f.name + yield temp_path + if os.path.exists(temp_path): + os.remove(temp_path) + + +@pytest.mark.asyncio +async def test_external_url_pattern_matches_youtube(play_commands_cog): + from cogs.music.play_commands import EXTERNAL_URL_PATTERN + + assert EXTERNAL_URL_PATTERN.match("https://youtube.com/watch?v=test") + assert EXTERNAL_URL_PATTERN.match("https://youtu.be/test") + assert EXTERNAL_URL_PATTERN.match("https://www.youtube.com/watch?v=test") + + +@pytest.mark.asyncio +async def test_external_url_pattern_matches_soundcloud(play_commands_cog): + from cogs.music.play_commands import EXTERNAL_URL_PATTERN + + assert EXTERNAL_URL_PATTERN.match("https://soundcloud.com/artist/track") + assert EXTERNAL_URL_PATTERN.match("https://api.soundcloud.com/tracks") + + +@pytest.mark.asyncio +async def test_external_url_pattern_matches_bandcamp(play_commands_cog): + from cogs.music.play_commands import EXTERNAL_URL_PATTERN + + assert EXTERNAL_URL_PATTERN.match("https://artist.bandcamp.com/track") + + +@pytest.mark.asyncio +async def test_external_url_pattern_rejects_copyparty(play_commands_cog): + from cogs.music.play_commands import EXTERNAL_URL_PATTERN + + assert not EXTERNAL_URL_PATTERN.match("https://catbox.2137697.xyz/sperma/file.mp3") + assert not EXTERNAL_URL_PATTERN.match("some search query") + + +@pytest.mark.asyncio +async def test_play_command_calls_external_handler(play_commands_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch.object(play_commands_cog, '_handle_external_url_play', new_callable=AsyncMock) as mock_handler: + mock_handler.return_value = None + mock_defer = AsyncMock() + ctx.defer = mock_defer + ctx.respond = AsyncMock() + + await play_commands_cog.play.callback( + play_commands_cog, ctx, + melodia="https://youtube.com/watch?v=test", + file=None, compressed=False + ) + + mock_handler.assert_called_once_with(ctx, "https://youtube.com/watch?v=test") + + +@pytest.mark.asyncio +async def test_play_command_calls_local_handler(play_commands_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch.object(play_commands_cog, '_handle_local_file_play', new_callable=AsyncMock) as mock_handler: + mock_handler.return_value = None + mock_defer = AsyncMock() + ctx.defer = mock_defer + ctx.respond = AsyncMock() + + await play_commands_cog.play.callback( + play_commands_cog, ctx, + melodia="some search query", + file=None, compressed=False + ) + + mock_handler.assert_called_once_with(ctx, "some search query", False) + + +@pytest.mark.asyncio +async def test_upload_to_copyparty_async(play_commands_cog, temp_mp3_file, monkeypatch): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"fileurl": "https://example.com/file.mp3"} + + monkeypatch.setenv("COPYPARTY_URL_UPLOAD", "https://catbox.2137697.xyz/sperma/") + monkeypatch.setenv("COPYPARTY_PASSWORD", "testpassword") + + with patch('cogs.music.play_commands.requests.post', return_value=mock_response) as mock_post: + await play_commands_cog._upload_to_copyparty_async(temp_mp3_file) + mock_post.assert_called_once() -- cgit v1.2.3