From c687d9fa1d89fef8ddc7150d1487dfef07d11abb Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 20 Apr 2026 15:05:51 +0200 Subject: uploader.py and voice fixes Signed-off-by: salmonik --- cogs/music/audio_playback.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) (limited to 'cogs/music') diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py index 28c6118..1c715de 100644 --- a/cogs/music/audio_playback.py +++ b/cogs/music/audio_playback.py @@ -137,8 +137,16 @@ class AudioPlaybackManager: return absolute_source, display_name, is_url @staticmethod - async def execute_playback(ctx, source, display_name, is_url): - """Execute the actual audio playback.""" + async def execute_playback(ctx, source, display_name, is_url, interrupt=True): + """Execute the actual audio playback. + + Args: + ctx: Discord context + source: Audio source path or URL + display_name: Name to display + is_url: Whether source is a URL + interrupt: If True, stop any currently playing audio. If False, skip if audio is playing. + """ target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) if not target_channel: return False @@ -169,9 +177,12 @@ class AudioPlaybackManager: 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] + # Stop any currently playing audio (if interrupt is True) + if interrupt and (vc.is_playing() or vc.is_paused()): # type: ignore[union-attr] vc.stop() # type: ignore[union-attr] + elif not interrupt and vc.is_playing(): # type: ignore[union-attr] + logger.info("Audio already playing and interrupt=False, skipping playback") + return False # Prepare audio source ( @@ -232,13 +243,14 @@ class AudioPlaybackManager: return prepared_source, display_name, is_url @staticmethod - async def play(ctx, source, delete_after_play=False): + async def play(ctx, source, delete_after_play=False, interrupt=True): """Play audio from a local file or URL. Args: ctx: Discord context source: Either a local file path or a URL to stream from delete_after_play: If True, delete the file after playing (only for local files) + interrupt: If True, stop any currently playing audio. If False, skip if audio is playing. """ # Validate and prepare source ( @@ -254,7 +266,7 @@ class AudioPlaybackManager: # Execute playback success = await AudioPlaybackManager.execute_playback( - ctx, prepared_source, display_name, is_url + ctx, prepared_source, display_name, is_url, interrupt ) # Cleanup if needed -- cgit v1.2.3 From 5675dfd20b24f1e0eef06b11d9ec41a894d3a5bf Mon Sep 17 00:00:00 2001 From: salmonik Date: Tue, 28 Apr 2026 20:05:36 +0200 Subject: multitude of changes. but primarly, use db for all data. Signed-off-by: salmonik --- .env.example | 1 + cogs/assistant.py | 2 +- cogs/compression.py | 2 +- cogs/conversion.py | 6 +- cogs/cron.py | 103 +++++++++++++------- cogs/music/play_commands.py | 8 +- cogs/trigger.py | 84 +--------------- cogs/uploader.py | 9 +- cogs/utils/database.py | 111 ++++++++++++++++++++++ cogs/utils/file_search.py | 18 +++- cogs/utils/settings.py | 69 +++++++++----- cogs/vclogs.py | 70 +++++++++++--- cogs/voice.py | 22 +++++ cogs/web.py | 4 +- main.py | 3 +- pyrightconfig.json | 2 +- tests/cogs/test_cron.py | 88 +++++++---------- tests/cogs/test_uploader.py | 226 ++++++++++++++++++++++++++++++++++++++++++++ tests/cogs/test_vclogs.py | 31 +++--- tests/conftest.py | 19 ++++ 20 files changed, 642 insertions(+), 236 deletions(-) create mode 100644 cogs/utils/database.py create mode 100644 tests/cogs/test_uploader.py (limited to 'cogs/music') diff --git a/.env.example b/.env.example index 7a52a26..9623155 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,4 @@ COPYPARTY_PASSWORD= # Optional - AI commands OPENROUTER_API_KEY= +OPENROUTER_MODEL= diff --git a/cogs/assistant.py b/cogs/assistant.py index 21e4989..9926361 100644 --- a/cogs/assistant.py +++ b/cogs/assistant.py @@ -100,7 +100,7 @@ class Jarvis(commands.Cog): @staticmethod def _build_payload(messages): return { - "model": "arcee-ai/trinity-large-preview:free", + "model": os.getenv("OPENROUTER_MODEL"), "messages": messages, "response_format": Jarvis._get_response_schema(), "plugins": [{"id": "response-healing"}] diff --git a/cogs/compression.py b/cogs/compression.py index acc9eda..7171abb 100644 --- a/cogs/compression.py +++ b/cogs/compression.py @@ -38,7 +38,7 @@ class Compression(commands.Cog): self.bot = bot self.temp_dir = get_temp_dir() self._cached_urls = {} - self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() + self._baseurl = get_baseurl() async def _file_autocomplete(self, ctx: AutocompleteContext): query = ctx.value.lower() if ctx.value else "" diff --git a/cogs/conversion.py b/cogs/conversion.py index 1117caf..455bfde 100644 --- a/cogs/conversion.py +++ b/cogs/conversion.py @@ -35,11 +35,11 @@ class Conversion(commands.Cog): def __init__(self, bot): self.bot = bot from cogs.utils.temp import get_temp_dir - from cogs.utils.settings import get_baseurl + from cogs.utils.settings import get_baseurl, get_uploadurl self.temp_dir = get_temp_dir() self._cached_urls = {} - self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() - self._uploadurl = os.getenv("COPYPARTY_URL_UPLOAD") + self._baseurl = get_baseurl() + self._uploadurl = get_uploadurl() async def _file_autocomplete(self, ctx: AutocompleteContext): query = ctx.value.lower() if ctx.value else "" diff --git a/cogs/cron.py b/cogs/cron.py index 4a63e3a..b4f494a 100644 --- a/cogs/cron.py +++ b/cogs/cron.py @@ -4,9 +4,7 @@ # https://codeberg.org/salmonik/senator-sperma import asyncio -import json import logging -import os from datetime import datetime from venv import logger @@ -16,37 +14,74 @@ from discord.ext import commands from cogs.utils import file_search as search_utils from cogs.voice import get_voice_manager +from cogs.utils.database import get_connection, init_database logger = logging.getLogger(__name__) +init_database() + class Cron(commands.Cog): def __init__(self, bot): self.bot = bot self.scheduled_tasks = [] - self.cron_file = "data/cron_jobs.json" self.load_tasks() self.task = bot.loop.create_task(self.schedule_checker()) def load_tasks(self): - """Load scheduled tasks from file""" try: - if os.path.exists(self.cron_file): - with open(self.cron_file, "r") as f: - self.scheduled_tasks = json.load(f) - logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs") - else: - self.scheduled_tasks = [] + conn = get_connection() + try: + c = conn.cursor() + c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") + rows = c.fetchall() + finally: + conn.close() + + self.scheduled_tasks = [ + { + "id": row[0], + "cron_expression": row[1], + "type": row[2], + "channel_id": row[3], + "active": bool(row[4]), + "audio_filename": row[5], + "message": row[6], + } + for row in rows + ] + logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database") except Exception as e: logger.error(f"Error loading cron jobs: {e}") self.scheduled_tasks = [] def save_tasks(self): - """Save scheduled tasks to file""" try: - os.makedirs(os.path.dirname(self.cron_file), exist_ok=True) - with open(self.cron_file, "w") as f: - json.dump(self.scheduled_tasks, f, indent=2) + conn = get_connection() + try: + c = conn.cursor() + c.execute("PRAGMA foreign_keys = ON") + + c.execute("DELETE FROM cron_jobs") + + for task in self.scheduled_tasks: + c.execute( + """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ( + task.get("id"), + task["cron_expression"], + task["type"], + task["channel_id"], + int(task.get("active", True)), + task.get("audio_filename"), + task.get("message"), + ), + ) + + conn.commit() + finally: + conn.close() except Exception as e: logger.error(f"Error saving cron jobs: {e}") @@ -132,11 +167,13 @@ class Cron(commands.Cog): while voice_client.is_playing(): await asyncio.sleep(1) + logger.info("Audio playback finished normally - disconnecting") await voice_client.disconnect() logger.info(f"Finished playing audio: {audio_url}") except Exception as e: logger.error(f"Error playing audio: {e}") if voice_client.is_connected(): + logger.info("Audio playback failed - disconnecting") await voice_client.disconnect() if channel.guild.system_channel: await channel.guild.system_channel.send( @@ -176,8 +213,19 @@ class Cron(commands.Cog): else [discord.OptionChoice(name="Brak wyników", value="")] ) - @commands.slash_command( - name="cron_dodaj_audio", description="Dodaj nowe zadanie cron audio" + def _get_baseurl(self): + """Get base URL from settings""" + try: + from cogs.utils.settings import get_baseurl + + return get_baseurl() + except Exception: + return "" + + trigger_group = discord.SlashCommandGroup("cron", "komendy cron") + + @trigger_group.command( + name="dodaj_audio", description="Dodaj nowe zadanie cron audio" ) async def cron_add_audio( self, @@ -227,8 +275,8 @@ class Cron(commands.Cog): f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" ) - @commands.slash_command( - name="cron_dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" + @trigger_group.command( + name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" ) async def cron_add_message( self, @@ -272,17 +320,8 @@ class Cron(commands.Cog): f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" ) - def _get_baseurl(self): - """Get base URL from settings""" - try: - from cogs.utils.settings import get_baseurl - - return get_baseurl() - except Exception: - return "" - - @commands.slash_command( - name="cron_pokaż", description="Pokaż wszystkie zadania cron" + @trigger_group.command( + name="pokaż", description="Pokaż wszystkie zadania cron" ) async def cron_list(self, ctx): """List all cron jobs""" @@ -313,7 +352,7 @@ class Cron(commands.Cog): await ctx.respond(embed=embed) - @commands.slash_command(name="cron_usuń", description="Usuń zadanie cron z ID") + @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID") async def cron_remove( self, ctx, @@ -331,7 +370,7 @@ class Cron(commands.Cog): await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") - @commands.slash_command(name="cron_pauza", description="Zapauzuj zadanie cron z ID") + @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID") async def cron_pause( self, ctx, @@ -349,7 +388,7 @@ class Cron(commands.Cog): await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") - @commands.slash_command(name="cron_wznów", description="Wznów zadanie cron z ID") + @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID") async def cron_resume( self, ctx, diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index 5373436..c53dbc7 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py @@ -18,6 +18,7 @@ from discord.ext import commands from cogs.music import audio_playback, media_processing from cogs.utils.file_search import UniversalSearcher +from cogs.utils.settings import get_uploadurl logger = logging.getLogger(__name__) @@ -229,14 +230,13 @@ class PlayCommands(commands.Cog): try: logger.info(f"Starting async upload: {file_path}") - baseurl = os.getenv("COPYPARTY_URL_UPLOAD") + baseurl = get_uploadurl() 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 + if baseurl == "http://localhost:3939": + logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") with open(file_path, "rb") as f: content = f.read() diff --git a/cogs/trigger.py b/cogs/trigger.py index 02e5573..ce80d43 100644 --- a/cogs/trigger.py +++ b/cogs/trigger.py @@ -7,13 +7,13 @@ import os import discord from discord.ext import commands import random -import sqlite3 import logging -logger = logging.getLogger(__name__) +from cogs.utils.database import get_connection, init_database +logger = logging.getLogger(__name__) -DB_PATH = 'data/triggers.db' +init_database() _SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" _SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" @@ -22,88 +22,12 @@ _TRIGGER_NOT_FOUND_MSG = "Trigger not found." _TRIGGER_NAME_OPTION = "the name of the trigger" -def get_connection(): - return sqlite3.connect(DB_PATH) - - class Trigger(commands.Cog): def __init__(self, bot): self.bot = bot - self.db_check() trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") - @staticmethod - def db_check(): - if not os.path.exists('data'): - os.makedirs('data') - - db_exists = os.path.exists(DB_PATH) - if not db_exists: - logger.warning("Triggers database not found. Creating new database.") - - try: - conn = sqlite3.connect(DB_PATH) - c = conn.cursor() - - # Enable foreign key constraints - c.execute('PRAGMA foreign_keys = ON') - - # Create triggers table - c.execute(''' - CREATE TABLE IF NOT EXISTS triggers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )''') - - # Create trigger_keywords table with foreign key constraint - c.execute(''' - CREATE TABLE IF NOT EXISTS trigger_keywords ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trigger_id INTEGER NOT NULL, - keyword TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, - UNIQUE(trigger_id, keyword) - )''') - - # Create trigger_responses table with foreign key constraint - c.execute(''' - CREATE TABLE IF NOT EXISTS trigger_responses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trigger_id INTEGER NOT NULL, - response_text TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE - )''') - - # Create indexes for better query performance - c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)') - c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)') - c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)') - - # Run integrity check - integrity_check = c.execute('PRAGMA integrity_check').fetchone() - if integrity_check and integrity_check[0] == 'ok': - logger.info("Triggers database integrity check passed") - else: - logger.warning(f"Triggers database integrity check: {integrity_check}") - - conn.commit() - - except sqlite3.Error as e: - logger.error(f"Triggers database error: {e}") - raise - - finally: - if 'conn' in locals(): - conn.close() - - if not db_exists: - logger.info("Triggers database initialized successfully") - @commands.Cog.listener() async def on_message(self, message): if message.author == self.bot.user: @@ -130,7 +54,7 @@ class Trigger(commands.Cog): @staticmethod def get_type(name): - with sqlite3.connect(DB_PATH) as conn: + with get_connection() as conn: c = conn.cursor() c.execute("SELECT name FROM triggers") return [row[0] for row in c.fetchall()] diff --git a/cogs/uploader.py b/cogs/uploader.py index 68a3938..df6908e 100644 --- a/cogs/uploader.py +++ b/cogs/uploader.py @@ -7,11 +7,12 @@ import os import aiofiles import requests import urllib.parse -from typing import List, Optional +from typing import Optional import discord from discord.ext import commands from discord import AutocompleteContext from cogs.utils import file_search as search_utils +from cogs.utils.settings import get_uploadurl import logging logger = logging.getLogger(__name__) @@ -23,7 +24,7 @@ class Uploader(commands.Cog): self.bot = bot self.temp_dir = get_temp_dir() self._cached_urls = {} - self._uploadurl = os.getenv("COPYPARTY_URL_UPLOAD") + self._uploadurl = get_uploadurl() async def _file_autocomplete(self, ctx: AutocompleteContext): query = ctx.value.lower() if ctx.value else "" @@ -77,8 +78,8 @@ class Uploader(commands.Cog): async def _upload_file(self, file_path: str) -> Optional[str]: """Upload a file and return the URL""" - if not self._uploadurl: - logger.error("COPYPARTY_URL_UPLOAD not set!") + if self._uploadurl == "http://localhost:3939": + logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") return None return await self._upload_to_copyparty(file_path) diff --git a/cogs/utils/database.py b/cogs/utils/database.py new file mode 100644 index 0000000..67cbc9f --- /dev/null +++ b/cogs/utils/database.py @@ -0,0 +1,111 @@ +import os +import sqlite3 +import logging + +logger = logging.getLogger(__name__) + +DB_PATH = "data/senator.db" + +_initialized = False + + +def get_connection(): + return sqlite3.connect(get_db_path()) + + +def get_db_path(): + return os.getenv("SENATOR_DB_PATH", DB_PATH) + + +def set_db_path(path): + global DB_PATH + DB_PATH = path + + +def init_database(): + global _initialized + if _initialized: + return + + _initialized = True + + db_path = get_db_path() + db_dir = os.path.dirname(db_path) + if db_dir and not os.path.exists(db_dir): + os.makedirs(db_dir) + + conn = sqlite3.connect(db_path) + try: + c = conn.cursor() + + c.execute("PRAGMA foreign_keys = ON") + + c.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + )""") + + c.execute(""" + CREATE TABLE IF NOT EXISTS cron_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cron_expression TEXT NOT NULL, + type TEXT NOT NULL, + channel_id INTEGER NOT NULL, + active INTEGER DEFAULT 1, + audio_filename TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )""") + + c.execute(""" + CREATE TABLE IF NOT EXISTS voice_activity ( + user_id TEXT PRIMARY KEY, + join_time REAL, + total_active REAL DEFAULT 0, + total_afk REAL DEFAULT 0, + is_afk INTEGER DEFAULT 0, + afk_start REAL, + active_start REAL, + channel_name TEXT, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )""") + + c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)") + + c.execute(""" + CREATE TABLE IF NOT EXISTS triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )""") + + c.execute(""" + CREATE TABLE IF NOT EXISTS trigger_keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger_id INTEGER NOT NULL, + keyword TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, + UNIQUE(trigger_id, keyword) + )""") + + c.execute(""" + CREATE TABLE IF NOT EXISTS trigger_responses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger_id INTEGER NOT NULL, + response_text TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE + )""") + + c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)") + c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)") + + conn.commit() + logger.info("Database initialized successfully") + finally: + conn.close() diff --git a/cogs/utils/file_search.py b/cogs/utils/file_search.py index 8111216..788f50c 100644 --- a/cogs/utils/file_search.py +++ b/cogs/utils/file_search.py @@ -34,10 +34,22 @@ class UniversalSearcher: query = query.strip() logger.info(f"Starting copyparty search for query: '{query}'") - # Build the query with optional extension filtering - name_query = f'name like "*{query}*"' + tokens = query.split() + if not tokens: + return [] + + token_queries = [] + for token in tokens: + path_query = f'path like "*{token}*"' + name_query = f'name like "*{token}*"' + token_queries.append(f"({path_query} or {name_query})") + + if len(token_queries) == 1: + name_query = token_queries[0] + else: + name_query = " and ".join(token_queries) + if file_extensions: - # Create extension filters using copyparty's .ext$ syntax ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] ext_query = " or ".join(ext_filters) query = f"{name_query} and ({ext_query})" diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py index d66ce7f..7146b6c 100644 --- a/cogs/utils/settings.py +++ b/cogs/utils/settings.py @@ -3,16 +3,18 @@ # Copyright (C) 2026 Salmonik @ salmonik@tuta.io # https://codeberg.org/salmonik/senator-sperma -import json import logging -import aiofiles +import sqlite3 import os from dataclasses import dataclass, field +from cogs.utils.database import get_connection, init_database + logger = logging.getLogger(__name__) -settings_file = "data/settings.json" -DEFAULT_BASEURL = "http://localhost:8080" +DEFAULT_BASEURL = "http://localhost:3939" + +init_database() @dataclass @@ -28,24 +30,22 @@ class Settings: } try: - if os.path.exists(settings_file): - async with aiofiles.open(settings_file, "r") as f: - content = await f.read() - if content.strip(): - self._data = json.loads(content) - self._sync_from_data() - logger.info("Settings loaded into memory") - else: - self._data = default_settings.copy() - self._sync_from_data() - await self.save() - logger.warning("Empty settings.json, reset to defaults") + conn = get_connection() + c = conn.cursor() + c.execute("SELECT key, value FROM settings") + rows = c.fetchall() + conn.close() + + if rows: + self._data = {row[0]: row[1] for row in rows} + self._sync_from_data() + logger.info("Settings loaded from database") else: self._data = default_settings.copy() self._sync_from_data() await self.save() - logger.info("Created default settings.json") - except json.JSONDecodeError as e: + logger.info("Created default settings in database") + except Exception as e: logger.error(f"Error loading settings: {e}") self._data = default_settings.copy() self._sync_from_data() @@ -53,17 +53,28 @@ class Settings: return self def _sync_from_data(self): - self.volume = self._data.get("volume", 1.0) + try: + self.volume = float(self._data.get("volume", 1.0)) + except (ValueError, TypeError): + self.volume = 1.0 self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) def _sync_to_data(self): - self._data["volume"] = self.volume + self._data["volume"] = str(self.volume) self._data["baseurl"] = self.baseurl async def save(self): self._sync_to_data() - async with aiofiles.open(settings_file, "w") as f: - await f.write(json.dumps(self._data, indent=2)) + conn = get_connection() + c = conn.cursor() + c.execute("PRAGMA foreign_keys = ON") + for key, value in self._data.items(): + c.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + (key, value), + ) + conn.commit() + conn.close() _settings_instance: Settings | None = None @@ -90,4 +101,16 @@ def get_volume() -> float: def get_baseurl() -> str: - return _settings_instance.baseurl.rstrip("/") if _settings_instance else DEFAULT_BASEURL + url = os.getenv("COPYPARTY_URL") + if not url: + logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939") + return DEFAULT_BASEURL + return url.rstrip("/") + + +def get_uploadurl() -> str: + url = os.getenv("COPYPARTY_URL_UPLOAD") + if not url: + logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939") + return DEFAULT_BASEURL + return url.rstrip("/") diff --git a/cogs/vclogs.py b/cogs/vclogs.py index d0c4825..404ffe0 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -4,7 +4,6 @@ # https://codeberg.org/salmonik/senator-sperma import datetime -import json import logging import os import asyncio @@ -13,24 +12,69 @@ from discord.ext import commands from gtts import gTTS from cogs.voice import get_voice_manager from cogs.utils.settings import get_volume, save_settings +from cogs.utils.database import get_connection, init_database import tempfile logger = logging.getLogger(__name__) -ACTIVITY_FILE = os.path.join(tempfile.gettempdir(), 'voice_activity.json') +init_database() def load_activity_data(): - if os.path.exists(ACTIVITY_FILE): - with open(ACTIVITY_FILE, 'r') as f: - return json.load(f) - return {} + try: + conn = get_connection() + c = conn.cursor() + c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") + rows = c.fetchall() + conn.close() + + return { + row[0]: { + "join_time": row[1], + "total_active": row[2], + "total_afk": row[3], + "is_afk": bool(row[4]), + "afk_start": row[5], + "active_start": row[6], + "channel_name": row[7], + } + for row in rows + } + except Exception as e: + logger.error(f"Error loading voice activity: {e}") + return {} def save_activity_data(data): - with open(ACTIVITY_FILE, 'w') as f: - json.dump(data, f, indent=4) + try: + conn = get_connection() + c = conn.cursor() + c.execute("PRAGMA foreign_keys = ON") + + c.execute("DELETE FROM voice_activity") + + for user_id, user_data in data.items(): + c.execute( + """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + user_id, + user_data.get("join_time"), + user_data.get("total_active", 0), + user_data.get("total_afk", 0), + int(user_data.get("is_afk", False)), + user_data.get("afk_start"), + user_data.get("active_start"), + user_data.get("channel_name"), + ), + ) + + conn.commit() + conn.close() + logger.info(f"Saved voice activity for {len(data)} users") + except Exception as e: + logger.error(f"Error saving voice activity: {e}") def format_duration(seconds): @@ -168,7 +212,7 @@ class VcLogs(commands.Cog): } save_activity_data(activity_data) - message = f"[{current_time}] {member.name} dołączył na {after.channel.name}" + message = f"[{current_time}] {member.name} dołączył na {after.channel.name} " tts_text = f"{member.display_name} sie zespawnił" tts_channel = after.channel await log_channel.send(message) @@ -189,7 +233,7 @@ class VcLogs(commands.Cog): afk_str = format_duration(data['total_afk']) message = ( - f"[{current_time}] {member.name} rozłączył się z {before.channel.name}\n" + f"[{current_time}] {member.name} rozłączył się z {before.channel.name} \n" f"Aktywny: {active_str} | AFK: {afk_str}" ) tts_text = f"{member.display_name} jest chujem" @@ -200,7 +244,7 @@ class VcLogs(commands.Cog): del activity_data[user_id] save_activity_data(activity_data) else: - message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name}" + message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} " tts_text = f"{member.display_name} jest chujem" tts_channel = before.channel await log_channel.send(message) @@ -224,7 +268,7 @@ class VcLogs(commands.Cog): else: activity = "odgłuchnął" - message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name}\n" + message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} \n" f"AFK: {afk_str}" ) await log_channel.send(message) logger.info(f"User returned from AFK: {message}") @@ -244,7 +288,7 @@ class VcLogs(commands.Cog): activity = "ogłuchnął" active_str = format_duration(active_since_start) - message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name}\n" + message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} \n" f"Aktywny: {active_str}" ) await log_channel.send(message) logger.info(f"User went AFK: {message}") diff --git a/cogs/voice.py b/cogs/voice.py index 319bb16..046c2a8 100644 --- a/cogs/voice.py +++ b/cogs/voice.py @@ -45,6 +45,7 @@ class VoiceManager: return voice_client except Exception as e: logger.error(f"Failed to move voice client: {e}") + logger.info("Disconnecting after failed move attempt") try: await voice_client.disconnect() except Exception: @@ -55,6 +56,25 @@ class VoiceManager: voice_client = await channel.connect(timeout=30.0, reconnect=False) logger.info(f"Connected to voice channel: {channel}") return voice_client + except discord.ClientException as e: + if "Already connected" in str(e): + logger.warning(f"Stale voice state detected, forcing disconnect and reconnect: {e}") + voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) + if voice_client: + try: + await voice_client.disconnect() + except Exception: + pass + await asyncio.sleep(0.3) + try: + voice_client = await channel.connect(timeout=30.0, reconnect=False) + logger.info(f"Reconnected to voice channel: {channel}") + return voice_client + except Exception as e2: + logger.error(f"Failed to reconnect after stale cleanup: {e2}") + return None + logger.error(f"Failed to connect to voice channel: {e}") + return None except Exception as e: logger.error(f"Failed to connect to voice channel: {e}") return None @@ -113,6 +133,7 @@ class VoiceManager: voice_client = await self.get_voice_client(guild) if voice_client: voice_client.stop() + logger.info("stop() called - disconnecting from voice") await voice_client.disconnect() logger.info(f"Stopped and disconnected from guild {guild}") @@ -174,6 +195,7 @@ class Voice(commands.Cog): if voice_client and not voice_client.is_connected(): try: + logger.info("Voice client exists but not connected - disconnecting stale client") await voice_client.disconnect() except Exception: pass diff --git a/cogs/web.py b/cogs/web.py index 607d917..eea13b1 100644 --- a/cogs/web.py +++ b/cogs/web.py @@ -67,7 +67,7 @@ class Web(commands.Cog): logger.info(f"Query: {query}") if response.status_code != 200: - await ctx.respond(content=f"Error: {response.status_code}") + await ctx.respond(content=f"Error: {response.status_code}", ephemeral=True) logger.error(f"Error: {response.status_code}") return @@ -75,7 +75,7 @@ class Web(commands.Cog): links = soup.find_all("a", class_="result__a") if not links: - await ctx.respond(content="No results found.") + await ctx.respond(content="No results found.", ephemeral=True) logger.warning("No results found") return diff --git a/main.py b/main.py index 37a11c6..8f75ef4 100644 --- a/main.py +++ b/main.py @@ -26,7 +26,8 @@ def setup_logging(debug: bool = False): logging.basicConfig( level=level, - format="%(levelname)s - %(filename)s - %(name)s - %(message)s", + datefmt="%m-%d %H:%M:%S", + format="%(levelname)s - %(asctime)s - %(filename)s - %(name)s - %(message)s", handlers=[logging.StreamHandler(sys.stdout)], ) logging.getLogger().addFilter(DecryptionFilter()) diff --git a/pyrightconfig.json b/pyrightconfig.json index 7722ae1..38f1ed5 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,5 +1,5 @@ { - "include": ["cogs", "main.py", "cogs/utils"], + "include": ["cogs", "main.py", "cogs/utils", "tests"], "exclude": ["pycord", ".venv", "temp", "data"], "typeCheckingMode": "basic", "reportMissingTypeStubs": false, diff --git a/tests/cogs/test_cron.py b/tests/cogs/test_cron.py index 93031f8..91fcb82 100644 --- a/tests/cogs/test_cron.py +++ b/tests/cogs/test_cron.py @@ -1,5 +1,3 @@ -import json -import os import pytest from datetime import datetime from unittest.mock import MagicMock, patch @@ -19,68 +17,48 @@ def mock_bot(): def create_cog(mock_bot, tmp_path): with patch.object(Cron, "__init__", lambda self, bot: None): - cog = Cron.__new__(Cron) - cog.bot = mock_bot - cog.scheduled_tasks = [] - cog.cron_file = str(tmp_path / "cron_jobs.json") - cog.task = MagicMock() - return cog + with patch("cogs.cron.init_database"): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_conn.cursor.return_value = mock_cursor + mock_conn.close.return_value = None + + with patch("cogs.cron.get_connection", return_value=mock_conn): + with patch("cogs.utils.database.get_db_path", return_value=":memory:"): + cog = Cron.__new__(Cron) + cog.bot = mock_bot + cog.scheduled_tasks = [] + cog.task = MagicMock() + return cog class TestLoadTasks: - def test_load_tasks_file_not_exists(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) - cog.cron_file = str(tmp_path / "nonexistent.json") - cog.load_tasks() - assert cog.scheduled_tasks == [] - - def test_load_tasks_empty_file(self, mock_bot, tmp_path): - cron_file = tmp_path / "empty.json" - cron_file.write_text("") - cog = create_cog(mock_bot, tmp_path) - cog.cron_file = str(cron_file) - cog.load_tasks() - assert cog.scheduled_tasks == [] - - def test_load_tasks_valid_file(self, mock_bot, tmp_path): - cron_file = tmp_path / "valid.json" - tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] - cron_file.write_text(json.dumps(tasks)) - cog = create_cog(mock_bot, tmp_path) - cog.cron_file = str(cron_file) - cog.load_tasks() - assert len(cog.scheduled_tasks) == 1 - assert cog.scheduled_tasks[0]["id"] == 1 + def test_load_tasks_with_data(self, mock_bot, tmp_path): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [ + (1, "* * * * *", "audio", 123, 1, None, "test message") + ] + mock_conn.cursor.return_value = mock_cursor + mock_conn.close.return_value = None - def test_load_tasks_io_error(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) - with patch("builtins.open", side_effect=IOError("test error")): + with patch("cogs.cron.get_connection", return_value=mock_conn): + cog = Cron.__new__(Cron) + cog.bot = mock_bot + cog.scheduled_tasks = [] + cog.task = MagicMock() cog.load_tasks() - assert cog.scheduled_tasks == [] + assert len(cog.scheduled_tasks) == 1 + assert cog.scheduled_tasks[0]["id"] == 1 class TestSaveTasks: - def test_save_tasks_with_real_file(self, mock_bot, tmp_path): - cron_file = tmp_path / "cron_jobs.json" - cog = create_cog(mock_bot, tmp_path) - cog.cron_file = str(cron_file) - cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *"}] - cog.save_tasks() - assert os.path.exists(cron_file) - with open(cron_file) as f: - loaded = json.load(f) - assert len(loaded) == 1 - - def test_save_tasks_creates_directory_structure(self, mock_bot, tmp_path): + def test_save_tasks(self, mock_bot, tmp_path): cog = create_cog(mock_bot, tmp_path) - new_dir = tmp_path / "new_dir" - cron_file = new_dir / "cron_jobs.json" - cog.cron_file = str(cron_file) - cog.scheduled_tasks = [{"id": 1}] - + cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] cog.save_tasks() - - assert os.path.exists(new_dir) + assert len(cog.scheduled_tasks) == 1 class TestShouldRunTask: @@ -163,4 +141,4 @@ class TestTaskTypes: def test_task_type_message_aliases(self, mock_bot, tmp_path): message_types = ("wyślij_wiadomość", "send_message") for task_type in message_types: - assert task_type in ("wyślij_wiadomość", "send_message") + assert task_type in ("wyślij_wiadomość", "send_message") \ No newline at end of file diff --git a/tests/cogs/test_uploader.py b/tests/cogs/test_uploader.py new file mode 100644 index 0000000..addf87e --- /dev/null +++ b/tests/cogs/test_uploader.py @@ -0,0 +1,226 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import os +import discord +from tests.conftest import MockApplicationContext, MockMessage, MockUser + + +@pytest.fixture +def uploader_cog(bot): + with patch('cogs.utils.temp.get_temp_dir', return_value='/tmp/test'): + from cogs.uploader import Uploader + cog = Uploader(bot) + return cog + + +@pytest.mark.asyncio +async def test_file_autocomplete(uploader_cog): + mock_autocomplete_ctx = MagicMock() + mock_autocomplete_ctx.value = "test" + mock_interaction = MagicMock() + mock_user = MockUser(id=12345) + mock_interaction.user = mock_user + mock_autocomplete_ctx.interaction = mock_interaction + + with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: + mock_search.return_value = [ + "https://copyparty.local/testfile1.mp3", + "https://copyparty.local/testfile2.mp3" + ] + result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) + assert len(result) == 2 + assert result[0].name == "testfile1.mp3" + + +@pytest.mark.asyncio +async def test_file_autocomplete_empty_query(uploader_cog): + mock_autocomplete_ctx = MagicMock() + mock_autocomplete_ctx.value = "" + mock_interaction = MagicMock() + mock_interaction.user = MockUser(id=12345) + mock_autocomplete_ctx.interaction = mock_interaction + + with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: + result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) + mock_search.assert_called_once_with("", max_results=25) + + +@pytest.mark.asyncio +async def test_download_from_url_success(uploader_cog): + with patch('requests.get') as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b"test file content" + mock_get.return_value = mock_response + + mock_file = MagicMock() + mock_file.write = AsyncMock() + + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_file) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): + result = await uploader_cog._download_from_url("https://copyparty.local/file.txt") + + assert result is not None + assert "file.txt" in result + mock_file.write.assert_called_once_with(b"test file content") + + +@pytest.mark.asyncio +async def test_download_from_url_failure(uploader_cog): + with patch('requests.get') as mock_get: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + + result = await uploader_cog._download_from_url("https://copyparty.local/notfound.txt") + + assert result is None + + +@pytest.mark.asyncio +async def test_upload_to_copyparty_success(uploader_cog): + mock_file = MagicMock() + mock_file.read = AsyncMock(return_value=b"test content") + + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_file) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): + with patch('requests.post') as mock_post: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"fileurl": "https://copyparty.local/uploaded.txt"} + mock_post.return_value = mock_response + + with patch.dict(os.environ, {"COPYPARTY_PASSWORD": "testpass"}): + result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") + + assert result == "https://copyparty.local/uploaded.txt" + + +@pytest.mark.asyncio +async def test_upload_to_copyparty_failure(uploader_cog): + mock_file = MagicMock() + mock_file.read = AsyncMock(return_value=b"test content") + + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_file) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): + with patch('requests.post') as mock_post: + mock_response = MagicMock() + mock_response.status_code = 500 + mock_post.return_value = mock_response + + result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") + + assert result is None + + +@pytest.mark.asyncio +async def test_upload_file_no_url(uploader_cog): + uploader_cog._uploadurl = None + + result = await uploader_cog._upload_file("/tmp/test.txt") + + assert result is None + + +@pytest.mark.asyncio +async def test_message_upload_no_attachments(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_message = MagicMock() + mock_message.attachments = [] + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) + mock_respond.assert_called_once_with("Brak załączników w wiadomości", ephemeral=True) + + +@pytest.mark.asyncio +async def test_message_upload_success(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.filename = "test.txt" + mock_attachment.save = AsyncMock() + + mock_message = MagicMock() + mock_message.attachments = [mock_attachment] + + with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: + mock_upload.return_value = "https://copyparty.local/uploaded.txt" + + with patch('os.path.exists', return_value=True): + with patch('os.remove'): + with patch.object(ctx, 'defer', new_callable=AsyncMock): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) + mock_respond.assert_called_once() + assert "Wysłano" in str(mock_respond.call_args) + + +@pytest.mark.asyncio +async def test_message_upload_all_failed(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.filename = "test.txt" + mock_attachment.save = AsyncMock() + + mock_message = MagicMock() + mock_message.attachments = [mock_attachment] + + with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: + mock_upload.return_value = None + + with patch('os.path.exists', return_value=True): + with patch('os.remove'): + with patch.object(ctx, 'defer', new_callable=AsyncMock): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) + mock_respond.assert_called_once_with("Nie udało się wysłać żadnych plików", ephemeral=True) + + +@pytest.mark.asyncio +async def test_upload_slash_command_success(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.filename = "test.txt" + mock_attachment.save = AsyncMock() + + with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: + mock_upload.return_value = "https://copyparty.local/uploaded.txt" + + with patch('os.path.exists', return_value=True): + with patch('os.remove'): + with patch.object(ctx, 'defer', new_callable=AsyncMock): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) + mock_respond.assert_called_once_with("https://copyparty.local/uploaded.txt") + + +@pytest.mark.asyncio +async def test_upload_slash_command_failure(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.filename = "test.txt" + mock_attachment.save = AsyncMock() + + with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: + mock_upload.return_value = None + + with patch('os.path.exists', return_value=True): + with patch('os.remove'): + with patch.object(ctx, 'defer', new_callable=AsyncMock): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) + mock_respond.assert_called_once_with("Nie udało się wysłać pliku", ephemeral=True) \ No newline at end of file diff --git a/tests/cogs/test_vclogs.py b/tests/cogs/test_vclogs.py index 2003478..da41482 100644 --- a/tests/cogs/test_vclogs.py +++ b/tests/cogs/test_vclogs.py @@ -1,6 +1,5 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -import json from tests.conftest import MockApplicationContext @@ -13,20 +12,26 @@ def test_format_duration(): def test_load_activity_data(): - with patch('builtins.open', MagicMock()): - with patch('os.path.exists', return_value=True): - with patch('json.load', return_value={"123": {"active": True}}): - from cogs.vclogs import load_activity_data - data = load_activity_data() - assert isinstance(data, dict) + with patch("cogs.vclogs.get_connection") as mock_conn: + mock_cursor = MagicMock() + mock_conn.return_value.cursor.return_value = mock_cursor + mock_conn.return_value.close.return_value = None + mock_cursor.fetchall.return_value = [] + + from cogs.vclogs import load_activity_data + data = load_activity_data() + assert isinstance(data, dict) def test_save_activity_data(): - with patch('builtins.open', MagicMock()) as mock_open: - with patch('json.dump'): - from cogs.vclogs import save_activity_data - save_activity_data({"123": {"active": True}}) - mock_open.assert_called_once() + with patch("cogs.vclogs.get_connection") as mock_conn: + mock_cursor = MagicMock() + mock_conn.return_value.cursor.return_value = mock_cursor + mock_conn.return_value.commit.return_value = None + mock_conn.return_value.close.return_value = None + + from cogs.vclogs import save_activity_data + save_activity_data({"123": {"active": True}}) @pytest.fixture @@ -80,4 +85,4 @@ async def test_handle_state_change_mute_false(vclogs_cog): tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None) - assert "zrobił jajecznice" in tts_text + assert "zrobił jajecznice" in tts_text \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index e9f0f6f..01ece10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ import asyncio +import os +import tempfile from unittest.mock import AsyncMock, MagicMock import pytest @@ -6,6 +8,23 @@ import discord from discord.ext import commands +@pytest.fixture(autouse=True, scope="function") +def setup_test_env(monkeypatch): + test_db = os.path.join(tempfile.gettempdir(), "test_senator.db") + if os.path.exists(test_db): + os.remove(test_db) + monkeypatch.setenv("SENATOR_DB_PATH", test_db) + reload_database() + yield + if os.path.exists(test_db): + os.remove(test_db) + + +def reload_database(): + import cogs.utils.database as db_module + db_module._initialized = False + + class MockUser: def __init__(self, id=12345, name="testuser", discriminator="0001"): self.id = id -- cgit v1.2.3 From dba3388567818cb5c60be9d406d3e1cccc84b3ea Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 29 Apr 2026 14:22:03 +0200 Subject: Refactor database connections and add integration tests - Use context managers for sqlite3 connections in cron, vclogs, and database utils - Add URL helper utility for yt-dlp with clean_url_for_ytdlp function - Add slow integration test markers and new integration test files - Fix test imports to use conftest directly instead of tests.conftest - Improve downloader error handling and use COPYPARTY_URL_UPLOAD env var - Update pytest.ini with slow test markers and warning filters - Document slow test usage in AGENTS.md --- AGENTS.md | 5 ++ cogs/cron.py | 11 +--- cogs/downloader.py | 14 ++++- cogs/music/media_processing.py | 2 + cogs/music/play_commands.py | 2 + cogs/utils/database.py | 17 +++++- cogs/utils/url_helper.py | 21 ++++++++ cogs/vclogs.py | 55 ++++++++++---------- cogs/web.py | 2 + pytest.ini | 12 +++-- tests/cogs/test_ai.py | 2 +- tests/cogs/test_assistant.py | 2 +- tests/cogs/test_compression_conversion.py | 2 +- tests/cogs/test_cron.py | 85 +++++++++++++++--------------- tests/cogs/test_cron_integration.py | 39 ++++++++++++++ tests/cogs/test_downloader.py | 2 +- tests/cogs/test_downloader_integration.py | 37 +++++++++++++ tests/cogs/test_math_commands.py | 2 +- tests/cogs/test_music_audio.py | 2 +- tests/cogs/test_music_external_play.py | 2 +- tests/cogs/test_music_integration.py | 44 ++++++++++++++++ tests/cogs/test_music_join.py | 2 +- tests/cogs/test_music_play.py | 2 +- tests/cogs/test_qr.py | 2 +- tests/cogs/test_say.py | 2 +- tests/cogs/test_search.py | 2 +- tests/cogs/test_trigger.py | 2 +- tests/cogs/test_trigger_integration.py | 54 +++++++++++++++++++ tests/cogs/test_tts.py | 2 +- tests/cogs/test_uploader.py | 2 +- tests/cogs/test_vclogs.py | 2 +- tests/cogs/test_vclogs_integration.py | 86 +++++++++++++++++++++++++++++++ tests/cogs/test_voice.py | 2 +- tests/cogs/test_web.py | 2 +- tests/conftest.py | 3 ++ 35 files changed, 421 insertions(+), 104 deletions(-) create mode 100644 cogs/utils/url_helper.py create mode 100644 tests/cogs/test_cron_integration.py create mode 100644 tests/cogs/test_downloader_integration.py create mode 100644 tests/cogs/test_music_integration.py create mode 100644 tests/cogs/test_trigger_integration.py create mode 100644 tests/cogs/test_vclogs_integration.py (limited to 'cogs/music') diff --git a/AGENTS.md b/AGENTS.md index 44be9d5..8d0b743 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,3 +26,8 @@ - Tests in `tests/cogs/test_*.py` - Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` - `conftest.py` provides fixtures for mocking Discord objects +- **Slow integration tests** marked with `@pytest.mark.slow` + - Test real-world feature usage with longer execution times + - Includes: voice session tracking, cron job execution, music playback lifecycle, downloader flow, trigger responses + - Run only fast tests: `.venv/bin/pytest -m "not slow"` + - Run only slow tests: `.venv/bin/pytest -m slow` diff --git a/cogs/cron.py b/cogs/cron.py index b4f494a..aad43d2 100644 --- a/cogs/cron.py +++ b/cogs/cron.py @@ -30,13 +30,10 @@ class Cron(commands.Cog): def load_tasks(self): try: - conn = get_connection() - try: + with get_connection() as conn: c = conn.cursor() c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") rows = c.fetchall() - finally: - conn.close() self.scheduled_tasks = [ { @@ -57,8 +54,7 @@ class Cron(commands.Cog): def save_tasks(self): try: - conn = get_connection() - try: + with get_connection() as conn: c = conn.cursor() c.execute("PRAGMA foreign_keys = ON") @@ -78,10 +74,7 @@ class Cron(commands.Cog): task.get("message"), ), ) - conn.commit() - finally: - conn.close() except Exception as e: logger.error(f"Error saving cron jobs: {e}") diff --git a/cogs/downloader.py b/cogs/downloader.py index d829ed7..900bafb 100644 --- a/cogs/downloader.py +++ b/cogs/downloader.py @@ -12,6 +12,7 @@ import re import yt_dlp from discord.ext import commands import aiofiles +from cogs.utils.url_helper import clean_url_for_ytdlp logger = logging.getLogger(__name__) @@ -61,6 +62,10 @@ class Downloader(commands.Cog): file_data = response["content"] await ctx.respond(file=discord.File(file_data)) else: + error_msg = response["content"] + if 'details' in response: + error_msg += f"\n{response['details']}" + await ctx.respond(f"Błąd: {error_msg}", ephemeral=True) logger.error(f"Error: {response['content']}") if 'details' in response: logger.error(f"Details: {response['details']}") @@ -68,6 +73,7 @@ class Downloader(commands.Cog): @staticmethod async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): try: + url = clean_url_for_ytdlp(url) logger.info(f"Downloading {url} as {fformat}") with tempfile.TemporaryDirectory(delete=False) as temp_dir: @@ -157,12 +163,18 @@ class Downloader(commands.Cog): files = {'f': (filename, content, 'application/octet-stream')} headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} params = {'j': '1', 'ck': 'b2'} - base_url = "https://catbox.2137697.xyz/sperma/" + base_url = os.getenv("COPYPARTY_URL_UPLOAD") response = requests.post(base_url, files=files, headers=headers, params=params) if response.status_code in [200, 201]: return Downloader._handle_upload_success(response, filename, temp_dir) + elif response.status_code == 413: + return Downloader._create_error_response( + "File too large for upload", + "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)", + temp_dir + ) else: return Downloader._create_error_response( f"Server returned {response.status_code}", diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py index b9b814c..91bd406 100644 --- a/cogs/music/media_processing.py +++ b/cogs/music/media_processing.py @@ -9,6 +9,7 @@ import os import yt_dlp from youtubesearchpython import VideosSearch +from cogs.utils.url_helper import clean_url_for_ytdlp logger = logging.getLogger(__name__) @@ -36,6 +37,7 @@ class MediaDownloader: } try: + url = clean_url_for_ytdlp(url) with yt_dlp.YoutubeDL(ytdl_opts) as ydl: info_dict = ydl.extract_info(url, download=True) # Get the base filename without extension diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index c53dbc7..bdb021d 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py @@ -19,6 +19,7 @@ from discord.ext import commands from cogs.music import audio_playback, media_processing from cogs.utils.file_search import UniversalSearcher from cogs.utils.settings import get_uploadurl +from cogs.utils.url_helper import clean_url_for_ytdlp logger = logging.getLogger(__name__) @@ -193,6 +194,7 @@ class PlayCommands(commands.Cog): } loop = asyncio.get_event_loop() + url = clean_url_for_ytdlp(url) with YoutubeDL(ytdl_opts) as ydl: info_dict = await loop.run_in_executor( None, lambda: ydl.extract_info(url, download=True) diff --git a/cogs/utils/database.py b/cogs/utils/database.py index 67cbc9f..ea7d356 100644 --- a/cogs/utils/database.py +++ b/cogs/utils/database.py @@ -1,6 +1,7 @@ import os import sqlite3 import logging +from contextlib import contextmanager logger = logging.getLogger(__name__) @@ -10,7 +11,8 @@ _initialized = False def get_connection(): - return sqlite3.connect(get_db_path()) + conn = sqlite3.connect(get_db_path()) + return _ConnectionContextManager(conn) def get_db_path(): @@ -22,6 +24,19 @@ def set_db_path(path): DB_PATH = path +class _ConnectionContextManager: + """Context manager for sqlite3 connections that auto-closes.""" + def __init__(self, conn): + self.conn = conn + + def __enter__(self): + return self.conn + + def __exit__(self, exc_type, exc_val, exc_tb): + self.conn.close() + return False + + def init_database(): global _initialized if _initialized: diff --git a/cogs/utils/url_helper.py b/cogs/utils/url_helper.py new file mode 100644 index 0000000..eac06f1 --- /dev/null +++ b/cogs/utils/url_helper.py @@ -0,0 +1,21 @@ +# 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 + + +def clean_url_for_ytdlp(url: str) -> str: + """Clean URL by removing everything after the first '&'. + + This prevents yt-dlp from interpreting additional query parameters + (like &list=...) as playlist URLs. + + Example: + Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM& + Output: https://www.youtube.com/watch?v=lPGipwoJiOM + """ + # Find the first '&' and truncate + first_ampersand = url.find('&') + if first_ampersand != -1: + return url[:first_ampersand] + return url diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 404ffe0..e5ca202 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -23,11 +23,10 @@ init_database() def load_activity_data(): try: - conn = get_connection() - c = conn.cursor() - c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") - rows = c.fetchall() - conn.close() + with get_connection() as conn: + c = conn.cursor() + c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") + rows = c.fetchall() return { row[0]: { @@ -48,30 +47,28 @@ def load_activity_data(): def save_activity_data(data): try: - conn = get_connection() - c = conn.cursor() - c.execute("PRAGMA foreign_keys = ON") - - c.execute("DELETE FROM voice_activity") - - for user_id, user_data in data.items(): - c.execute( - """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - ( - user_id, - user_data.get("join_time"), - user_data.get("total_active", 0), - user_data.get("total_afk", 0), - int(user_data.get("is_afk", False)), - user_data.get("afk_start"), - user_data.get("active_start"), - user_data.get("channel_name"), - ), - ) - - conn.commit() - conn.close() + with get_connection() as conn: + c = conn.cursor() + c.execute("PRAGMA foreign_keys = ON") + + c.execute("DELETE FROM voice_activity") + + for user_id, user_data in data.items(): + c.execute( + """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + user_id, + user_data.get("join_time"), + user_data.get("total_active", 0), + user_data.get("total_afk", 0), + int(user_data.get("is_afk", False)), + user_data.get("afk_start"), + user_data.get("active_start"), + user_data.get("channel_name"), + ), + ) + conn.commit() logger.info(f"Saved voice activity for {len(data)} users") except Exception as e: logger.error(f"Error saving voice activity: {e}") diff --git a/cogs/web.py b/cogs/web.py index eea13b1..fd22f9f 100644 --- a/cogs/web.py +++ b/cogs/web.py @@ -11,6 +11,7 @@ from bs4 import BeautifulSoup import urllib.parse import yt_dlp import logging +from cogs.utils.url_helper import clean_url_for_ytdlp logger = logging.getLogger(__name__) @@ -24,6 +25,7 @@ class Web(commands.Cog): if "youtube.com/watch" not in url: return None + url = clean_url_for_ytdlp(url) logger.info("youtube link") try: ydl_opts = {"quiet": True, "nowarnings": True} diff --git a/pytest.ini b/pytest.ini index 03a070f..23e8e9c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,9 @@ [pytest] +markers = + slow: marks tests as slow (deselect with '-m "not slow"') +asyncio_mode = strict asyncio_default_fixture_loop_scope = function -python_files = test_*.py -python_classes = Test* -python_functions = test_* -testpaths = tests -pythonpath = . \ No newline at end of file +filterwarnings = + ignore::RuntimeWarning + ignore::pytest.PytestUnraisableExceptionWarning + ignore::DeprecationWarning:discord.client \ No newline at end of file diff --git a/tests/cogs/test_ai.py b/tests/cogs/test_ai.py index a1c3fd4..6d92f55 100644 --- a/tests/cogs/test_ai.py +++ b/tests/cogs/test_ai.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import discord import httpx -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_assistant.py b/tests/cogs/test_assistant.py index 9a33232..056a868 100644 --- a/tests/cogs/test_assistant.py +++ b/tests/cogs/test_assistant.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_compression_conversion.py b/tests/cogs/test_compression_conversion.py index f9d3427..892482b 100644 --- a/tests/cogs/test_compression_conversion.py +++ b/tests/cogs/test_compression_conversion.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import os import discord -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext def test_compression_filetype_video(): diff --git a/tests/cogs/test_cron.py b/tests/cogs/test_cron.py index 91fcb82..28ccfe1 100644 --- a/tests/cogs/test_cron.py +++ b/tests/cogs/test_cron.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import MagicMock, patch from cogs.cron import Cron +from cogs.utils.database import _ConnectionContextManager @pytest.fixture @@ -15,35 +16,31 @@ def mock_bot(): return bot -def create_cog(mock_bot, tmp_path): +def create_cog(mock_bot): with patch.object(Cron, "__init__", lambda self, bot: None): with patch("cogs.cron.init_database"): - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [] - mock_conn.cursor.return_value = mock_cursor - mock_conn.close.return_value = None - - with patch("cogs.cron.get_connection", return_value=mock_conn): - with patch("cogs.utils.database.get_db_path", return_value=":memory:"): - cog = Cron.__new__(Cron) - cog.bot = mock_bot - cog.scheduled_tasks = [] - cog.task = MagicMock() - return cog + cog = Cron.__new__(Cron) + cog.bot = mock_bot + cog.scheduled_tasks = [] + cog.task = MagicMock() + return cog class TestLoadTasks: - def test_load_tasks_with_data(self, mock_bot, tmp_path): + def test_load_tasks_with_data(self, mock_bot): mock_conn = MagicMock() mock_cursor = MagicMock() mock_cursor.fetchall.return_value = [ (1, "* * * * *", "audio", 123, 1, None, "test message") ] mock_conn.cursor.return_value = mock_cursor - mock_conn.close.return_value = None - with patch("cogs.cron.get_connection", return_value=mock_conn): + # Create a context manager mock + mock_ctx = MagicMock() + mock_ctx.__enter__ = MagicMock(return_value=mock_conn) + mock_ctx.__exit__ = MagicMock(return_value=False) + + with patch("cogs.cron.get_connection", return_value=mock_ctx): cog = Cron.__new__(Cron) cog.bot = mock_bot cog.scheduled_tasks = [] @@ -52,33 +49,41 @@ class TestLoadTasks: assert len(cog.scheduled_tasks) == 1 assert cog.scheduled_tasks[0]["id"] == 1 - class TestSaveTasks: - def test_save_tasks(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_save_tasks(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] - cog.save_tasks() - assert len(cog.scheduled_tasks) == 1 + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + + # Create a context manager mock + mock_ctx = MagicMock() + mock_ctx.__enter__ = MagicMock(return_value=mock_conn) + mock_ctx.__exit__ = MagicMock(return_value=False) + + with patch("cogs.cron.get_connection", return_value=mock_ctx): + cog.save_tasks() + assert len(cog.scheduled_tasks) == 1 class TestShouldRunTask: - def test_should_run_task_invalid_cron(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_should_run_task_invalid_cron(self, mock_bot): + cog = create_cog(mock_bot) task = {"cron_expression": "invalid"} now = datetime.now() result = cog.should_run_task(task, now) assert result is False - def test_should_run_task_missing_cron_expression(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_should_run_task_missing_cron_expression(self, mock_bot): + cog = create_cog(mock_bot) task = {} now = datetime.now() result = cog.should_run_task(task, now) assert result is False - class TestCogUnload: - def test_cog_unload_cancels_task(self, mock_bot, tmp_path): + def test_cog_unload_cancels_task(self, mock_bot): with patch.object(Cron, "__init__", lambda self, bot: None): cog = Cron.__new__(Cron) cog.bot = mock_bot @@ -87,10 +92,9 @@ class TestCogUnload: cog.cog_unload() cog.task.cancel.assert_called_once() - class TestTaskManagement: - def test_add_task_increments_id(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_add_task_increments_id(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [ {"id": 1, "cron_expression": "* * * * *"}, {"id": 2, "cron_expression": "0 * * * *"}, @@ -99,8 +103,8 @@ class TestTaskManagement: new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1 assert new_id == 3 - def test_find_task_by_id(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_find_task_by_id(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [ {"id": 1, "cron_expression": "* * * * *"}, {"id": 2, "cron_expression": "0 * * * *"}, @@ -110,8 +114,8 @@ class TestTaskManagement: assert found_task is not None assert found_task["cron_expression"] == "0 * * * *" - def test_remove_task_by_index(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_remove_task_by_index(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [ {"id": 1, "cron_expression": "* * * * *"}, {"id": 2, "cron_expression": "0 * * * *"}, @@ -121,8 +125,8 @@ class TestTaskManagement: assert len(cog.scheduled_tasks) == 1 assert cog.scheduled_tasks[0]["id"] == 2 - def test_toggle_task_active(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_toggle_task_active(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [{"id": 1, "active": True}] cog.scheduled_tasks[0]["active"] = False @@ -131,14 +135,13 @@ class TestTaskManagement: cog.scheduled_tasks[0]["active"] = True assert cog.scheduled_tasks[0]["active"] is True - class TestTaskTypes: - def test_task_type_audio_aliases(self, mock_bot, tmp_path): + def test_task_type_audio_aliases(self, mock_bot): audio_types = ("zagraj_audio", "audio") for task_type in audio_types: assert task_type in ("zagraj_audio", "audio") - def test_task_type_message_aliases(self, mock_bot, tmp_path): + def test_task_type_message_aliases(self, mock_bot): message_types = ("wyślij_wiadomość", "send_message") for task_type in message_types: - assert task_type in ("wyślij_wiadomość", "send_message") \ No newline at end of file + assert task_type in ("wyślij_wiadomość", "send_message") diff --git a/tests/cogs/test_cron_integration.py b/tests/cogs/test_cron_integration.py new file mode 100644 index 0000000..f56fc46 --- /dev/null +++ b/tests/cogs/test_cron_integration.py @@ -0,0 +1,39 @@ +import pytest +import time +from unittest.mock import MagicMock, AsyncMock, patch + + +@pytest.fixture +def cron_cog(bot): + # Patch create_task to prevent background task creation + with patch.object(bot.loop, 'create_task', return_value=MagicMock()): + from cogs.cron import Cron + cog = Cron(bot) + yield cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_cron_job_execution(cron_cog): + """Test cron job executes when due.""" + cog = cron_cog + + job = { + "id": "test_job", + "cron_expression": "* * * * *", + "type": "message", + "channel_id": 456, + "active": True, + "message": "Test", + } + cog.scheduled_tasks.append(job) + + # Mock the execute_task method + with patch.object(cog, 'execute_task', new_callable=AsyncMock) as mock_exec: + # Mock should_run_task to return True + with patch.object(cog, 'should_run_task', return_value=True): + # Test single iteration of schedule_checker logic + for task in cog.scheduled_tasks: + if task.get("active", True) and cog.should_run_task(task, None): + await cog.execute_task(task) + mock_exec.assert_called_once_with(job) \ No newline at end of file diff --git a/tests/cogs/test_downloader.py b/tests/cogs/test_downloader.py index 6ac8ae7..125a103 100644 --- a/tests/cogs/test_downloader.py +++ b/tests/cogs/test_downloader.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import discord -from tests.conftest import MockApplicationContext, MockMessage +from conftest import MockApplicationContext, MockMessage @pytest.fixture diff --git a/tests/cogs/test_downloader_integration.py b/tests/cogs/test_downloader_integration.py new file mode 100644 index 0000000..c7c9790 --- /dev/null +++ b/tests/cogs/test_downloader_integration.py @@ -0,0 +1,37 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, patch + + +@pytest.fixture +def downloader_cog(bot): + from cogs.downloader import Downloader + cog = Downloader(bot) + return cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_download_command_flow(downloader_cog, bot): + """Test download command with URL processing.""" + cog = downloader_cog + + ctx = MagicMock() + ctx.author = MagicMock() + ctx.guild = MagicMock() + ctx.channel = MagicMock() + ctx.respond = AsyncMock() + ctx.defer = AsyncMock() + ctx.followup = AsyncMock() + ctx.voice_client = None + + # Mock the download_standalone static method + mock_response = { + "status": "success", + "type": "url", + "content": "http://example.com/video.mp4" + } + + with patch.object(cog, 'download_standalone', new_callable=AsyncMock, return_value=mock_response): + await cog.download.callback(cog, ctx, url="http://youtube.com/test") + + ctx.respond.assert_called_once() diff --git a/tests/cogs/test_math_commands.py b/tests/cogs/test_math_commands.py index 5c78027..21e9a2e 100644 --- a/tests/cogs/test_math_commands.py +++ b/tests/cogs/test_math_commands.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_music_audio.py b/tests/cogs/test_music_audio.py index 7ea902e..705f1b3 100644 --- a/tests/cogs/test_music_audio.py +++ b/tests/cogs/test_music_audio.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import os -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.mark.asyncio diff --git a/tests/cogs/test_music_external_play.py b/tests/cogs/test_music_external_play.py index fd4b625..2e9c5b1 100644 --- a/tests/cogs/test_music_external_play.py +++ b/tests/cogs/test_music_external_play.py @@ -3,7 +3,7 @@ import os import asyncio import tempfile from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_music_integration.py b/tests/cogs/test_music_integration.py new file mode 100644 index 0000000..1c0260a --- /dev/null +++ b/tests/cogs/test_music_integration.py @@ -0,0 +1,44 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock +import sys +sys.path.insert(0, '/home/salmon/Documents/botcode/senator-sperma/tests') +from conftest import MockApplicationContext + + +@pytest.fixture +def music_cog(bot): + from cogs.music.play_commands import PlayCommands + cog = PlayCommands(bot) + return cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_music_join_stop(music_cog, bot): + """Test music join and stop commands.""" + from cogs.music.join import Join + from cogs.music.stop import Stop + + join_cog = Join(bot) + stop_cog = Stop(bot) + + ctx = MockApplicationContext(bot=bot) + ctx.author.voice = MagicMock() + ctx.author.voice.channel = MagicMock() + ctx.guild = MagicMock() + + voice_client = AsyncMock() + ctx.voice_client = voice_client + + # Test join + ctx.author.voice.channel.connect = AsyncMock(return_value=voice_client) + with patch.object(ctx, 'respond', new_callable=AsyncMock): + with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): + await join_cog.join.callback(join_cog, ctx) + ctx.author.voice.channel.connect.assert_called_once() + + # Test stop + with patch.object(ctx, 'respond', new_callable=AsyncMock): + with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): + await stop_cog.stop.callback(stop_cog, ctx) + voice_client.disconnect.assert_called_once() diff --git a/tests/cogs/test_music_join.py b/tests/cogs/test_music_join.py index 3c7ca6e..0176759 100644 --- a/tests/cogs/test_music_join.py +++ b/tests/cogs/test_music_join.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_music_play.py b/tests/cogs/test_music_play.py index e720c9a..7684729 100644 --- a/tests/cogs/test_music_play.py +++ b/tests/cogs/test_music_play.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import os -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_qr.py b/tests/cogs/test_qr.py index 2a132ca..b5cb31f 100644 --- a/tests/cogs/test_qr.py +++ b/tests/cogs/test_qr.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock, mock_open import os from cogs.qr import QR -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_say.py b/tests/cogs/test_say.py index 13cad40..76f1b94 100644 --- a/tests/cogs/test_say.py +++ b/tests/cogs/test_say.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch from cogs.say import Say -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_search.py b/tests/cogs/test_search.py index 8c04dfc..c9c7d0e 100644 --- a/tests/cogs/test_search.py +++ b/tests/cogs/test_search.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import discord -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_trigger.py b/tests/cogs/test_trigger.py index 6aaaa11..a60a139 100644 --- a/tests/cogs/test_trigger.py +++ b/tests/cogs/test_trigger.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import sqlite3 -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_trigger_integration.py b/tests/cogs/test_trigger_integration.py new file mode 100644 index 0000000..4828387 --- /dev/null +++ b/tests/cogs/test_trigger_integration.py @@ -0,0 +1,54 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from cogs.trigger import Trigger + + +@pytest.fixture +def trigger_cog(bot): + from cogs.utils.database import init_database + init_database() + from cogs.trigger import Trigger + cog = Trigger(bot) + return cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_trigger_response_flow(trigger_cog): + """Test trigger creation and response in a simulated message flow.""" + cog = trigger_cog + + ctx = MagicMock() + ctx.author = MagicMock() + ctx.author.id = 123 + ctx.guild = MagicMock() + ctx.guild.id = 456 + ctx.respond = AsyncMock() + + # Create a trigger using the create command + await cog.create.callback(cog, ctx, name="test_trigger") + + # Add a keyword and response + await cog.add.callback(cog, ctx, name="test_trigger", trigger="hello", response="world") + + # Verify trigger was created - check DB + from cogs.utils.database import get_connection + with get_connection() as conn: + c = conn.cursor() + c.execute("SELECT id FROM triggers WHERE name=?", ("test_trigger",)) + result = c.fetchone() + assert result is not None + + # Simulate a message that should trigger + message = MagicMock() + message.author = ctx.author + message.author.bot = False + message.guild = ctx.guild + message.content = "say hello to me" + message.channel.send = AsyncMock() # Make send awaitable + + # Check if trigger matches using the static method + await Trigger.trigger(message) + + # Verify message.channel.send was called with the response + message.channel.send.assert_called_once_with("world") diff --git a/tests/cogs/test_tts.py b/tests/cogs/test_tts.py index de68580..c6563e6 100644 --- a/tests/cogs/test_tts.py +++ b/tests/cogs/test_tts.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import discord -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_uploader.py b/tests/cogs/test_uploader.py index addf87e..0777126 100644 --- a/tests/cogs/test_uploader.py +++ b/tests/cogs/test_uploader.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import os import discord -from tests.conftest import MockApplicationContext, MockMessage, MockUser +from conftest import MockApplicationContext, MockMessage, MockUser @pytest.fixture diff --git a/tests/cogs/test_vclogs.py b/tests/cogs/test_vclogs.py index da41482..17ff839 100644 --- a/tests/cogs/test_vclogs.py +++ b/tests/cogs/test_vclogs.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext def test_format_duration(): diff --git a/tests/cogs/test_vclogs_integration.py b/tests/cogs/test_vclogs_integration.py new file mode 100644 index 0000000..9391876 --- /dev/null +++ b/tests/cogs/test_vclogs_integration.py @@ -0,0 +1,86 @@ +import pytest +import asyncio +from unittest.mock import MagicMock, AsyncMock, patch + + +@pytest.fixture +def vclogs_cog(bot): + from cogs.vclogs import VcLogs + cog = VcLogs(bot) + return cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_full_voice_session_tracking(vclogs_cog, monkeypatch): + """Test complete voice session with AFK transitions using real time sleeps.""" + cog = vclogs_cog + + # Set up environment + monkeypatch.setenv("STATUS_CHANNEL_ID", "123456") + + member = MagicMock() + member.id = 123 + member.guild.id = 456 + member.bot = False + member.display_name = "TestUser" + member.guild.get_member.return_value = member + + # Create a mock log channel + log_channel = MagicMock() + log_channel.send = AsyncMock(return_value=MagicMock()) + + # Mock bot.get_channel to return our log channel + with patch.object(vclogs_cog.bot, 'get_channel', return_value=log_channel): + # Join voice + before = MagicMock() + before.channel = None + after_join = MagicMock() + after_join.channel = MagicMock() + after_join.channel.id = 789 + after_join.channel.name = "Test Voice" + after_join.self_deaf = False + after_join.self_mute = False + after_join.deaf = False + after_join.mute = False + await cog.on_voice_state_update(member, before, after_join) + + await asyncio.sleep(2) # Simulate 2s active + + # Go AFK (self_deaf) + before_afk = after_join + after_afk = MagicMock() + after_afk.channel = before_afk.channel + after_afk.self_deaf = True + after_afk.self_mute = False + after_afk.deaf = True + after_afk.mute = False + await cog.on_voice_state_update(member, before_afk, after_afk) + + await asyncio.sleep(3) # Simulate 3s AFK + + # Return from AFK + before_back = after_afk + after_back = MagicMock() + after_back.channel = before_back.channel + after_back.self_deaf = False + after_back.self_mute = False + after_back.deaf = False + after_back.mute = False + await cog.on_voice_state_update(member, before_back, after_back) + + await asyncio.sleep(1) # Simulate 1s active + + # Leave voice + before_leave = after_back + after_leave = MagicMock() + after_leave.channel = None + after_leave.self_deaf = False + after_leave.self_mute = False + await cog.on_voice_state_update(member, before_leave, after_leave) + + # Verify activity was saved (check DB) + from cogs.vclogs import load_activity_data + activity_data = load_activity_data() + # After leaving, data should be deleted, so check the log channel was called + assert log_channel.send.called diff --git a/tests/cogs/test_voice.py b/tests/cogs/test_voice.py index ade29cb..104eea6 100644 --- a/tests/cogs/test_voice.py +++ b/tests/cogs/test_voice.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import asyncio -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.mark.asyncio diff --git a/tests/cogs/test_web.py b/tests/cogs/test_web.py index e0d2208..4de2e8b 100644 --- a/tests/cogs/test_web.py +++ b/tests/cogs/test_web.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock from cogs.web import Web -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/conftest.py b/tests/conftest.py index 01ece10..6b7c3bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import asyncio import os +import sys import tempfile from unittest.mock import AsyncMock, MagicMock import pytest @@ -7,6 +8,8 @@ import pytest import discord from discord.ext import commands +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + @pytest.fixture(autouse=True, scope="function") def setup_test_env(monkeypatch): -- cgit v1.2.3 From 0e4c707b2b11b4ec70a000e7a9d3108030fc0c61 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 29 Apr 2026 14:43:08 +0200 Subject: damn short files Signed-off-by: salmonik --- cogs/music/audio_playback.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) (limited to 'cogs/music') diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py index 1c715de..1313d01 100644 --- a/cogs/music/audio_playback.py +++ b/cogs/music/audio_playback.py @@ -192,13 +192,14 @@ class AudioPlaybackManager: source, is_url, display_name ) - # Track if playback failed - playback_failed = {"error": None} + # Track playback state + playback_state = {"error": None, "finished": False} def after_playing(error): if error: - playback_failed["error"] = str(error) + playback_state["error"] = str(error) logger.error(f"Playback error: {error}") + playback_state["finished"] = True # Play the audio vc.play(audio_source, after=after_playing) @@ -208,14 +209,19 @@ class AudioPlaybackManager: # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds await asyncio.sleep(5) - # Check if playback actually started - if playback_failed["error"]: - logger.error(f"Playback failed: {playback_failed['error']}") + # Check if playback failed to start (not just finished quickly) + if playback_state["error"]: + logger.error(f"Playback failed: {playback_state['error']}") await ctx.followup.send( "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True ) return False + # If playback finished within 5s, it was a short file - not an error + if playback_state["finished"]: + logger.info(f"Playback completed quickly: {final_display_name}") + return True + if not vc.is_playing(): logger.error(f"Stream failed - not playing after 5s: {source}") await ctx.followup.send( -- cgit v1.2.3