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/uploader.py | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 cogs/uploader.py (limited to 'cogs/uploader.py') diff --git a/cogs/uploader.py b/cogs/uploader.py new file mode 100644 index 0000000..68a3938 --- /dev/null +++ b/cogs/uploader.py @@ -0,0 +1,154 @@ +# 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 os +import aiofiles +import requests +import urllib.parse +from typing import List, Optional +import discord +from discord.ext import commands +from discord import AutocompleteContext +from cogs.utils import file_search as search_utils +import logging + +logger = logging.getLogger(__name__) + + +class Uploader(commands.Cog): + def __init__(self, bot): + from cogs.utils.temp import get_temp_dir + self.bot = bot + self.temp_dir = get_temp_dir() + self._cached_urls = {} + self._uploadurl = os.getenv("COPYPARTY_URL_UPLOAD") + + async def _file_autocomplete(self, ctx: AutocompleteContext): + query = ctx.value.lower() if ctx.value else "" + results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) + + user_id = ctx.interaction.user.id + self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} + + choices = [] + for url in results[:25]: + filename = urllib.parse.unquote(url.split('/')[-1]) + display_name = filename[:99] + "…" if len(filename) > 100 else filename + choices.append(discord.OptionChoice(name=display_name, value=filename)) + return choices + + async def _download_from_url(self, url: str) -> Optional[str]: + """Download file from copyparty URL and return local path""" + try: + response = requests.get(url) + if response.status_code != 200: + return None + + filename = url.split('/')[-1].split('?')[0] + file_path = os.path.join(self.temp_dir, filename) + + async with aiofiles.open(file_path, 'wb') as f: + await f.write(response.content) + return file_path + except Exception as e: + logger.error(f"Error downloading from {url}: {e}") + return None + + async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: + """Upload file to copyparty and return download URL""" + try: + async with aiofiles.open(file_path, 'rb') as f: + file_content = await f.read() + + files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} + headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} + params = {'j': '1', 'ck': 'b2'} + + response = requests.post(self._uploadurl, files=files, headers=headers, params=params) + + if response.status_code in [200, 201]: + result = response.json() + return result.get('fileurl') + except Exception as e: + logger.error(f"Upload error: {e}") + return None + + 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!") + return None + + return await self._upload_to_copyparty(file_path) + + @commands.message_command( + name="upload", + description="wyślij załączniki na copyparty", + ) + async def message_upload(self, ctx, message: discord.Message): + if not message.attachments: + await ctx.respond("Brak załączników w wiadomości", ephemeral=True) + return + + await ctx.defer() + + uploaded_urls = [] + failed_count = 0 + + for attachment in message.attachments: + file_path = os.path.join(self.temp_dir, attachment.filename) + await attachment.save(file_path) + + url = await self._upload_file(file_path) + if url: + uploaded_urls.append(url) + else: + failed_count += 1 + + if os.path.exists(file_path): + os.remove(file_path) + + if uploaded_urls: + response = f"Wysłano {len(uploaded_urls)} plików:\n" + "\n".join(uploaded_urls) + if failed_count > 0: + response += f"\n\nNie wysłano {failed_count} plików" + await ctx.respond(response) + else: + await ctx.respond("Nie udało się wysłać żadnych plików", ephemeral=True) + + @commands.slash_command( + name="upload", + description="wyślij plik na copyparty", + ) + @discord.option( + name="zalacznik", + description="plik z discorda", + required=True, + type=discord.Attachment + ) + async def upload(self, ctx, zalacznik: discord.Attachment = None): + await ctx.defer() + + file_path = None + file_path = os.path.join(self.temp_dir, zalacznik.filename) + await zalacznik.save(file_path) + + if not file_path or not os.path.exists(file_path): + await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) + return + + try: + dl_url = await self._upload_file(file_path) + if dl_url: + await ctx.respond(dl_url) + else: + await ctx.respond("Nie udało się wysłać pliku", ephemeral=True) + finally: + if file_path and os.path.exists(file_path): + os.remove(file_path) + + +def setup(bot): + bot.add_cog(Uploader(bot)) -- 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/uploader.py') 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