diff options
| author | salmonik <raisersoft@proton.me> | 2026-04-29 14:47:32 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-04-29 14:47:32 +0200 |
| commit | 8a4352ebb5e15ff4be82a9b4e111c1535aab349e (patch) | |
| tree | 2c4d5b4b65bdc8308b35c411cffa7f17b1bfeb4d | |
| parent | fix: fallback to plaintext if no json is provided for assistant.py (diff) | |
| parent | proper context for settings (diff) | |
| download | senator-sperma-8a4352ebb5e15ff4be82a9b4e111c1535aab349e.tar.gz senator-sperma-8a4352ebb5e15ff4be82a9b4e111c1535aab349e.zip | |
Merge branch 'dev'
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to '')
48 files changed, 1202 insertions, 310 deletions
diff --git a/.env.example b/.env.example index 7a52a26..9623155 100644 --- a/.env.example +++ b/.env.example | |||
| @@ -12,3 +12,4 @@ COPYPARTY_PASSWORD= | |||
| 12 | 12 | ||
| 13 | # Optional - AI commands | 13 | # Optional - AI commands |
| 14 | OPENROUTER_API_KEY= | 14 | OPENROUTER_API_KEY= |
| 15 | OPENROUTER_MODEL= | ||
| @@ -26,3 +26,8 @@ | |||
| 26 | - Tests in `tests/cogs/test_*.py` | 26 | - Tests in `tests/cogs/test_*.py` |
| 27 | - Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` | 27 | - Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` |
| 28 | - `conftest.py` provides fixtures for mocking Discord objects | 28 | - `conftest.py` provides fixtures for mocking Discord objects |
| 29 | - **Slow integration tests** marked with `@pytest.mark.slow` | ||
| 30 | - Test real-world feature usage with longer execution times | ||
| 31 | - Includes: voice session tracking, cron job execution, music playback lifecycle, downloader flow, trigger responses | ||
| 32 | - Run only fast tests: `.venv/bin/pytest -m "not slow"` | ||
| 33 | - Run only slow tests: `.venv/bin/pytest -m slow` | ||
| @@ -66,7 +66,7 @@ class AI(commands.Cog): | |||
| 66 | tts_buffer = io.BytesIO() | 66 | tts_buffer = io.BytesIO() |
| 67 | tts_obj.write_to_fp(tts_buffer) | 67 | tts_obj.write_to_fp(tts_buffer) |
| 68 | tts_buffer.seek(0) | 68 | tts_buffer.seek(0) |
| 69 | await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) | 69 | await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True, interrupt=False) |
| 70 | 70 | ||
| 71 | else: | 71 | else: |
| 72 | await ctx.send_followup(response_text) | 72 | await ctx.send_followup(response_text) |
diff --git a/cogs/assistant.py b/cogs/assistant.py index a89c356..a91d027 100644 --- a/cogs/assistant.py +++ b/cogs/assistant.py | |||
| @@ -16,8 +16,6 @@ from cogs.downloader import Downloader | |||
| 16 | 16 | ||
| 17 | logger = logging.getLogger(__name__) | 17 | logger = logging.getLogger(__name__) |
| 18 | 18 | ||
| 19 | AI_MODEL = "z-ai/glm-4.5-air:free" | ||
| 20 | |||
| 21 | class Jarvis(commands.Cog): | 19 | class Jarvis(commands.Cog): |
| 22 | def __init__(self, bot): | 20 | def __init__(self, bot): |
| 23 | self.bot = bot | 21 | self.bot = bot |
| @@ -102,7 +100,7 @@ class Jarvis(commands.Cog): | |||
| 102 | @staticmethod | 100 | @staticmethod |
| 103 | def _build_payload(messages): | 101 | def _build_payload(messages): |
| 104 | return { | 102 | return { |
| 105 | "model": AI_MODEL, | 103 | "model": os.getenv("OPENROUTER_MODEL"), |
| 106 | "messages": messages, | 104 | "messages": messages, |
| 107 | "response_format": Jarvis._get_response_schema(), | 105 | "response_format": Jarvis._get_response_schema(), |
| 108 | "plugins": [{"id": "response-healing"}] | 106 | "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): | |||
| 38 | self.bot = bot | 38 | self.bot = bot |
| 39 | self.temp_dir = get_temp_dir() | 39 | self.temp_dir = get_temp_dir() |
| 40 | self._cached_urls = {} | 40 | self._cached_urls = {} |
| 41 | self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() | 41 | self._baseurl = get_baseurl() |
| 42 | 42 | ||
| 43 | async def _file_autocomplete(self, ctx: AutocompleteContext): | 43 | async def _file_autocomplete(self, ctx: AutocompleteContext): |
| 44 | query = ctx.value.lower() if ctx.value else "" | 44 | 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): | |||
| 35 | def __init__(self, bot): | 35 | def __init__(self, bot): |
| 36 | self.bot = bot | 36 | self.bot = bot |
| 37 | from cogs.utils.temp import get_temp_dir | 37 | from cogs.utils.temp import get_temp_dir |
| 38 | from cogs.utils.settings import get_baseurl | 38 | from cogs.utils.settings import get_baseurl, get_uploadurl |
| 39 | self.temp_dir = get_temp_dir() | 39 | self.temp_dir = get_temp_dir() |
| 40 | self._cached_urls = {} | 40 | self._cached_urls = {} |
| 41 | self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() | 41 | self._baseurl = get_baseurl() |
| 42 | self._uploadurl = os.getenv("COPYPARTY_URL_UPLOAD") | 42 | self._uploadurl = get_uploadurl() |
| 43 | 43 | ||
| 44 | async def _file_autocomplete(self, ctx: AutocompleteContext): | 44 | async def _file_autocomplete(self, ctx: AutocompleteContext): |
| 45 | query = ctx.value.lower() if ctx.value else "" | 45 | query = ctx.value.lower() if ctx.value else "" |
diff --git a/cogs/cron.py b/cogs/cron.py index da8127b..aad43d2 100644 --- a/cogs/cron.py +++ b/cogs/cron.py | |||
| @@ -4,9 +4,7 @@ | |||
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import asyncio | 6 | import asyncio |
| 7 | import json | ||
| 8 | import logging | 7 | import logging |
| 9 | import os | ||
| 10 | from datetime import datetime | 8 | from datetime import datetime |
| 11 | from venv import logger | 9 | from venv import logger |
| 12 | 10 | ||
| @@ -16,37 +14,67 @@ from discord.ext import commands | |||
| 16 | 14 | ||
| 17 | from cogs.utils import file_search as search_utils | 15 | from cogs.utils import file_search as search_utils |
| 18 | from cogs.voice import get_voice_manager | 16 | from cogs.voice import get_voice_manager |
| 17 | from cogs.utils.database import get_connection, init_database | ||
| 19 | 18 | ||
| 20 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| 21 | 20 | ||
| 21 | init_database() | ||
| 22 | |||
| 22 | 23 | ||
| 23 | class Cron(commands.Cog): | 24 | class Cron(commands.Cog): |
| 24 | def __init__(self, bot): | 25 | def __init__(self, bot): |
| 25 | self.bot = bot | 26 | self.bot = bot |
| 26 | self.scheduled_tasks = [] | 27 | self.scheduled_tasks = [] |
| 27 | self.cron_file = "data/cron_jobs.json" | ||
| 28 | self.load_tasks() | 28 | self.load_tasks() |
| 29 | self.task = bot.loop.create_task(self.schedule_checker()) | 29 | self.task = bot.loop.create_task(self.schedule_checker()) |
| 30 | 30 | ||
| 31 | def load_tasks(self): | 31 | def load_tasks(self): |
| 32 | """Load scheduled tasks from file""" | ||
| 33 | try: | 32 | try: |
| 34 | if os.path.exists(self.cron_file): | 33 | with get_connection() as conn: |
| 35 | with open(self.cron_file, "r") as f: | 34 | c = conn.cursor() |
| 36 | self.scheduled_tasks = json.load(f) | 35 | c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") |
| 37 | logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs") | 36 | rows = c.fetchall() |
| 38 | else: | 37 | |
| 39 | self.scheduled_tasks = [] | 38 | self.scheduled_tasks = [ |
| 39 | { | ||
| 40 | "id": row[0], | ||
| 41 | "cron_expression": row[1], | ||
| 42 | "type": row[2], | ||
| 43 | "channel_id": row[3], | ||
| 44 | "active": bool(row[4]), | ||
| 45 | "audio_filename": row[5], | ||
| 46 | "message": row[6], | ||
| 47 | } | ||
| 48 | for row in rows | ||
| 49 | ] | ||
| 50 | logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database") | ||
| 40 | except Exception as e: | 51 | except Exception as e: |
| 41 | logger.error(f"Error loading cron jobs: {e}") | 52 | logger.error(f"Error loading cron jobs: {e}") |
| 42 | self.scheduled_tasks = [] | 53 | self.scheduled_tasks = [] |
| 43 | 54 | ||
| 44 | def save_tasks(self): | 55 | def save_tasks(self): |
| 45 | """Save scheduled tasks to file""" | ||
| 46 | try: | 56 | try: |
| 47 | os.makedirs(os.path.dirname(self.cron_file), exist_ok=True) | 57 | with get_connection() as conn: |
| 48 | with open(self.cron_file, "w") as f: | 58 | c = conn.cursor() |
| 49 | json.dump(self.scheduled_tasks, f, indent=2) | 59 | c.execute("PRAGMA foreign_keys = ON") |
| 60 | |||
| 61 | c.execute("DELETE FROM cron_jobs") | ||
| 62 | |||
| 63 | for task in self.scheduled_tasks: | ||
| 64 | c.execute( | ||
| 65 | """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message) | ||
| 66 | VALUES (?, ?, ?, ?, ?, ?, ?)""", | ||
| 67 | ( | ||
| 68 | task.get("id"), | ||
| 69 | task["cron_expression"], | ||
| 70 | task["type"], | ||
| 71 | task["channel_id"], | ||
| 72 | int(task.get("active", True)), | ||
| 73 | task.get("audio_filename"), | ||
| 74 | task.get("message"), | ||
| 75 | ), | ||
| 76 | ) | ||
| 77 | conn.commit() | ||
| 50 | except Exception as e: | 78 | except Exception as e: |
| 51 | logger.error(f"Error saving cron jobs: {e}") | 79 | logger.error(f"Error saving cron jobs: {e}") |
| 52 | 80 | ||
| @@ -104,7 +132,7 @@ class Cron(commands.Cog): | |||
| 104 | 132 | ||
| 105 | if task_type in ("zagraj_audio", "audio"): | 133 | if task_type in ("zagraj_audio", "audio"): |
| 106 | await self.play_audio_task(task, channel) | 134 | await self.play_audio_task(task, channel) |
| 107 | elif task_type in ("wyślij_wiadomość", "send_message"): | 135 | elif task_type in ("wyślij_wiadomość", "send_message", "wiadomość"): |
| 108 | await self.send_message_task(task, channel) | 136 | await self.send_message_task(task, channel) |
| 109 | 137 | ||
| 110 | logger.info(f"Executed task {task['id']} of type {task_type}") | 138 | logger.info(f"Executed task {task['id']} of type {task_type}") |
| @@ -132,11 +160,13 @@ class Cron(commands.Cog): | |||
| 132 | while voice_client.is_playing(): | 160 | while voice_client.is_playing(): |
| 133 | await asyncio.sleep(1) | 161 | await asyncio.sleep(1) |
| 134 | 162 | ||
| 163 | logger.info("Audio playback finished normally - disconnecting") | ||
| 135 | await voice_client.disconnect() | 164 | await voice_client.disconnect() |
| 136 | logger.info(f"Finished playing audio: {audio_url}") | 165 | logger.info(f"Finished playing audio: {audio_url}") |
| 137 | except Exception as e: | 166 | except Exception as e: |
| 138 | logger.error(f"Error playing audio: {e}") | 167 | logger.error(f"Error playing audio: {e}") |
| 139 | if voice_client.is_connected(): | 168 | if voice_client.is_connected(): |
| 169 | logger.info("Audio playback failed - disconnecting") | ||
| 140 | await voice_client.disconnect() | 170 | await voice_client.disconnect() |
| 141 | if channel.guild.system_channel: | 171 | if channel.guild.system_channel: |
| 142 | await channel.guild.system_channel.send( | 172 | await channel.guild.system_channel.send( |
| @@ -176,8 +206,19 @@ class Cron(commands.Cog): | |||
| 176 | else [discord.OptionChoice(name="Brak wyników", value="")] | 206 | else [discord.OptionChoice(name="Brak wyników", value="")] |
| 177 | ) | 207 | ) |
| 178 | 208 | ||
| 179 | @commands.slash_command( | 209 | def _get_baseurl(self): |
| 180 | name="cron_dodaj_audio", description="Dodaj nowe zadanie cron audio" | 210 | """Get base URL from settings""" |
| 211 | try: | ||
| 212 | from cogs.utils.settings import get_baseurl | ||
| 213 | |||
| 214 | return get_baseurl() | ||
| 215 | except Exception: | ||
| 216 | return "" | ||
| 217 | |||
| 218 | trigger_group = discord.SlashCommandGroup("cron", "komendy cron") | ||
| 219 | |||
| 220 | @trigger_group.command( | ||
| 221 | name="dodaj_audio", description="Dodaj nowe zadanie cron audio" | ||
| 181 | ) | 222 | ) |
| 182 | async def cron_add_audio( | 223 | async def cron_add_audio( |
| 183 | self, | 224 | self, |
| @@ -227,8 +268,8 @@ class Cron(commands.Cog): | |||
| 227 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" | 268 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" |
| 228 | ) | 269 | ) |
| 229 | 270 | ||
| 230 | @commands.slash_command( | 271 | @trigger_group.command( |
| 231 | name="cron_dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" | 272 | name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" |
| 232 | ) | 273 | ) |
| 233 | async def cron_add_message( | 274 | async def cron_add_message( |
| 234 | self, | 275 | self, |
| @@ -272,17 +313,8 @@ class Cron(commands.Cog): | |||
| 272 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" | 313 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" |
| 273 | ) | 314 | ) |
| 274 | 315 | ||
| 275 | def _get_baseurl(self): | 316 | @trigger_group.command( |
| 276 | """Get base URL from settings""" | 317 | name="pokaż", description="Pokaż wszystkie zadania cron" |
| 277 | try: | ||
| 278 | from cogs.utils.settings import get_baseurl | ||
| 279 | |||
| 280 | return get_baseurl() | ||
| 281 | except Exception: | ||
| 282 | return "" | ||
| 283 | |||
| 284 | @commands.slash_command( | ||
| 285 | name="cron_pokaż", description="Pokaż wszystkie zadania cron" | ||
| 286 | ) | 318 | ) |
| 287 | async def cron_list(self, ctx): | 319 | async def cron_list(self, ctx): |
| 288 | """List all cron jobs""" | 320 | """List all cron jobs""" |
| @@ -313,7 +345,7 @@ class Cron(commands.Cog): | |||
| 313 | 345 | ||
| 314 | await ctx.respond(embed=embed) | 346 | await ctx.respond(embed=embed) |
| 315 | 347 | ||
| 316 | @commands.slash_command(name="cron_usuń", description="Usuń zadanie cron z ID") | 348 | @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID") |
| 317 | async def cron_remove( | 349 | async def cron_remove( |
| 318 | self, | 350 | self, |
| 319 | ctx, | 351 | ctx, |
| @@ -331,7 +363,7 @@ class Cron(commands.Cog): | |||
| 331 | 363 | ||
| 332 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | 364 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") |
| 333 | 365 | ||
| 334 | @commands.slash_command(name="cron_pauza", description="Zapauzuj zadanie cron z ID") | 366 | @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID") |
| 335 | async def cron_pause( | 367 | async def cron_pause( |
| 336 | self, | 368 | self, |
| 337 | ctx, | 369 | ctx, |
| @@ -349,7 +381,7 @@ class Cron(commands.Cog): | |||
| 349 | 381 | ||
| 350 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | 382 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") |
| 351 | 383 | ||
| 352 | @commands.slash_command(name="cron_wznów", description="Wznów zadanie cron z ID") | 384 | @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID") |
| 353 | async def cron_resume( | 385 | async def cron_resume( |
| 354 | self, | 386 | self, |
| 355 | ctx, | 387 | ctx, |
diff --git a/cogs/downloader.py b/cogs/downloader.py index 36bd723..900bafb 100644 --- a/cogs/downloader.py +++ b/cogs/downloader.py | |||
| @@ -12,6 +12,7 @@ import re | |||
| 12 | import yt_dlp | 12 | import yt_dlp |
| 13 | from discord.ext import commands | 13 | from discord.ext import commands |
| 14 | import aiofiles | 14 | import aiofiles |
| 15 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 15 | 16 | ||
| 16 | logger = logging.getLogger(__name__) | 17 | logger = logging.getLogger(__name__) |
| 17 | 18 | ||
| @@ -61,6 +62,10 @@ class Downloader(commands.Cog): | |||
| 61 | file_data = response["content"] | 62 | file_data = response["content"] |
| 62 | await ctx.respond(file=discord.File(file_data)) | 63 | await ctx.respond(file=discord.File(file_data)) |
| 63 | else: | 64 | else: |
| 65 | error_msg = response["content"] | ||
| 66 | if 'details' in response: | ||
| 67 | error_msg += f"\n{response['details']}" | ||
| 68 | await ctx.respond(f"Błąd: {error_msg}", ephemeral=True) | ||
| 64 | logger.error(f"Error: {response['content']}") | 69 | logger.error(f"Error: {response['content']}") |
| 65 | if 'details' in response: | 70 | if 'details' in response: |
| 66 | logger.error(f"Details: {response['details']}") | 71 | logger.error(f"Details: {response['details']}") |
| @@ -68,10 +73,11 @@ class Downloader(commands.Cog): | |||
| 68 | @staticmethod | 73 | @staticmethod |
| 69 | async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): | 74 | async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): |
| 70 | try: | 75 | try: |
| 76 | url = clean_url_for_ytdlp(url) | ||
| 71 | logger.info(f"Downloading {url} as {fformat}") | 77 | logger.info(f"Downloading {url} as {fformat}") |
| 72 | 78 | ||
| 73 | with tempfile.TemporaryDirectory(delete=False) as temp_dir: | 79 | with tempfile.TemporaryDirectory(delete=False) as temp_dir: |
| 74 | ydl_opts = Downloader._get_ydl_options(fformat, temp_dir) | 80 | ydl_opts = Downloader._get_ydl_options(filename_user, fformat, temp_dir) |
| 75 | 81 | ||
| 76 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: | 82 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: |
| 77 | ydl.download([url]) | 83 | ydl.download([url]) |
| @@ -87,15 +93,23 @@ class Downloader(commands.Cog): | |||
| 87 | return Downloader._create_error_response("Download failed", str(e), None) | 93 | return Downloader._create_error_response("Download failed", str(e), None) |
| 88 | 94 | ||
| 89 | @staticmethod | 95 | @staticmethod |
| 90 | def _get_ydl_options(fformat: str, temp_dir: str) -> dict: | 96 | def _sanitize_filename(name: str) -> str: |
| 97 | invalid = '<>:"/\\|?*' | ||
| 98 | for c in invalid: | ||
| 99 | name = name.replace(c, '') | ||
| 100 | return name.strip() | ||
| 101 | |||
| 102 | @staticmethod | ||
| 103 | def _get_ydl_options(filename: str = None, fformat: str = None, temp_dir: str = None) -> dict: | ||
| 104 | if filename: | ||
| 105 | filename = Downloader._sanitize_filename(filename)[:100] | ||
| 91 | ydl_opts = { | 106 | ydl_opts = { |
| 92 | 'outtmpl': os.path.join(temp_dir, '%(title)s.%(ext)s'), | 107 | 'outtmpl': os.path.join(temp_dir, filename if filename is not None else '%(title).100s.%(ext)s'), |
| 93 | 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', | 108 | 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', |
| 94 | 'merge_output_format': 'mp4', | 109 | 'merge_output_format': 'mp4', |
| 95 | 'postprocessors': [], | 110 | 'postprocessors': [], |
| 96 | 'remote-components': 'ejs:github', | 111 | 'remote-components': 'ejs:github', |
| 97 | 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}}, | 112 | 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}}, |
| 98 | 'o': '"%(title)s.%(ext)s"' | ||
| 99 | } | 113 | } |
| 100 | 114 | ||
| 101 | if fformat in ['mp3', 'wav', 'm4a']: | 115 | if fformat in ['mp3', 'wav', 'm4a']: |
| @@ -120,10 +134,15 @@ class Downloader(commands.Cog): | |||
| 120 | 134 | ||
| 121 | actual_file = os.path.join(temp_dir, files[0]) | 135 | actual_file = os.path.join(temp_dir, files[0]) |
| 122 | extension = actual_file.split('.')[-1] | 136 | extension = actual_file.split('.')[-1] |
| 137 | extension = Downloader._sanitize_filename(extension) | ||
| 123 | 138 | ||
| 124 | if not filename_user: | 139 | if not filename_user: |
| 125 | filename = os.path.basename(actual_file) | 140 | filename = os.path.basename(actual_file) |
| 141 | filename = Downloader._sanitize_filename(filename) | ||
| 142 | if len(filename) > 100: | ||
| 143 | filename = filename[:100] | ||
| 126 | else: | 144 | else: |
| 145 | filename_user = Downloader._sanitize_filename(filename_user)[:100] | ||
| 127 | filename = filename_user + '.' + extension | 146 | filename = filename_user + '.' + extension |
| 128 | actual_file = os.path.join(temp_dir, filename) | 147 | actual_file = os.path.join(temp_dir, filename) |
| 129 | 148 | ||
| @@ -144,12 +163,18 @@ class Downloader(commands.Cog): | |||
| 144 | files = {'f': (filename, content, 'application/octet-stream')} | 163 | files = {'f': (filename, content, 'application/octet-stream')} |
| 145 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | 164 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} |
| 146 | params = {'j': '1', 'ck': 'b2'} | 165 | params = {'j': '1', 'ck': 'b2'} |
| 147 | base_url = "https://catbox.2137697.xyz/sperma/" | 166 | base_url = os.getenv("COPYPARTY_URL_UPLOAD") |
| 148 | 167 | ||
| 149 | response = requests.post(base_url, files=files, headers=headers, params=params) | 168 | response = requests.post(base_url, files=files, headers=headers, params=params) |
| 150 | 169 | ||
| 151 | if response.status_code in [200, 201]: | 170 | if response.status_code in [200, 201]: |
| 152 | return Downloader._handle_upload_success(response, filename, temp_dir) | 171 | return Downloader._handle_upload_success(response, filename, temp_dir) |
| 172 | elif response.status_code == 413: | ||
| 173 | return Downloader._create_error_response( | ||
| 174 | "File too large for upload", | ||
| 175 | "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)", | ||
| 176 | temp_dir | ||
| 177 | ) | ||
| 153 | else: | 178 | else: |
| 154 | return Downloader._create_error_response( | 179 | return Downloader._create_error_response( |
| 155 | f"Server returned {response.status_code}", | 180 | f"Server returned {response.status_code}", |
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py index 28c6118..1313d01 100644 --- a/cogs/music/audio_playback.py +++ b/cogs/music/audio_playback.py | |||
| @@ -137,8 +137,16 @@ class AudioPlaybackManager: | |||
| 137 | return absolute_source, display_name, is_url | 137 | return absolute_source, display_name, is_url |
| 138 | 138 | ||
| 139 | @staticmethod | 139 | @staticmethod |
| 140 | async def execute_playback(ctx, source, display_name, is_url): | 140 | async def execute_playback(ctx, source, display_name, is_url, interrupt=True): |
| 141 | """Execute the actual audio playback.""" | 141 | """Execute the actual audio playback. |
| 142 | |||
| 143 | Args: | ||
| 144 | ctx: Discord context | ||
| 145 | source: Audio source path or URL | ||
| 146 | display_name: Name to display | ||
| 147 | is_url: Whether source is a URL | ||
| 148 | interrupt: If True, stop any currently playing audio. If False, skip if audio is playing. | ||
| 149 | """ | ||
| 142 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) | 150 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) |
| 143 | if not target_channel: | 151 | if not target_channel: |
| 144 | return False | 152 | return False |
| @@ -169,9 +177,12 @@ class AudioPlaybackManager: | |||
| 169 | logger.info(f"Moving to voice channel: {target_channel.name}") | 177 | logger.info(f"Moving to voice channel: {target_channel.name}") |
| 170 | await vc.move_to(target_channel) | 178 | await vc.move_to(target_channel) |
| 171 | 179 | ||
| 172 | # Stop any currently playing audio | 180 | # Stop any currently playing audio (if interrupt is True) |
| 173 | if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] | 181 | if interrupt and (vc.is_playing() or vc.is_paused()): # type: ignore[union-attr] |
| 174 | vc.stop() # type: ignore[union-attr] | 182 | vc.stop() # type: ignore[union-attr] |
| 183 | elif not interrupt and vc.is_playing(): # type: ignore[union-attr] | ||
| 184 | logger.info("Audio already playing and interrupt=False, skipping playback") | ||
| 185 | return False | ||
| 175 | 186 | ||
| 176 | # Prepare audio source | 187 | # Prepare audio source |
| 177 | ( | 188 | ( |
| @@ -181,13 +192,14 @@ class AudioPlaybackManager: | |||
| 181 | source, is_url, display_name | 192 | source, is_url, display_name |
| 182 | ) | 193 | ) |
| 183 | 194 | ||
| 184 | # Track if playback failed | 195 | # Track playback state |
| 185 | playback_failed = {"error": None} | 196 | playback_state = {"error": None, "finished": False} |
| 186 | 197 | ||
| 187 | def after_playing(error): | 198 | def after_playing(error): |
| 188 | if error: | 199 | if error: |
| 189 | playback_failed["error"] = str(error) | 200 | playback_state["error"] = str(error) |
| 190 | logger.error(f"Playback error: {error}") | 201 | logger.error(f"Playback error: {error}") |
| 202 | playback_state["finished"] = True | ||
| 191 | 203 | ||
| 192 | # Play the audio | 204 | # Play the audio |
| 193 | vc.play(audio_source, after=after_playing) | 205 | vc.play(audio_source, after=after_playing) |
| @@ -197,14 +209,19 @@ class AudioPlaybackManager: | |||
| 197 | # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds | 209 | # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds |
| 198 | await asyncio.sleep(5) | 210 | await asyncio.sleep(5) |
| 199 | 211 | ||
| 200 | # Check if playback actually started | 212 | # Check if playback failed to start (not just finished quickly) |
| 201 | if playback_failed["error"]: | 213 | if playback_state["error"]: |
| 202 | logger.error(f"Playback failed: {playback_failed['error']}") | 214 | logger.error(f"Playback failed: {playback_state['error']}") |
| 203 | await ctx.followup.send( | 215 | await ctx.followup.send( |
| 204 | "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True | 216 | "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True |
| 205 | ) | 217 | ) |
| 206 | return False | 218 | return False |
| 207 | 219 | ||
| 220 | # If playback finished within 5s, it was a short file - not an error | ||
| 221 | if playback_state["finished"]: | ||
| 222 | logger.info(f"Playback completed quickly: {final_display_name}") | ||
| 223 | return True | ||
| 224 | |||
| 208 | if not vc.is_playing(): | 225 | if not vc.is_playing(): |
| 209 | logger.error(f"Stream failed - not playing after 5s: {source}") | 226 | logger.error(f"Stream failed - not playing after 5s: {source}") |
| 210 | await ctx.followup.send( | 227 | await ctx.followup.send( |
| @@ -232,13 +249,14 @@ class AudioPlaybackManager: | |||
| 232 | return prepared_source, display_name, is_url | 249 | return prepared_source, display_name, is_url |
| 233 | 250 | ||
| 234 | @staticmethod | 251 | @staticmethod |
| 235 | async def play(ctx, source, delete_after_play=False): | 252 | async def play(ctx, source, delete_after_play=False, interrupt=True): |
| 236 | """Play audio from a local file or URL. | 253 | """Play audio from a local file or URL. |
| 237 | 254 | ||
| 238 | Args: | 255 | Args: |
| 239 | ctx: Discord context | 256 | ctx: Discord context |
| 240 | source: Either a local file path or a URL to stream from | 257 | source: Either a local file path or a URL to stream from |
| 241 | delete_after_play: If True, delete the file after playing (only for local files) | 258 | delete_after_play: If True, delete the file after playing (only for local files) |
| 259 | interrupt: If True, stop any currently playing audio. If False, skip if audio is playing. | ||
| 242 | """ | 260 | """ |
| 243 | # Validate and prepare source | 261 | # Validate and prepare source |
| 244 | ( | 262 | ( |
| @@ -254,7 +272,7 @@ class AudioPlaybackManager: | |||
| 254 | 272 | ||
| 255 | # Execute playback | 273 | # Execute playback |
| 256 | success = await AudioPlaybackManager.execute_playback( | 274 | success = await AudioPlaybackManager.execute_playback( |
| 257 | ctx, prepared_source, display_name, is_url | 275 | ctx, prepared_source, display_name, is_url, interrupt |
| 258 | ) | 276 | ) |
| 259 | 277 | ||
| 260 | # Cleanup if needed | 278 | # Cleanup if needed |
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 | |||
| 9 | 9 | ||
| 10 | import yt_dlp | 10 | import yt_dlp |
| 11 | from youtubesearchpython import VideosSearch | 11 | from youtubesearchpython import VideosSearch |
| 12 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 12 | 13 | ||
| 13 | logger = logging.getLogger(__name__) | 14 | logger = logging.getLogger(__name__) |
| 14 | 15 | ||
| @@ -36,6 +37,7 @@ class MediaDownloader: | |||
| 36 | } | 37 | } |
| 37 | 38 | ||
| 38 | try: | 39 | try: |
| 40 | url = clean_url_for_ytdlp(url) | ||
| 39 | with yt_dlp.YoutubeDL(ytdl_opts) as ydl: | 41 | with yt_dlp.YoutubeDL(ytdl_opts) as ydl: |
| 40 | info_dict = ydl.extract_info(url, download=True) | 42 | info_dict = ydl.extract_info(url, download=True) |
| 41 | # Get the base filename without extension | 43 | # Get the base filename without extension |
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index 5373436..bdb021d 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -18,6 +18,8 @@ from discord.ext import commands | |||
| 18 | 18 | ||
| 19 | from cogs.music import audio_playback, media_processing | 19 | from cogs.music import audio_playback, media_processing |
| 20 | from cogs.utils.file_search import UniversalSearcher | 20 | from cogs.utils.file_search import UniversalSearcher |
| 21 | from cogs.utils.settings import get_uploadurl | ||
| 22 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 21 | 23 | ||
| 22 | logger = logging.getLogger(__name__) | 24 | logger = logging.getLogger(__name__) |
| 23 | 25 | ||
| @@ -192,6 +194,7 @@ class PlayCommands(commands.Cog): | |||
| 192 | } | 194 | } |
| 193 | 195 | ||
| 194 | loop = asyncio.get_event_loop() | 196 | loop = asyncio.get_event_loop() |
| 197 | url = clean_url_for_ytdlp(url) | ||
| 195 | with YoutubeDL(ytdl_opts) as ydl: | 198 | with YoutubeDL(ytdl_opts) as ydl: |
| 196 | info_dict = await loop.run_in_executor( | 199 | info_dict = await loop.run_in_executor( |
| 197 | None, lambda: ydl.extract_info(url, download=True) | 200 | None, lambda: ydl.extract_info(url, download=True) |
| @@ -229,14 +232,13 @@ class PlayCommands(commands.Cog): | |||
| 229 | 232 | ||
| 230 | try: | 233 | try: |
| 231 | logger.info(f"Starting async upload: {file_path}") | 234 | logger.info(f"Starting async upload: {file_path}") |
| 232 | baseurl = os.getenv("COPYPARTY_URL_UPLOAD") | 235 | baseurl = get_uploadurl() |
| 233 | password = os.getenv("COPYPARTY_PASSWORD", "") | 236 | password = os.getenv("COPYPARTY_PASSWORD", "") |
| 234 | 237 | ||
| 235 | logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") | 238 | logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") |
| 236 | 239 | ||
| 237 | if not baseurl: | 240 | if baseurl == "http://localhost:3939": |
| 238 | logger.error("COPYPARTY_URL_UPLOAD not set!") | 241 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") |
| 239 | return | ||
| 240 | 242 | ||
| 241 | with open(file_path, "rb") as f: | 243 | with open(file_path, "rb") as f: |
| 242 | content = f.read() | 244 | 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 | |||
| 7 | import discord | 7 | import discord |
| 8 | from discord.ext import commands | 8 | from discord.ext import commands |
| 9 | import random | 9 | import random |
| 10 | import sqlite3 | ||
| 11 | import logging | 10 | import logging |
| 12 | 11 | ||
| 13 | logger = logging.getLogger(__name__) | 12 | from cogs.utils.database import get_connection, init_database |
| 14 | 13 | ||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | 15 | ||
| 16 | DB_PATH = 'data/triggers.db' | 16 | init_database() |
| 17 | 17 | ||
| 18 | _SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" | 18 | _SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" |
| 19 | _SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" | 19 | _SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" |
| @@ -22,88 +22,12 @@ _TRIGGER_NOT_FOUND_MSG = "Trigger not found." | |||
| 22 | _TRIGGER_NAME_OPTION = "the name of the trigger" | 22 | _TRIGGER_NAME_OPTION = "the name of the trigger" |
| 23 | 23 | ||
| 24 | 24 | ||
| 25 | def get_connection(): | ||
| 26 | return sqlite3.connect(DB_PATH) | ||
| 27 | |||
| 28 | |||
| 29 | class Trigger(commands.Cog): | 25 | class Trigger(commands.Cog): |
| 30 | def __init__(self, bot): | 26 | def __init__(self, bot): |
| 31 | self.bot = bot | 27 | self.bot = bot |
| 32 | self.db_check() | ||
| 33 | 28 | ||
| 34 | trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") | 29 | trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") |
| 35 | 30 | ||
| 36 | @staticmethod | ||
| 37 | def db_check(): | ||
| 38 | if not os.path.exists('data'): | ||
| 39 | os.makedirs('data') | ||
| 40 | |||
| 41 | db_exists = os.path.exists(DB_PATH) | ||
| 42 | if not db_exists: | ||
| 43 | logger.warning("Triggers database not found. Creating new database.") | ||
| 44 | |||
| 45 | try: | ||
| 46 | conn = sqlite3.connect(DB_PATH) | ||
| 47 | c = conn.cursor() | ||
| 48 | |||
| 49 | # Enable foreign key constraints | ||
| 50 | c.execute('PRAGMA foreign_keys = ON') | ||
| 51 | |||
| 52 | # Create triggers table | ||
| 53 | c.execute(''' | ||
| 54 | CREATE TABLE IF NOT EXISTS triggers ( | ||
| 55 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 56 | name TEXT NOT NULL UNIQUE, | ||
| 57 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 58 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 59 | )''') | ||
| 60 | |||
| 61 | # Create trigger_keywords table with foreign key constraint | ||
| 62 | c.execute(''' | ||
| 63 | CREATE TABLE IF NOT EXISTS trigger_keywords ( | ||
| 64 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 65 | trigger_id INTEGER NOT NULL, | ||
| 66 | keyword TEXT NOT NULL, | ||
| 67 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 68 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, | ||
| 69 | UNIQUE(trigger_id, keyword) | ||
| 70 | )''') | ||
| 71 | |||
| 72 | # Create trigger_responses table with foreign key constraint | ||
| 73 | c.execute(''' | ||
| 74 | CREATE TABLE IF NOT EXISTS trigger_responses ( | ||
| 75 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 76 | trigger_id INTEGER NOT NULL, | ||
| 77 | response_text TEXT NOT NULL, | ||
| 78 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 79 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE | ||
| 80 | )''') | ||
| 81 | |||
| 82 | # Create indexes for better query performance | ||
| 83 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)') | ||
| 84 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)') | ||
| 85 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)') | ||
| 86 | |||
| 87 | # Run integrity check | ||
| 88 | integrity_check = c.execute('PRAGMA integrity_check').fetchone() | ||
| 89 | if integrity_check and integrity_check[0] == 'ok': | ||
| 90 | logger.info("Triggers database integrity check passed") | ||
| 91 | else: | ||
| 92 | logger.warning(f"Triggers database integrity check: {integrity_check}") | ||
| 93 | |||
| 94 | conn.commit() | ||
| 95 | |||
| 96 | except sqlite3.Error as e: | ||
| 97 | logger.error(f"Triggers database error: {e}") | ||
| 98 | raise | ||
| 99 | |||
| 100 | finally: | ||
| 101 | if 'conn' in locals(): | ||
| 102 | conn.close() | ||
| 103 | |||
| 104 | if not db_exists: | ||
| 105 | logger.info("Triggers database initialized successfully") | ||
| 106 | |||
| 107 | @commands.Cog.listener() | 31 | @commands.Cog.listener() |
| 108 | async def on_message(self, message): | 32 | async def on_message(self, message): |
| 109 | if message.author == self.bot.user: | 33 | if message.author == self.bot.user: |
| @@ -130,7 +54,7 @@ class Trigger(commands.Cog): | |||
| 130 | 54 | ||
| 131 | @staticmethod | 55 | @staticmethod |
| 132 | def get_type(name): | 56 | def get_type(name): |
| 133 | with sqlite3.connect(DB_PATH) as conn: | 57 | with get_connection() as conn: |
| 134 | c = conn.cursor() | 58 | c = conn.cursor() |
| 135 | c.execute("SELECT name FROM triggers") | 59 | c.execute("SELECT name FROM triggers") |
| 136 | return [row[0] for row in c.fetchall()] | 60 | return [row[0] for row in c.fetchall()] |
diff --git a/cogs/uploader.py b/cogs/uploader.py new file mode 100644 index 0000000..df6908e --- /dev/null +++ b/cogs/uploader.py | |||
| @@ -0,0 +1,155 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import aiofiles | ||
| 8 | import requests | ||
| 9 | import urllib.parse | ||
| 10 | from typing import Optional | ||
| 11 | import discord | ||
| 12 | from discord.ext import commands | ||
| 13 | from discord import AutocompleteContext | ||
| 14 | from cogs.utils import file_search as search_utils | ||
| 15 | from cogs.utils.settings import get_uploadurl | ||
| 16 | import logging | ||
| 17 | |||
| 18 | logger = logging.getLogger(__name__) | ||
| 19 | |||
| 20 | |||
| 21 | class Uploader(commands.Cog): | ||
| 22 | def __init__(self, bot): | ||
| 23 | from cogs.utils.temp import get_temp_dir | ||
| 24 | self.bot = bot | ||
| 25 | self.temp_dir = get_temp_dir() | ||
| 26 | self._cached_urls = {} | ||
| 27 | self._uploadurl = get_uploadurl() | ||
| 28 | |||
| 29 | async def _file_autocomplete(self, ctx: AutocompleteContext): | ||
| 30 | query = ctx.value.lower() if ctx.value else "" | ||
| 31 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 32 | |||
| 33 | user_id = ctx.interaction.user.id | ||
| 34 | self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 35 | |||
| 36 | choices = [] | ||
| 37 | for url in results[:25]: | ||
| 38 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 39 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 40 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 41 | return choices | ||
| 42 | |||
| 43 | async def _download_from_url(self, url: str) -> Optional[str]: | ||
| 44 | """Download file from copyparty URL and return local path""" | ||
| 45 | try: | ||
| 46 | response = requests.get(url) | ||
| 47 | if response.status_code != 200: | ||
| 48 | return None | ||
| 49 | |||
| 50 | filename = url.split('/')[-1].split('?')[0] | ||
| 51 | file_path = os.path.join(self.temp_dir, filename) | ||
| 52 | |||
| 53 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 54 | await f.write(response.content) | ||
| 55 | return file_path | ||
| 56 | except Exception as e: | ||
| 57 | logger.error(f"Error downloading from {url}: {e}") | ||
| 58 | return None | ||
| 59 | |||
| 60 | async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: | ||
| 61 | """Upload file to copyparty and return download URL""" | ||
| 62 | try: | ||
| 63 | async with aiofiles.open(file_path, 'rb') as f: | ||
| 64 | file_content = await f.read() | ||
| 65 | |||
| 66 | files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} | ||
| 67 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | ||
| 68 | params = {'j': '1', 'ck': 'b2'} | ||
| 69 | |||
| 70 | response = requests.post(self._uploadurl, files=files, headers=headers, params=params) | ||
| 71 | |||
| 72 | if response.status_code in [200, 201]: | ||
| 73 | result = response.json() | ||
| 74 | return result.get('fileurl') | ||
| 75 | except Exception as e: | ||
| 76 | logger.error(f"Upload error: {e}") | ||
| 77 | return None | ||
| 78 | |||
| 79 | async def _upload_file(self, file_path: str) -> Optional[str]: | ||
| 80 | """Upload a file and return the URL""" | ||
| 81 | if self._uploadurl == "http://localhost:3939": | ||
| 82 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") | ||
| 83 | return None | ||
| 84 | |||
| 85 | return await self._upload_to_copyparty(file_path) | ||
| 86 | |||
| 87 | @commands.message_command( | ||
| 88 | name="upload", | ||
| 89 | description="wyślij załączniki na copyparty", | ||
| 90 | ) | ||
| 91 | async def message_upload(self, ctx, message: discord.Message): | ||
| 92 | if not message.attachments: | ||
| 93 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) | ||
| 94 | return | ||
| 95 | |||
| 96 | await ctx.defer() | ||
| 97 | |||
| 98 | uploaded_urls = [] | ||
| 99 | failed_count = 0 | ||
| 100 | |||
| 101 | for attachment in message.attachments: | ||
| 102 | file_path = os.path.join(self.temp_dir, attachment.filename) | ||
| 103 | await attachment.save(file_path) | ||
| 104 | |||
| 105 | url = await self._upload_file(file_path) | ||
| 106 | if url: | ||
| 107 | uploaded_urls.append(url) | ||
| 108 | else: | ||
| 109 | failed_count += 1 | ||
| 110 | |||
| 111 | if os.path.exists(file_path): | ||
| 112 | os.remove(file_path) | ||
| 113 | |||
| 114 | if uploaded_urls: | ||
| 115 | response = f"Wysłano {len(uploaded_urls)} plików:\n" + "\n".join(uploaded_urls) | ||
| 116 | if failed_count > 0: | ||
| 117 | response += f"\n\nNie wysłano {failed_count} plików" | ||
| 118 | await ctx.respond(response) | ||
| 119 | else: | ||
| 120 | await ctx.respond("Nie udało się wysłać żadnych plików", ephemeral=True) | ||
| 121 | |||
| 122 | @commands.slash_command( | ||
| 123 | name="upload", | ||
| 124 | description="wyślij plik na copyparty", | ||
| 125 | ) | ||
| 126 | @discord.option( | ||
| 127 | name="zalacznik", | ||
| 128 | description="plik z discorda", | ||
| 129 | required=True, | ||
| 130 | type=discord.Attachment | ||
| 131 | ) | ||
| 132 | async def upload(self, ctx, zalacznik: discord.Attachment = None): | ||
| 133 | await ctx.defer() | ||
| 134 | |||
| 135 | file_path = None | ||
| 136 | file_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 137 | await zalacznik.save(file_path) | ||
| 138 | |||
| 139 | if not file_path or not os.path.exists(file_path): | ||
| 140 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 141 | return | ||
| 142 | |||
| 143 | try: | ||
| 144 | dl_url = await self._upload_file(file_path) | ||
| 145 | if dl_url: | ||
| 146 | await ctx.respond(dl_url) | ||
| 147 | else: | ||
| 148 | await ctx.respond("Nie udało się wysłać pliku", ephemeral=True) | ||
| 149 | finally: | ||
| 150 | if file_path and os.path.exists(file_path): | ||
| 151 | os.remove(file_path) | ||
| 152 | |||
| 153 | |||
| 154 | def setup(bot): | ||
| 155 | bot.add_cog(Uploader(bot)) | ||
diff --git a/cogs/utils/database.py b/cogs/utils/database.py new file mode 100644 index 0000000..ea7d356 --- /dev/null +++ b/cogs/utils/database.py | |||
| @@ -0,0 +1,126 @@ | |||
| 1 | import os | ||
| 2 | import sqlite3 | ||
| 3 | import logging | ||
| 4 | from contextlib import contextmanager | ||
| 5 | |||
| 6 | logger = logging.getLogger(__name__) | ||
| 7 | |||
| 8 | DB_PATH = "data/senator.db" | ||
| 9 | |||
| 10 | _initialized = False | ||
| 11 | |||
| 12 | |||
| 13 | def get_connection(): | ||
| 14 | conn = sqlite3.connect(get_db_path()) | ||
| 15 | return _ConnectionContextManager(conn) | ||
| 16 | |||
| 17 | |||
| 18 | def get_db_path(): | ||
| 19 | return os.getenv("SENATOR_DB_PATH", DB_PATH) | ||
| 20 | |||
| 21 | |||
| 22 | def set_db_path(path): | ||
| 23 | global DB_PATH | ||
| 24 | DB_PATH = path | ||
| 25 | |||
| 26 | |||
| 27 | class _ConnectionContextManager: | ||
| 28 | """Context manager for sqlite3 connections that auto-closes.""" | ||
| 29 | def __init__(self, conn): | ||
| 30 | self.conn = conn | ||
| 31 | |||
| 32 | def __enter__(self): | ||
| 33 | return self.conn | ||
| 34 | |||
| 35 | def __exit__(self, exc_type, exc_val, exc_tb): | ||
| 36 | self.conn.close() | ||
| 37 | return False | ||
| 38 | |||
| 39 | |||
| 40 | def init_database(): | ||
| 41 | global _initialized | ||
| 42 | if _initialized: | ||
| 43 | return | ||
| 44 | |||
| 45 | _initialized = True | ||
| 46 | |||
| 47 | db_path = get_db_path() | ||
| 48 | db_dir = os.path.dirname(db_path) | ||
| 49 | if db_dir and not os.path.exists(db_dir): | ||
| 50 | os.makedirs(db_dir) | ||
| 51 | |||
| 52 | conn = sqlite3.connect(db_path) | ||
| 53 | try: | ||
| 54 | c = conn.cursor() | ||
| 55 | |||
| 56 | c.execute("PRAGMA foreign_keys = ON") | ||
| 57 | |||
| 58 | c.execute(""" | ||
| 59 | CREATE TABLE IF NOT EXISTS settings ( | ||
| 60 | key TEXT PRIMARY KEY, | ||
| 61 | value TEXT | ||
| 62 | )""") | ||
| 63 | |||
| 64 | c.execute(""" | ||
| 65 | CREATE TABLE IF NOT EXISTS cron_jobs ( | ||
| 66 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 67 | cron_expression TEXT NOT NULL, | ||
| 68 | type TEXT NOT NULL, | ||
| 69 | channel_id INTEGER NOT NULL, | ||
| 70 | active INTEGER DEFAULT 1, | ||
| 71 | audio_filename TEXT, | ||
| 72 | message TEXT, | ||
| 73 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 74 | )""") | ||
| 75 | |||
| 76 | c.execute(""" | ||
| 77 | CREATE TABLE IF NOT EXISTS voice_activity ( | ||
| 78 | user_id TEXT PRIMARY KEY, | ||
| 79 | join_time REAL, | ||
| 80 | total_active REAL DEFAULT 0, | ||
| 81 | total_afk REAL DEFAULT 0, | ||
| 82 | is_afk INTEGER DEFAULT 0, | ||
| 83 | afk_start REAL, | ||
| 84 | active_start REAL, | ||
| 85 | channel_name TEXT, | ||
| 86 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 87 | )""") | ||
| 88 | |||
| 89 | c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)") | ||
| 90 | c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)") | ||
| 91 | |||
| 92 | c.execute(""" | ||
| 93 | CREATE TABLE IF NOT EXISTS triggers ( | ||
| 94 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 95 | name TEXT NOT NULL UNIQUE, | ||
| 96 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 97 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 98 | )""") | ||
| 99 | |||
| 100 | c.execute(""" | ||
| 101 | CREATE TABLE IF NOT EXISTS trigger_keywords ( | ||
| 102 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 103 | trigger_id INTEGER NOT NULL, | ||
| 104 | keyword TEXT NOT NULL, | ||
| 105 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 106 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, | ||
| 107 | UNIQUE(trigger_id, keyword) | ||
| 108 | )""") | ||
| 109 | |||
| 110 | c.execute(""" | ||
| 111 | CREATE TABLE IF NOT EXISTS trigger_responses ( | ||
| 112 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 113 | trigger_id INTEGER NOT NULL, | ||
| 114 | response_text TEXT NOT NULL, | ||
| 115 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 116 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE | ||
| 117 | )""") | ||
| 118 | |||
| 119 | c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)") | ||
| 120 | c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)") | ||
| 121 | c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)") | ||
| 122 | |||
| 123 | conn.commit() | ||
| 124 | logger.info("Database initialized successfully") | ||
| 125 | finally: | ||
| 126 | 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: | |||
| 34 | query = query.strip() | 34 | query = query.strip() |
| 35 | logger.info(f"Starting copyparty search for query: '{query}'") | 35 | logger.info(f"Starting copyparty search for query: '{query}'") |
| 36 | 36 | ||
| 37 | # Build the query with optional extension filtering | 37 | tokens = query.split() |
| 38 | name_query = f'name like "*{query}*"' | 38 | if not tokens: |
| 39 | return [] | ||
| 40 | |||
| 41 | token_queries = [] | ||
| 42 | for token in tokens: | ||
| 43 | path_query = f'path like "*{token}*"' | ||
| 44 | name_query = f'name like "*{token}*"' | ||
| 45 | token_queries.append(f"({path_query} or {name_query})") | ||
| 46 | |||
| 47 | if len(token_queries) == 1: | ||
| 48 | name_query = token_queries[0] | ||
| 49 | else: | ||
| 50 | name_query = " and ".join(token_queries) | ||
| 51 | |||
| 39 | if file_extensions: | 52 | if file_extensions: |
| 40 | # Create extension filters using copyparty's .ext$ syntax | ||
| 41 | ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] | 53 | ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] |
| 42 | ext_query = " or ".join(ext_filters) | 54 | ext_query = " or ".join(ext_filters) |
| 43 | query = f"{name_query} and ({ext_query})" | 55 | query = f"{name_query} and ({ext_query})" |
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py index d66ce7f..b1a585c 100644 --- a/cogs/utils/settings.py +++ b/cogs/utils/settings.py | |||
| @@ -3,16 +3,18 @@ | |||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io |
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import json | ||
| 7 | import logging | 6 | import logging |
| 8 | import aiofiles | 7 | import sqlite3 |
| 9 | import os | 8 | import os |
| 10 | from dataclasses import dataclass, field | 9 | from dataclasses import dataclass, field |
| 11 | 10 | ||
| 11 | from cogs.utils.database import get_connection, init_database | ||
| 12 | |||
| 12 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
| 13 | 14 | ||
| 14 | settings_file = "data/settings.json" | 15 | DEFAULT_BASEURL = "http://localhost:3939" |
| 15 | DEFAULT_BASEURL = "http://localhost:8080" | 16 | |
| 17 | init_database() | ||
| 16 | 18 | ||
| 17 | 19 | ||
| 18 | @dataclass | 20 | @dataclass |
| @@ -28,24 +30,21 @@ class Settings: | |||
| 28 | } | 30 | } |
| 29 | 31 | ||
| 30 | try: | 32 | try: |
| 31 | if os.path.exists(settings_file): | 33 | with get_connection() as conn: |
| 32 | async with aiofiles.open(settings_file, "r") as f: | 34 | c = conn.cursor() |
| 33 | content = await f.read() | 35 | c.execute("SELECT key, value FROM settings") |
| 34 | if content.strip(): | 36 | rows = c.fetchall() |
| 35 | self._data = json.loads(content) | 37 | |
| 36 | self._sync_from_data() | 38 | if rows: |
| 37 | logger.info("Settings loaded into memory") | 39 | self._data = {row[0]: row[1] for row in rows} |
| 38 | else: | 40 | self._sync_from_data() |
| 39 | self._data = default_settings.copy() | 41 | logger.info("Settings loaded from database") |
| 40 | self._sync_from_data() | ||
| 41 | await self.save() | ||
| 42 | logger.warning("Empty settings.json, reset to defaults") | ||
| 43 | else: | 42 | else: |
| 44 | self._data = default_settings.copy() | 43 | self._data = default_settings.copy() |
| 45 | self._sync_from_data() | 44 | self._sync_from_data() |
| 46 | await self.save() | 45 | await self.save() |
| 47 | logger.info("Created default settings.json") | 46 | logger.info("Created default settings in database") |
| 48 | except json.JSONDecodeError as e: | 47 | except Exception as e: |
| 49 | logger.error(f"Error loading settings: {e}") | 48 | logger.error(f"Error loading settings: {e}") |
| 50 | self._data = default_settings.copy() | 49 | self._data = default_settings.copy() |
| 51 | self._sync_from_data() | 50 | self._sync_from_data() |
| @@ -53,17 +52,27 @@ class Settings: | |||
| 53 | return self | 52 | return self |
| 54 | 53 | ||
| 55 | def _sync_from_data(self): | 54 | def _sync_from_data(self): |
| 56 | self.volume = self._data.get("volume", 1.0) | 55 | try: |
| 56 | self.volume = float(self._data.get("volume", 1.0)) | ||
| 57 | except (ValueError, TypeError): | ||
| 58 | self.volume = 1.0 | ||
| 57 | self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) | 59 | self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) |
| 58 | 60 | ||
| 59 | def _sync_to_data(self): | 61 | def _sync_to_data(self): |
| 60 | self._data["volume"] = self.volume | 62 | self._data["volume"] = str(self.volume) |
| 61 | self._data["baseurl"] = self.baseurl | 63 | self._data["baseurl"] = self.baseurl |
| 62 | 64 | ||
| 63 | async def save(self): | 65 | async def save(self): |
| 64 | self._sync_to_data() | 66 | self._sync_to_data() |
| 65 | async with aiofiles.open(settings_file, "w") as f: | 67 | with get_connection() as conn: |
| 66 | await f.write(json.dumps(self._data, indent=2)) | 68 | c = conn.cursor() |
| 69 | c.execute("PRAGMA foreign_keys = ON") | ||
| 70 | for key, value in self._data.items(): | ||
| 71 | c.execute( | ||
| 72 | "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", | ||
| 73 | (key, value), | ||
| 74 | ) | ||
| 75 | conn.commit() | ||
| 67 | 76 | ||
| 68 | 77 | ||
| 69 | _settings_instance: Settings | None = None | 78 | _settings_instance: Settings | None = None |
| @@ -90,4 +99,16 @@ def get_volume() -> float: | |||
| 90 | 99 | ||
| 91 | 100 | ||
| 92 | def get_baseurl() -> str: | 101 | def get_baseurl() -> str: |
| 93 | return _settings_instance.baseurl.rstrip("/") if _settings_instance else DEFAULT_BASEURL | 102 | url = os.getenv("COPYPARTY_URL") |
| 103 | if not url: | ||
| 104 | logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939") | ||
| 105 | return DEFAULT_BASEURL | ||
| 106 | return url.rstrip("/") | ||
| 107 | |||
| 108 | |||
| 109 | def get_uploadurl() -> str: | ||
| 110 | url = os.getenv("COPYPARTY_URL_UPLOAD") | ||
| 111 | if not url: | ||
| 112 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939") | ||
| 113 | return DEFAULT_BASEURL | ||
| 114 | return url.rstrip("/") | ||
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 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | |||
| 7 | def clean_url_for_ytdlp(url: str) -> str: | ||
| 8 | """Clean URL by removing everything after the first '&'. | ||
| 9 | |||
| 10 | This prevents yt-dlp from interpreting additional query parameters | ||
| 11 | (like &list=...) as playlist URLs. | ||
| 12 | |||
| 13 | Example: | ||
| 14 | Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM& | ||
| 15 | Output: https://www.youtube.com/watch?v=lPGipwoJiOM | ||
| 16 | """ | ||
| 17 | # Find the first '&' and truncate | ||
| 18 | first_ampersand = url.find('&') | ||
| 19 | if first_ampersand != -1: | ||
| 20 | return url[:first_ampersand] | ||
| 21 | return url | ||
diff --git a/cogs/vclogs.py b/cogs/vclogs.py index fb0a2e0..e5ca202 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py | |||
| @@ -4,7 +4,6 @@ | |||
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import datetime | 6 | import datetime |
| 7 | import json | ||
| 8 | import logging | 7 | import logging |
| 9 | import os | 8 | import os |
| 10 | import asyncio | 9 | import asyncio |
| @@ -13,24 +12,66 @@ from discord.ext import commands | |||
| 13 | from gtts import gTTS | 12 | from gtts import gTTS |
| 14 | from cogs.voice import get_voice_manager | 13 | from cogs.voice import get_voice_manager |
| 15 | from cogs.utils.settings import get_volume, save_settings | 14 | from cogs.utils.settings import get_volume, save_settings |
| 15 | from cogs.utils.database import get_connection, init_database | ||
| 16 | 16 | ||
| 17 | import tempfile | 17 | import tempfile |
| 18 | 18 | ||
| 19 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| 20 | 20 | ||
| 21 | ACTIVITY_FILE = os.path.join(tempfile.gettempdir(), 'voice_activity.json') | 21 | init_database() |
| 22 | 22 | ||
| 23 | 23 | ||
| 24 | def load_activity_data(): | 24 | def load_activity_data(): |
| 25 | if os.path.exists(ACTIVITY_FILE): | 25 | try: |
| 26 | with open(ACTIVITY_FILE, 'r') as f: | 26 | with get_connection() as conn: |
| 27 | return json.load(f) | 27 | c = conn.cursor() |
| 28 | return {} | 28 | c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") |
| 29 | rows = c.fetchall() | ||
| 30 | |||
| 31 | return { | ||
| 32 | row[0]: { | ||
| 33 | "join_time": row[1], | ||
| 34 | "total_active": row[2], | ||
| 35 | "total_afk": row[3], | ||
| 36 | "is_afk": bool(row[4]), | ||
| 37 | "afk_start": row[5], | ||
| 38 | "active_start": row[6], | ||
| 39 | "channel_name": row[7], | ||
| 40 | } | ||
| 41 | for row in rows | ||
| 42 | } | ||
| 43 | except Exception as e: | ||
| 44 | logger.error(f"Error loading voice activity: {e}") | ||
| 45 | return {} | ||
| 29 | 46 | ||
| 30 | 47 | ||
| 31 | def save_activity_data(data): | 48 | def save_activity_data(data): |
| 32 | with open(ACTIVITY_FILE, 'w') as f: | 49 | try: |
| 33 | json.dump(data, f, indent=4) | 50 | with get_connection() as conn: |
| 51 | c = conn.cursor() | ||
| 52 | c.execute("PRAGMA foreign_keys = ON") | ||
| 53 | |||
| 54 | c.execute("DELETE FROM voice_activity") | ||
| 55 | |||
| 56 | for user_id, user_data in data.items(): | ||
| 57 | c.execute( | ||
| 58 | """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) | ||
| 59 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", | ||
| 60 | ( | ||
| 61 | user_id, | ||
| 62 | user_data.get("join_time"), | ||
| 63 | user_data.get("total_active", 0), | ||
| 64 | user_data.get("total_afk", 0), | ||
| 65 | int(user_data.get("is_afk", False)), | ||
| 66 | user_data.get("afk_start"), | ||
| 67 | user_data.get("active_start"), | ||
| 68 | user_data.get("channel_name"), | ||
| 69 | ), | ||
| 70 | ) | ||
| 71 | conn.commit() | ||
| 72 | logger.info(f"Saved voice activity for {len(data)} users") | ||
| 73 | except Exception as e: | ||
| 74 | logger.error(f"Error saving voice activity: {e}") | ||
| 34 | 75 | ||
| 35 | 76 | ||
| 36 | def format_duration(seconds): | 77 | def format_duration(seconds): |
| @@ -104,9 +145,8 @@ class VcLogs(commands.Cog): | |||
| 104 | 145 | ||
| 105 | if voice_client: | 146 | if voice_client: |
| 106 | if is_playing: | 147 | if is_playing: |
| 107 | logger.info("voice already playing, stopping current audio") | 148 | logger.info("voice already playing, skipping TTS") |
| 108 | voice_client.stop() | 149 | return |
| 109 | await asyncio.sleep(0.1) | ||
| 110 | 150 | ||
| 111 | logger.info("preparing to play audio") | 151 | logger.info("preparing to play audio") |
| 112 | audio_source = discord.FFmpegPCMAudio(tts_file) | 152 | audio_source = discord.FFmpegPCMAudio(tts_file) |
| @@ -169,7 +209,7 @@ class VcLogs(commands.Cog): | |||
| 169 | } | 209 | } |
| 170 | save_activity_data(activity_data) | 210 | save_activity_data(activity_data) |
| 171 | 211 | ||
| 172 | message = f"[{current_time}] {member.name} dołączył na {after.channel.name}" | 212 | message = f"[{current_time}] {member.name} dołączył na {after.channel.name} <t:{int(current_ts)}:R>" |
| 173 | tts_text = f"{member.display_name} sie zespawnił" | 213 | tts_text = f"{member.display_name} sie zespawnił" |
| 174 | tts_channel = after.channel | 214 | tts_channel = after.channel |
| 175 | await log_channel.send(message) | 215 | await log_channel.send(message) |
| @@ -190,7 +230,7 @@ class VcLogs(commands.Cog): | |||
| 190 | afk_str = format_duration(data['total_afk']) | 230 | afk_str = format_duration(data['total_afk']) |
| 191 | 231 | ||
| 192 | message = ( | 232 | message = ( |
| 193 | f"[{current_time}] {member.name} rozłączył się z {before.channel.name}\n" | 233 | f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>\n" |
| 194 | f"Aktywny: {active_str} | AFK: {afk_str}" | 234 | f"Aktywny: {active_str} | AFK: {afk_str}" |
| 195 | ) | 235 | ) |
| 196 | tts_text = f"{member.display_name} jest chujem" | 236 | tts_text = f"{member.display_name} jest chujem" |
| @@ -201,7 +241,7 @@ class VcLogs(commands.Cog): | |||
| 201 | del activity_data[user_id] | 241 | del activity_data[user_id] |
| 202 | save_activity_data(activity_data) | 242 | save_activity_data(activity_data) |
| 203 | else: | 243 | else: |
| 204 | message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name}" | 244 | message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>" |
| 205 | tts_text = f"{member.display_name} jest chujem" | 245 | tts_text = f"{member.display_name} jest chujem" |
| 206 | tts_channel = before.channel | 246 | tts_channel = before.channel |
| 207 | await log_channel.send(message) | 247 | await log_channel.send(message) |
| @@ -225,7 +265,7 @@ class VcLogs(commands.Cog): | |||
| 225 | else: | 265 | else: |
| 226 | activity = "odgłuchnął" | 266 | activity = "odgłuchnął" |
| 227 | 267 | ||
| 228 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name}\n" | 268 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n" |
| 229 | f"AFK: {afk_str}" ) | 269 | f"AFK: {afk_str}" ) |
| 230 | await log_channel.send(message) | 270 | await log_channel.send(message) |
| 231 | logger.info(f"User returned from AFK: {message}") | 271 | logger.info(f"User returned from AFK: {message}") |
| @@ -245,7 +285,7 @@ class VcLogs(commands.Cog): | |||
| 245 | activity = "ogłuchnął" | 285 | activity = "ogłuchnął" |
| 246 | 286 | ||
| 247 | active_str = format_duration(active_since_start) | 287 | active_str = format_duration(active_since_start) |
| 248 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name}\n" | 288 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n" |
| 249 | f"Aktywny: {active_str}" ) | 289 | f"Aktywny: {active_str}" ) |
| 250 | await log_channel.send(message) | 290 | await log_channel.send(message) |
| 251 | logger.info(f"User went AFK: {message}") | 291 | 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: | |||
| 45 | return voice_client | 45 | return voice_client |
| 46 | except Exception as e: | 46 | except Exception as e: |
| 47 | logger.error(f"Failed to move voice client: {e}") | 47 | logger.error(f"Failed to move voice client: {e}") |
| 48 | logger.info("Disconnecting after failed move attempt") | ||
| 48 | try: | 49 | try: |
| 49 | await voice_client.disconnect() | 50 | await voice_client.disconnect() |
| 50 | except Exception: | 51 | except Exception: |
| @@ -55,6 +56,25 @@ class VoiceManager: | |||
| 55 | voice_client = await channel.connect(timeout=30.0, reconnect=False) | 56 | voice_client = await channel.connect(timeout=30.0, reconnect=False) |
| 56 | logger.info(f"Connected to voice channel: {channel}") | 57 | logger.info(f"Connected to voice channel: {channel}") |
| 57 | return voice_client | 58 | return voice_client |
| 59 | except discord.ClientException as e: | ||
| 60 | if "Already connected" in str(e): | ||
| 61 | logger.warning(f"Stale voice state detected, forcing disconnect and reconnect: {e}") | ||
| 62 | voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 63 | if voice_client: | ||
| 64 | try: | ||
| 65 | await voice_client.disconnect() | ||
| 66 | except Exception: | ||
| 67 | pass | ||
| 68 | await asyncio.sleep(0.3) | ||
| 69 | try: | ||
| 70 | voice_client = await channel.connect(timeout=30.0, reconnect=False) | ||
| 71 | logger.info(f"Reconnected to voice channel: {channel}") | ||
| 72 | return voice_client | ||
| 73 | except Exception as e2: | ||
| 74 | logger.error(f"Failed to reconnect after stale cleanup: {e2}") | ||
| 75 | return None | ||
| 76 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 77 | return None | ||
| 58 | except Exception as e: | 78 | except Exception as e: |
| 59 | logger.error(f"Failed to connect to voice channel: {e}") | 79 | logger.error(f"Failed to connect to voice channel: {e}") |
| 60 | return None | 80 | return None |
| @@ -113,6 +133,7 @@ class VoiceManager: | |||
| 113 | voice_client = await self.get_voice_client(guild) | 133 | voice_client = await self.get_voice_client(guild) |
| 114 | if voice_client: | 134 | if voice_client: |
| 115 | voice_client.stop() | 135 | voice_client.stop() |
| 136 | logger.info("stop() called - disconnecting from voice") | ||
| 116 | await voice_client.disconnect() | 137 | await voice_client.disconnect() |
| 117 | logger.info(f"Stopped and disconnected from guild {guild}") | 138 | logger.info(f"Stopped and disconnected from guild {guild}") |
| 118 | 139 | ||
| @@ -174,6 +195,7 @@ class Voice(commands.Cog): | |||
| 174 | 195 | ||
| 175 | if voice_client and not voice_client.is_connected(): | 196 | if voice_client and not voice_client.is_connected(): |
| 176 | try: | 197 | try: |
| 198 | logger.info("Voice client exists but not connected - disconnecting stale client") | ||
| 177 | await voice_client.disconnect() | 199 | await voice_client.disconnect() |
| 178 | except Exception: | 200 | except Exception: |
| 179 | pass | 201 | pass |
diff --git a/cogs/web.py b/cogs/web.py index b072e99..fd22f9f 100644 --- a/cogs/web.py +++ b/cogs/web.py | |||
| @@ -11,6 +11,7 @@ from bs4 import BeautifulSoup | |||
| 11 | import urllib.parse | 11 | import urllib.parse |
| 12 | import yt_dlp | 12 | import yt_dlp |
| 13 | import logging | 13 | import logging |
| 14 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 14 | 15 | ||
| 15 | logger = logging.getLogger(__name__) | 16 | logger = logging.getLogger(__name__) |
| 16 | 17 | ||
| @@ -24,6 +25,7 @@ class Web(commands.Cog): | |||
| 24 | if "youtube.com/watch" not in url: | 25 | if "youtube.com/watch" not in url: |
| 25 | return None | 26 | return None |
| 26 | 27 | ||
| 28 | url = clean_url_for_ytdlp(url) | ||
| 27 | logger.info("youtube link") | 29 | logger.info("youtube link") |
| 28 | try: | 30 | try: |
| 29 | ydl_opts = {"quiet": True, "nowarnings": True} | 31 | ydl_opts = {"quiet": True, "nowarnings": True} |
| @@ -34,18 +36,8 @@ class Web(commands.Cog): | |||
| 34 | pass | 36 | pass |
| 35 | return None | 37 | return None |
| 36 | 38 | ||
| 37 | def _create_button(self, index, link): | ||
| 38 | """Create a button for sending search result.""" | ||
| 39 | button = discord.ui.Button(label=f"Send Result {index}") | ||
| 40 | |||
| 41 | async def button_callback(interaction: discord.Interaction): | ||
| 42 | await interaction.response.send_message(link.get("href")) | ||
| 43 | |||
| 44 | button.callback = button_callback | ||
| 45 | return button | ||
| 46 | |||
| 47 | def _process_search_results(self, links, query): | 39 | def _process_search_results(self, links, query): |
| 48 | """Process search results and create embed with buttons.""" | 40 | """Process search results and create embed.""" |
| 49 | embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) | 41 | embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) |
| 50 | view = discord.ui.View(disable_on_timeout=True) | 42 | view = discord.ui.View(disable_on_timeout=True) |
| 51 | 43 | ||
| @@ -56,8 +48,6 @@ class Web(commands.Cog): | |||
| 56 | title = youtube_title | 48 | title = youtube_title |
| 57 | 49 | ||
| 58 | embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) | 50 | embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) |
| 59 | button = self._create_button(i, link) | ||
| 60 | view.add_item(button) | ||
| 61 | 51 | ||
| 62 | return embed, view | 52 | return embed, view |
| 63 | 53 | ||
| @@ -70,7 +60,7 @@ class Web(commands.Cog): | |||
| 70 | ctx: discord.ApplicationContext, | 60 | ctx: discord.ApplicationContext, |
| 71 | query: str | 61 | query: str |
| 72 | ): | 62 | ): |
| 73 | await ctx.defer(ephemeral=True) | 63 | await ctx.defer() |
| 74 | headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"} | 64 | headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"} |
| 75 | encoded_query = urllib.parse.quote_plus(query) | 65 | encoded_query = urllib.parse.quote_plus(query) |
| 76 | url = f"https://duckduckgo.com/html/?q={encoded_query}" | 66 | url = f"https://duckduckgo.com/html/?q={encoded_query}" |
| @@ -92,7 +82,7 @@ class Web(commands.Cog): | |||
| 92 | return | 82 | return |
| 93 | 83 | ||
| 94 | embed, view = self._process_search_results(links, query) | 84 | embed, view = self._process_search_results(links, query) |
| 95 | await ctx.respond(embed=embed, view=view, ephemeral=True) | 85 | await ctx.respond(embed=embed, view=view) |
| 96 | logger.info("Search results sent") | 86 | logger.info("Search results sent") |
| 97 | 87 | ||
| 98 | 88 | ||
| @@ -26,7 +26,8 @@ def setup_logging(debug: bool = False): | |||
| 26 | 26 | ||
| 27 | logging.basicConfig( | 27 | logging.basicConfig( |
| 28 | level=level, | 28 | level=level, |
| 29 | format="%(levelname)s - %(filename)s - %(name)s - %(message)s", | 29 | datefmt="%m-%d %H:%M:%S", |
| 30 | format="%(levelname)s - %(asctime)s - %(filename)s - %(name)s - %(message)s", | ||
| 30 | handlers=[logging.StreamHandler(sys.stdout)], | 31 | handlers=[logging.StreamHandler(sys.stdout)], |
| 31 | ) | 32 | ) |
| 32 | logging.getLogger().addFilter(DecryptionFilter()) | 33 | logging.getLogger().addFilter(DecryptionFilter()) |
| @@ -75,6 +76,7 @@ if __name__ == "__main__": | |||
| 75 | "cogs.cron", | 76 | "cogs.cron", |
| 76 | "cogs.qr", | 77 | "cogs.qr", |
| 77 | "cogs.voice", | 78 | "cogs.voice", |
| 79 | "cogs.uploader", | ||
| 78 | ] | 80 | ] |
| 79 | 81 | ||
| 80 | try: | 82 | try: |
diff --git a/pyrightconfig.json b/pyrightconfig.json index 7722ae1..38f1ed5 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json | |||
| @@ -1,5 +1,5 @@ | |||
| 1 | { | 1 | { |
| 2 | "include": ["cogs", "main.py", "cogs/utils"], | 2 | "include": ["cogs", "main.py", "cogs/utils", "tests"], |
| 3 | "exclude": ["pycord", ".venv", "temp", "data"], | 3 | "exclude": ["pycord", ".venv", "temp", "data"], |
| 4 | "typeCheckingMode": "basic", | 4 | "typeCheckingMode": "basic", |
| 5 | "reportMissingTypeStubs": false, | 5 | "reportMissingTypeStubs": false, |
| @@ -1,7 +1,9 @@ | |||
| 1 | [pytest] | 1 | [pytest] |
| 2 | markers = | ||
| 3 | slow: marks tests as slow (deselect with '-m "not slow"') | ||
| 4 | asyncio_mode = strict | ||
| 2 | asyncio_default_fixture_loop_scope = function | 5 | asyncio_default_fixture_loop_scope = function |
| 3 | python_files = test_*.py | 6 | filterwarnings = |
| 4 | python_classes = Test* | 7 | ignore::RuntimeWarning |
| 5 | python_functions = test_* | 8 | ignore::pytest.PytestUnraisableExceptionWarning |
| 6 | testpaths = tests | 9 | ignore::DeprecationWarning:discord.client \ No newline at end of file |
| 7 | pythonpath = . \ 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 | |||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import discord | 3 | import discord |
| 4 | import httpx | 4 | import httpx |
| 5 | from tests.conftest import MockApplicationContext | 5 | from conftest import MockApplicationContext |
| 6 | 6 | ||
| 7 | 7 | ||
| 8 | @pytest.fixture | 8 | @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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | from tests.conftest import MockApplicationContext | 3 | from conftest import MockApplicationContext |
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | @pytest.fixture | 6 | @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 | |||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import os | 3 | import os |
| 4 | import discord | 4 | import discord |
| 5 | from tests.conftest import MockApplicationContext | 5 | from conftest import MockApplicationContext |
| 6 | 6 | ||
| 7 | 7 | ||
| 8 | def test_compression_filetype_video(): | 8 | def test_compression_filetype_video(): |
diff --git a/tests/cogs/test_cron.py b/tests/cogs/test_cron.py index 93031f8..28ccfe1 100644 --- a/tests/cogs/test_cron.py +++ b/tests/cogs/test_cron.py | |||
| @@ -1,10 +1,9 @@ | |||
| 1 | import json | ||
| 2 | import os | ||
| 3 | import pytest | 1 | import pytest |
| 4 | from datetime import datetime | 2 | from datetime import datetime |
| 5 | from unittest.mock import MagicMock, patch | 3 | from unittest.mock import MagicMock, patch |
| 6 | 4 | ||
| 7 | from cogs.cron import Cron | 5 | from cogs.cron import Cron |
| 6 | from cogs.utils.database import _ConnectionContextManager | ||
| 8 | 7 | ||
| 9 | 8 | ||
| 10 | @pytest.fixture | 9 | @pytest.fixture |
| @@ -17,90 +16,74 @@ def mock_bot(): | |||
| 17 | return bot | 16 | return bot |
| 18 | 17 | ||
| 19 | 18 | ||
| 20 | def create_cog(mock_bot, tmp_path): | 19 | def create_cog(mock_bot): |
| 21 | with patch.object(Cron, "__init__", lambda self, bot: None): | 20 | with patch.object(Cron, "__init__", lambda self, bot: None): |
| 22 | cog = Cron.__new__(Cron) | 21 | with patch("cogs.cron.init_database"): |
| 23 | cog.bot = mock_bot | 22 | cog = Cron.__new__(Cron) |
| 24 | cog.scheduled_tasks = [] | 23 | cog.bot = mock_bot |
| 25 | cog.cron_file = str(tmp_path / "cron_jobs.json") | 24 | cog.scheduled_tasks = [] |
| 26 | cog.task = MagicMock() | 25 | cog.task = MagicMock() |
| 27 | return cog | 26 | return cog |
| 28 | 27 | ||
| 29 | 28 | ||
| 30 | class TestLoadTasks: | 29 | class TestLoadTasks: |
| 31 | def test_load_tasks_file_not_exists(self, mock_bot, tmp_path): | 30 | def test_load_tasks_with_data(self, mock_bot): |
| 32 | cog = create_cog(mock_bot, tmp_path) | 31 | mock_conn = MagicMock() |
| 33 | cog.cron_file = str(tmp_path / "nonexistent.json") | 32 | mock_cursor = MagicMock() |
| 34 | cog.load_tasks() | 33 | mock_cursor.fetchall.return_value = [ |
| 35 | assert cog.scheduled_tasks == [] | 34 | (1, "* * * * *", "audio", 123, 1, None, "test message") |
| 36 | 35 | ] | |
| 37 | def test_load_tasks_empty_file(self, mock_bot, tmp_path): | 36 | mock_conn.cursor.return_value = mock_cursor |
| 38 | cron_file = tmp_path / "empty.json" | ||
| 39 | cron_file.write_text("") | ||
| 40 | cog = create_cog(mock_bot, tmp_path) | ||
| 41 | cog.cron_file = str(cron_file) | ||
| 42 | cog.load_tasks() | ||
| 43 | assert cog.scheduled_tasks == [] | ||
| 44 | |||
| 45 | def test_load_tasks_valid_file(self, mock_bot, tmp_path): | ||
| 46 | cron_file = tmp_path / "valid.json" | ||
| 47 | tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] | ||
| 48 | cron_file.write_text(json.dumps(tasks)) | ||
| 49 | cog = create_cog(mock_bot, tmp_path) | ||
| 50 | cog.cron_file = str(cron_file) | ||
| 51 | cog.load_tasks() | ||
| 52 | assert len(cog.scheduled_tasks) == 1 | ||
| 53 | assert cog.scheduled_tasks[0]["id"] == 1 | ||
| 54 | 37 | ||
| 55 | def test_load_tasks_io_error(self, mock_bot, tmp_path): | 38 | # Create a context manager mock |
| 56 | cog = create_cog(mock_bot, tmp_path) | 39 | mock_ctx = MagicMock() |
| 57 | with patch("builtins.open", side_effect=IOError("test error")): | 40 | mock_ctx.__enter__ = MagicMock(return_value=mock_conn) |
| 58 | cog.load_tasks() | 41 | mock_ctx.__exit__ = MagicMock(return_value=False) |
| 59 | assert cog.scheduled_tasks == [] | ||
| 60 | 42 | ||
| 43 | with patch("cogs.cron.get_connection", return_value=mock_ctx): | ||
| 44 | cog = Cron.__new__(Cron) | ||
| 45 | cog.bot = mock_bot | ||
| 46 | cog.scheduled_tasks = [] | ||
| 47 | cog.task = MagicMock() | ||
| 48 | cog.load_tasks() | ||
| 49 | assert len(cog.scheduled_tasks) == 1 | ||
| 50 | assert cog.scheduled_tasks[0]["id"] == 1 | ||
| 61 | 51 | ||
| 62 | class TestSaveTasks: | 52 | class TestSaveTasks: |
| 63 | def test_save_tasks_with_real_file(self, mock_bot, tmp_path): | 53 | def test_save_tasks(self, mock_bot): |
| 64 | cron_file = tmp_path / "cron_jobs.json" | 54 | cog = create_cog(mock_bot) |
| 65 | cog = create_cog(mock_bot, tmp_path) | 55 | cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] |
| 66 | cog.cron_file = str(cron_file) | ||
| 67 | cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *"}] | ||
| 68 | cog.save_tasks() | ||
| 69 | assert os.path.exists(cron_file) | ||
| 70 | with open(cron_file) as f: | ||
| 71 | loaded = json.load(f) | ||
| 72 | assert len(loaded) == 1 | ||
| 73 | 56 | ||
| 74 | def test_save_tasks_creates_directory_structure(self, mock_bot, tmp_path): | 57 | mock_conn = MagicMock() |
| 75 | cog = create_cog(mock_bot, tmp_path) | 58 | mock_cursor = MagicMock() |
| 76 | new_dir = tmp_path / "new_dir" | 59 | mock_conn.cursor.return_value = mock_cursor |
| 77 | cron_file = new_dir / "cron_jobs.json" | ||
| 78 | cog.cron_file = str(cron_file) | ||
| 79 | cog.scheduled_tasks = [{"id": 1}] | ||
| 80 | 60 | ||
| 81 | cog.save_tasks() | 61 | # Create a context manager mock |
| 82 | 62 | mock_ctx = MagicMock() | |
| 83 | assert os.path.exists(new_dir) | 63 | mock_ctx.__enter__ = MagicMock(return_value=mock_conn) |
| 64 | mock_ctx.__exit__ = MagicMock(return_value=False) | ||
| 84 | 65 | ||
| 66 | with patch("cogs.cron.get_connection", return_value=mock_ctx): | ||
| 67 | cog.save_tasks() | ||
| 68 | assert len(cog.scheduled_tasks) == 1 | ||
| 85 | 69 | ||
| 86 | class TestShouldRunTask: | 70 | class TestShouldRunTask: |
| 87 | def test_should_run_task_invalid_cron(self, mock_bot, tmp_path): | 71 | def test_should_run_task_invalid_cron(self, mock_bot): |
| 88 | cog = create_cog(mock_bot, tmp_path) | 72 | cog = create_cog(mock_bot) |
| 89 | task = {"cron_expression": "invalid"} | 73 | task = {"cron_expression": "invalid"} |
| 90 | now = datetime.now() | 74 | now = datetime.now() |
| 91 | result = cog.should_run_task(task, now) | 75 | result = cog.should_run_task(task, now) |
| 92 | assert result is False | 76 | assert result is False |
| 93 | 77 | ||
| 94 | def test_should_run_task_missing_cron_expression(self, mock_bot, tmp_path): | 78 | def test_should_run_task_missing_cron_expression(self, mock_bot): |
| 95 | cog = create_cog(mock_bot, tmp_path) | 79 | cog = create_cog(mock_bot) |
| 96 | task = {} | 80 | task = {} |
| 97 | now = datetime.now() | 81 | now = datetime.now() |
| 98 | result = cog.should_run_task(task, now) | 82 | result = cog.should_run_task(task, now) |
| 99 | assert result is False | 83 | assert result is False |
| 100 | 84 | ||
| 101 | |||
| 102 | class TestCogUnload: | 85 | class TestCogUnload: |
| 103 | def test_cog_unload_cancels_task(self, mock_bot, tmp_path): | 86 | def test_cog_unload_cancels_task(self, mock_bot): |
| 104 | with patch.object(Cron, "__init__", lambda self, bot: None): | 87 | with patch.object(Cron, "__init__", lambda self, bot: None): |
| 105 | cog = Cron.__new__(Cron) | 88 | cog = Cron.__new__(Cron) |
| 106 | cog.bot = mock_bot | 89 | cog.bot = mock_bot |
| @@ -109,10 +92,9 @@ class TestCogUnload: | |||
| 109 | cog.cog_unload() | 92 | cog.cog_unload() |
| 110 | cog.task.cancel.assert_called_once() | 93 | cog.task.cancel.assert_called_once() |
| 111 | 94 | ||
| 112 | |||
| 113 | class TestTaskManagement: | 95 | class TestTaskManagement: |
| 114 | def test_add_task_increments_id(self, mock_bot, tmp_path): | 96 | def test_add_task_increments_id(self, mock_bot): |
| 115 | cog = create_cog(mock_bot, tmp_path) | 97 | cog = create_cog(mock_bot) |
| 116 | cog.scheduled_tasks = [ | 98 | cog.scheduled_tasks = [ |
| 117 | {"id": 1, "cron_expression": "* * * * *"}, | 99 | {"id": 1, "cron_expression": "* * * * *"}, |
| 118 | {"id": 2, "cron_expression": "0 * * * *"}, | 100 | {"id": 2, "cron_expression": "0 * * * *"}, |
| @@ -121,8 +103,8 @@ class TestTaskManagement: | |||
| 121 | new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1 | 103 | new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1 |
| 122 | assert new_id == 3 | 104 | assert new_id == 3 |
| 123 | 105 | ||
| 124 | def test_find_task_by_id(self, mock_bot, tmp_path): | 106 | def test_find_task_by_id(self, mock_bot): |
| 125 | cog = create_cog(mock_bot, tmp_path) | 107 | cog = create_cog(mock_bot) |
| 126 | cog.scheduled_tasks = [ | 108 | cog.scheduled_tasks = [ |
| 127 | {"id": 1, "cron_expression": "* * * * *"}, | 109 | {"id": 1, "cron_expression": "* * * * *"}, |
| 128 | {"id": 2, "cron_expression": "0 * * * *"}, | 110 | {"id": 2, "cron_expression": "0 * * * *"}, |
| @@ -132,8 +114,8 @@ class TestTaskManagement: | |||
| 132 | assert found_task is not None | 114 | assert found_task is not None |
| 133 | assert found_task["cron_expression"] == "0 * * * *" | 115 | assert found_task["cron_expression"] == "0 * * * *" |
| 134 | 116 | ||
| 135 | def test_remove_task_by_index(self, mock_bot, tmp_path): | 117 | def test_remove_task_by_index(self, mock_bot): |
| 136 | cog = create_cog(mock_bot, tmp_path) | 118 | cog = create_cog(mock_bot) |
| 137 | cog.scheduled_tasks = [ | 119 | cog.scheduled_tasks = [ |
| 138 | {"id": 1, "cron_expression": "* * * * *"}, | 120 | {"id": 1, "cron_expression": "* * * * *"}, |
| 139 | {"id": 2, "cron_expression": "0 * * * *"}, | 121 | {"id": 2, "cron_expression": "0 * * * *"}, |
| @@ -143,8 +125,8 @@ class TestTaskManagement: | |||
| 143 | assert len(cog.scheduled_tasks) == 1 | 125 | assert len(cog.scheduled_tasks) == 1 |
| 144 | assert cog.scheduled_tasks[0]["id"] == 2 | 126 | assert cog.scheduled_tasks[0]["id"] == 2 |
| 145 | 127 | ||
| 146 | def test_toggle_task_active(self, mock_bot, tmp_path): | 128 | def test_toggle_task_active(self, mock_bot): |
| 147 | cog = create_cog(mock_bot, tmp_path) | 129 | cog = create_cog(mock_bot) |
| 148 | cog.scheduled_tasks = [{"id": 1, "active": True}] | 130 | cog.scheduled_tasks = [{"id": 1, "active": True}] |
| 149 | 131 | ||
| 150 | cog.scheduled_tasks[0]["active"] = False | 132 | cog.scheduled_tasks[0]["active"] = False |
| @@ -153,14 +135,13 @@ class TestTaskManagement: | |||
| 153 | cog.scheduled_tasks[0]["active"] = True | 135 | cog.scheduled_tasks[0]["active"] = True |
| 154 | assert cog.scheduled_tasks[0]["active"] is True | 136 | assert cog.scheduled_tasks[0]["active"] is True |
| 155 | 137 | ||
| 156 | |||
| 157 | class TestTaskTypes: | 138 | class TestTaskTypes: |
| 158 | def test_task_type_audio_aliases(self, mock_bot, tmp_path): | 139 | def test_task_type_audio_aliases(self, mock_bot): |
| 159 | audio_types = ("zagraj_audio", "audio") | 140 | audio_types = ("zagraj_audio", "audio") |
| 160 | for task_type in audio_types: | 141 | for task_type in audio_types: |
| 161 | assert task_type in ("zagraj_audio", "audio") | 142 | assert task_type in ("zagraj_audio", "audio") |
| 162 | 143 | ||
| 163 | def test_task_type_message_aliases(self, mock_bot, tmp_path): | 144 | def test_task_type_message_aliases(self, mock_bot): |
| 164 | message_types = ("wyślij_wiadomość", "send_message") | 145 | message_types = ("wyślij_wiadomość", "send_message") |
| 165 | for task_type in message_types: | 146 | for task_type in message_types: |
| 166 | assert task_type in ("wyślij_wiadomość", "send_message") | 147 | 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 @@ | |||
| 1 | import pytest | ||
| 2 | import time | ||
| 3 | from unittest.mock import MagicMock, AsyncMock, patch | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def cron_cog(bot): | ||
| 8 | # Patch create_task to prevent background task creation | ||
| 9 | with patch.object(bot.loop, 'create_task', return_value=MagicMock()): | ||
| 10 | from cogs.cron import Cron | ||
| 11 | cog = Cron(bot) | ||
| 12 | yield cog | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.mark.slow | ||
| 16 | @pytest.mark.asyncio | ||
| 17 | async def test_cron_job_execution(cron_cog): | ||
| 18 | """Test cron job executes when due.""" | ||
| 19 | cog = cron_cog | ||
| 20 | |||
| 21 | job = { | ||
| 22 | "id": "test_job", | ||
| 23 | "cron_expression": "* * * * *", | ||
| 24 | "type": "message", | ||
| 25 | "channel_id": 456, | ||
| 26 | "active": True, | ||
| 27 | "message": "Test", | ||
| 28 | } | ||
| 29 | cog.scheduled_tasks.append(job) | ||
| 30 | |||
| 31 | # Mock the execute_task method | ||
| 32 | with patch.object(cog, 'execute_task', new_callable=AsyncMock) as mock_exec: | ||
| 33 | # Mock should_run_task to return True | ||
| 34 | with patch.object(cog, 'should_run_task', return_value=True): | ||
| 35 | # Test single iteration of schedule_checker logic | ||
| 36 | for task in cog.scheduled_tasks: | ||
| 37 | if task.get("active", True) and cog.should_run_task(task, None): | ||
| 38 | await cog.execute_task(task) | ||
| 39 | 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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import discord | 3 | import discord |
| 4 | from tests.conftest import MockApplicationContext, MockMessage | 4 | from conftest import MockApplicationContext, MockMessage |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | @pytest.fixture | 7 | @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 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import MagicMock, AsyncMock, patch | ||
| 3 | |||
| 4 | |||
| 5 | @pytest.fixture | ||
| 6 | def downloader_cog(bot): | ||
| 7 | from cogs.downloader import Downloader | ||
| 8 | cog = Downloader(bot) | ||
| 9 | return cog | ||
| 10 | |||
| 11 | |||
| 12 | @pytest.mark.slow | ||
| 13 | @pytest.mark.asyncio | ||
| 14 | async def test_download_command_flow(downloader_cog, bot): | ||
| 15 | """Test download command with URL processing.""" | ||
| 16 | cog = downloader_cog | ||
| 17 | |||
| 18 | ctx = MagicMock() | ||
| 19 | ctx.author = MagicMock() | ||
| 20 | ctx.guild = MagicMock() | ||
| 21 | ctx.channel = MagicMock() | ||
| 22 | ctx.respond = AsyncMock() | ||
| 23 | ctx.defer = AsyncMock() | ||
| 24 | ctx.followup = AsyncMock() | ||
| 25 | ctx.voice_client = None | ||
| 26 | |||
| 27 | # Mock the download_standalone static method | ||
| 28 | mock_response = { | ||
| 29 | "status": "success", | ||
| 30 | "type": "url", | ||
| 31 | "content": "http://example.com/video.mp4" | ||
| 32 | } | ||
| 33 | |||
| 34 | with patch.object(cog, 'download_standalone', new_callable=AsyncMock, return_value=mock_response): | ||
| 35 | await cog.download.callback(cog, ctx, url="http://youtube.com/test") | ||
| 36 | |||
| 37 | 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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | from tests.conftest import MockApplicationContext | 3 | from conftest import MockApplicationContext |
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | @pytest.fixture | 6 | @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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import os | 3 | import os |
| 4 | from tests.conftest import MockApplicationContext | 4 | from conftest import MockApplicationContext |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | @pytest.mark.asyncio | 7 | @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 | |||
| 3 | import asyncio | 3 | import asyncio |
| 4 | import tempfile | 4 | import tempfile |
| 5 | from unittest.mock import AsyncMock, patch, MagicMock | 5 | from unittest.mock import AsyncMock, patch, MagicMock |
| 6 | from tests.conftest import MockApplicationContext | 6 | from conftest import MockApplicationContext |
| 7 | 7 | ||
| 8 | 8 | ||
| 9 | @pytest.fixture | 9 | @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 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock | ||
| 3 | import sys | ||
| 4 | sys.path.insert(0, '/home/salmon/Documents/botcode/senator-sperma/tests') | ||
| 5 | from conftest import MockApplicationContext | ||
| 6 | |||
| 7 | |||
| 8 | @pytest.fixture | ||
| 9 | def music_cog(bot): | ||
| 10 | from cogs.music.play_commands import PlayCommands | ||
| 11 | cog = PlayCommands(bot) | ||
| 12 | return cog | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.mark.slow | ||
| 16 | @pytest.mark.asyncio | ||
| 17 | async def test_music_join_stop(music_cog, bot): | ||
| 18 | """Test music join and stop commands.""" | ||
| 19 | from cogs.music.join import Join | ||
| 20 | from cogs.music.stop import Stop | ||
| 21 | |||
| 22 | join_cog = Join(bot) | ||
| 23 | stop_cog = Stop(bot) | ||
| 24 | |||
| 25 | ctx = MockApplicationContext(bot=bot) | ||
| 26 | ctx.author.voice = MagicMock() | ||
| 27 | ctx.author.voice.channel = MagicMock() | ||
| 28 | ctx.guild = MagicMock() | ||
| 29 | |||
| 30 | voice_client = AsyncMock() | ||
| 31 | ctx.voice_client = voice_client | ||
| 32 | |||
| 33 | # Test join | ||
| 34 | ctx.author.voice.channel.connect = AsyncMock(return_value=voice_client) | ||
| 35 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | ||
| 36 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | ||
| 37 | await join_cog.join.callback(join_cog, ctx) | ||
| 38 | ctx.author.voice.channel.connect.assert_called_once() | ||
| 39 | |||
| 40 | # Test stop | ||
| 41 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | ||
| 42 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | ||
| 43 | await stop_cog.stop.callback(stop_cog, ctx) | ||
| 44 | 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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | from tests.conftest import MockApplicationContext | 3 | from conftest import MockApplicationContext |
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | @pytest.fixture | 6 | @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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import os | 3 | import os |
| 4 | from tests.conftest import MockApplicationContext | 4 | from conftest import MockApplicationContext |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | @pytest.fixture | 7 | @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 | |||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock, mock_open | 2 | from unittest.mock import AsyncMock, patch, MagicMock, mock_open |
| 3 | import os | 3 | import os |
| 4 | from cogs.qr import QR | 4 | from cogs.qr import QR |
| 5 | from tests.conftest import MockApplicationContext | 5 | from conftest import MockApplicationContext |
| 6 | 6 | ||
| 7 | 7 | ||
| 8 | @pytest.fixture | 8 | @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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch | 2 | from unittest.mock import AsyncMock, patch |
| 3 | from cogs.say import Say | 3 | from cogs.say import Say |
| 4 | from tests.conftest import MockApplicationContext | 4 | from conftest import MockApplicationContext |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | @pytest.fixture | 7 | @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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import discord | 3 | import discord |
| 4 | from tests.conftest import MockApplicationContext | 4 | from conftest import MockApplicationContext |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | @pytest.fixture | 7 | @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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import sqlite3 | 3 | import sqlite3 |
| 4 | from tests.conftest import MockApplicationContext | 4 | from conftest import MockApplicationContext |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | @pytest.fixture | 7 | @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 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import MagicMock, AsyncMock, patch | ||
| 3 | from cogs.trigger import Trigger | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def trigger_cog(bot): | ||
| 8 | from cogs.utils.database import init_database | ||
| 9 | init_database() | ||
| 10 | from cogs.trigger import Trigger | ||
| 11 | cog = Trigger(bot) | ||
| 12 | return cog | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.mark.slow | ||
| 16 | @pytest.mark.asyncio | ||
| 17 | async def test_trigger_response_flow(trigger_cog): | ||
| 18 | """Test trigger creation and response in a simulated message flow.""" | ||
| 19 | cog = trigger_cog | ||
| 20 | |||
| 21 | ctx = MagicMock() | ||
| 22 | ctx.author = MagicMock() | ||
| 23 | ctx.author.id = 123 | ||
| 24 | ctx.guild = MagicMock() | ||
| 25 | ctx.guild.id = 456 | ||
| 26 | ctx.respond = AsyncMock() | ||
| 27 | |||
| 28 | # Create a trigger using the create command | ||
| 29 | await cog.create.callback(cog, ctx, name="test_trigger") | ||
| 30 | |||
| 31 | # Add a keyword and response | ||
| 32 | await cog.add.callback(cog, ctx, name="test_trigger", trigger="hello", response="world") | ||
| 33 | |||
| 34 | # Verify trigger was created - check DB | ||
| 35 | from cogs.utils.database import get_connection | ||
| 36 | with get_connection() as conn: | ||
| 37 | c = conn.cursor() | ||
| 38 | c.execute("SELECT id FROM triggers WHERE name=?", ("test_trigger",)) | ||
| 39 | result = c.fetchone() | ||
| 40 | assert result is not None | ||
| 41 | |||
| 42 | # Simulate a message that should trigger | ||
| 43 | message = MagicMock() | ||
| 44 | message.author = ctx.author | ||
| 45 | message.author.bot = False | ||
| 46 | message.guild = ctx.guild | ||
| 47 | message.content = "say hello to me" | ||
| 48 | message.channel.send = AsyncMock() # Make send awaitable | ||
| 49 | |||
| 50 | # Check if trigger matches using the static method | ||
| 51 | await Trigger.trigger(message) | ||
| 52 | |||
| 53 | # Verify message.channel.send was called with the response | ||
| 54 | 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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import discord | 3 | import discord |
| 4 | from tests.conftest import MockApplicationContext | 4 | from conftest import MockApplicationContext |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | @pytest.fixture | 7 | @pytest.fixture |
diff --git a/tests/cogs/test_uploader.py b/tests/cogs/test_uploader.py new file mode 100644 index 0000000..0777126 --- /dev/null +++ b/tests/cogs/test_uploader.py | |||
| @@ -0,0 +1,226 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import os | ||
| 4 | import discord | ||
| 5 | from conftest import MockApplicationContext, MockMessage, MockUser | ||
| 6 | |||
| 7 | |||
| 8 | @pytest.fixture | ||
| 9 | def uploader_cog(bot): | ||
| 10 | with patch('cogs.utils.temp.get_temp_dir', return_value='/tmp/test'): | ||
| 11 | from cogs.uploader import Uploader | ||
| 12 | cog = Uploader(bot) | ||
| 13 | return cog | ||
| 14 | |||
| 15 | |||
| 16 | @pytest.mark.asyncio | ||
| 17 | async def test_file_autocomplete(uploader_cog): | ||
| 18 | mock_autocomplete_ctx = MagicMock() | ||
| 19 | mock_autocomplete_ctx.value = "test" | ||
| 20 | mock_interaction = MagicMock() | ||
| 21 | mock_user = MockUser(id=12345) | ||
| 22 | mock_interaction.user = mock_user | ||
| 23 | mock_autocomplete_ctx.interaction = mock_interaction | ||
| 24 | |||
| 25 | with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: | ||
| 26 | mock_search.return_value = [ | ||
| 27 | "https://copyparty.local/testfile1.mp3", | ||
| 28 | "https://copyparty.local/testfile2.mp3" | ||
| 29 | ] | ||
| 30 | result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) | ||
| 31 | assert len(result) == 2 | ||
| 32 | assert result[0].name == "testfile1.mp3" | ||
| 33 | |||
| 34 | |||
| 35 | @pytest.mark.asyncio | ||
| 36 | async def test_file_autocomplete_empty_query(uploader_cog): | ||
| 37 | mock_autocomplete_ctx = MagicMock() | ||
| 38 | mock_autocomplete_ctx.value = "" | ||
| 39 | mock_interaction = MagicMock() | ||
| 40 | mock_interaction.user = MockUser(id=12345) | ||
| 41 | mock_autocomplete_ctx.interaction = mock_interaction | ||
| 42 | |||
| 43 | with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: | ||
| 44 | result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) | ||
| 45 | mock_search.assert_called_once_with("", max_results=25) | ||
| 46 | |||
| 47 | |||
| 48 | @pytest.mark.asyncio | ||
| 49 | async def test_download_from_url_success(uploader_cog): | ||
| 50 | with patch('requests.get') as mock_get: | ||
| 51 | mock_response = MagicMock() | ||
| 52 | mock_response.status_code = 200 | ||
| 53 | mock_response.content = b"test file content" | ||
| 54 | mock_get.return_value = mock_response | ||
| 55 | |||
| 56 | mock_file = MagicMock() | ||
| 57 | mock_file.write = AsyncMock() | ||
| 58 | |||
| 59 | mock_cm = AsyncMock() | ||
| 60 | mock_cm.__aenter__ = AsyncMock(return_value=mock_file) | ||
| 61 | mock_cm.__aexit__ = AsyncMock(return_value=None) | ||
| 62 | |||
| 63 | with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): | ||
| 64 | result = await uploader_cog._download_from_url("https://copyparty.local/file.txt") | ||
| 65 | |||
| 66 | assert result is not None | ||
| 67 | assert "file.txt" in result | ||
| 68 | mock_file.write.assert_called_once_with(b"test file content") | ||
| 69 | |||
| 70 | |||
| 71 | @pytest.mark.asyncio | ||
| 72 | async def test_download_from_url_failure(uploader_cog): | ||
| 73 | with patch('requests.get') as mock_get: | ||
| 74 | mock_response = MagicMock() | ||
| 75 | mock_response.status_code = 404 | ||
| 76 | mock_get.return_value = mock_response | ||
| 77 | |||
| 78 | result = await uploader_cog._download_from_url("https://copyparty.local/notfound.txt") | ||
| 79 | |||
| 80 | assert result is None | ||
| 81 | |||
| 82 | |||
| 83 | @pytest.mark.asyncio | ||
| 84 | async def test_upload_to_copyparty_success(uploader_cog): | ||
| 85 | mock_file = MagicMock() | ||
| 86 | mock_file.read = AsyncMock(return_value=b"test content") | ||
| 87 | |||
| 88 | mock_cm = AsyncMock() | ||
| 89 | mock_cm.__aenter__ = AsyncMock(return_value=mock_file) | ||
| 90 | mock_cm.__aexit__ = AsyncMock(return_value=None) | ||
| 91 | |||
| 92 | with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): | ||
| 93 | with patch('requests.post') as mock_post: | ||
| 94 | mock_response = MagicMock() | ||
| 95 | mock_response.status_code = 200 | ||
| 96 | mock_response.json.return_value = {"fileurl": "https://copyparty.local/uploaded.txt"} | ||
| 97 | mock_post.return_value = mock_response | ||
| 98 | |||
| 99 | with patch.dict(os.environ, {"COPYPARTY_PASSWORD": "testpass"}): | ||
| 100 | result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") | ||
| 101 | |||
| 102 | assert result == "https://copyparty.local/uploaded.txt" | ||
| 103 | |||
| 104 | |||
| 105 | @pytest.mark.asyncio | ||
| 106 | async def test_upload_to_copyparty_failure(uploader_cog): | ||
| 107 | mock_file = MagicMock() | ||
| 108 | mock_file.read = AsyncMock(return_value=b"test content") | ||
| 109 | |||
| 110 | mock_cm = AsyncMock() | ||
| 111 | mock_cm.__aenter__ = AsyncMock(return_value=mock_file) | ||
| 112 | mock_cm.__aexit__ = AsyncMock(return_value=None) | ||
| 113 | |||
| 114 | with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): | ||
| 115 | with patch('requests.post') as mock_post: | ||
| 116 | mock_response = MagicMock() | ||
| 117 | mock_response.status_code = 500 | ||
| 118 | mock_post.return_value = mock_response | ||
| 119 | |||
| 120 | result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") | ||
| 121 | |||
| 122 | assert result is None | ||
| 123 | |||
| 124 | |||
| 125 | @pytest.mark.asyncio | ||
| 126 | async def test_upload_file_no_url(uploader_cog): | ||
| 127 | uploader_cog._uploadurl = None | ||
| 128 | |||
| 129 | result = await uploader_cog._upload_file("/tmp/test.txt") | ||
| 130 | |||
| 131 | assert result is None | ||
| 132 | |||
| 133 | |||
| 134 | @pytest.mark.asyncio | ||
| 135 | async def test_message_upload_no_attachments(uploader_cog, mock_channel, mock_guild, bot): | ||
| 136 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 137 | |||
| 138 | mock_message = MagicMock() | ||
| 139 | mock_message.attachments = [] | ||
| 140 | |||
| 141 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 142 | await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) | ||
| 143 | mock_respond.assert_called_once_with("Brak załączników w wiadomości", ephemeral=True) | ||
| 144 | |||
| 145 | |||
| 146 | @pytest.mark.asyncio | ||
| 147 | async def test_message_upload_success(uploader_cog, mock_channel, mock_guild, bot): | ||
| 148 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 149 | |||
| 150 | mock_attachment = MagicMock() | ||
| 151 | mock_attachment.filename = "test.txt" | ||
| 152 | mock_attachment.save = AsyncMock() | ||
| 153 | |||
| 154 | mock_message = MagicMock() | ||
| 155 | mock_message.attachments = [mock_attachment] | ||
| 156 | |||
| 157 | with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: | ||
| 158 | mock_upload.return_value = "https://copyparty.local/uploaded.txt" | ||
| 159 | |||
| 160 | with patch('os.path.exists', return_value=True): | ||
| 161 | with patch('os.remove'): | ||
| 162 | with patch.object(ctx, 'defer', new_callable=AsyncMock): | ||
| 163 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 164 | await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) | ||
| 165 | mock_respond.assert_called_once() | ||
| 166 | assert "Wysłano" in str(mock_respond.call_args) | ||
| 167 | |||
| 168 | |||
| 169 | @pytest.mark.asyncio | ||
| 170 | async def test_message_upload_all_failed(uploader_cog, mock_channel, mock_guild, bot): | ||
| 171 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 172 | |||
| 173 | mock_attachment = MagicMock() | ||
| 174 | mock_attachment.filename = "test.txt" | ||
| 175 | mock_attachment.save = AsyncMock() | ||
| 176 | |||
| 177 | mock_message = MagicMock() | ||
| 178 | mock_message.attachments = [mock_attachment] | ||
| 179 | |||
| 180 | with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: | ||
| 181 | mock_upload.return_value = None | ||
| 182 | |||
| 183 | with patch('os.path.exists', return_value=True): | ||
| 184 | with patch('os.remove'): | ||
| 185 | with patch.object(ctx, 'defer', new_callable=AsyncMock): | ||
| 186 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 187 | await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) | ||
| 188 | mock_respond.assert_called_once_with("Nie udało się wysłać żadnych plików", ephemeral=True) | ||
| 189 | |||
| 190 | |||
| 191 | @pytest.mark.asyncio | ||
| 192 | async def test_upload_slash_command_success(uploader_cog, mock_channel, mock_guild, bot): | ||
| 193 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 194 | |||
| 195 | mock_attachment = MagicMock() | ||
| 196 | mock_attachment.filename = "test.txt" | ||
| 197 | mock_attachment.save = AsyncMock() | ||
| 198 | |||
| 199 | with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: | ||
| 200 | mock_upload.return_value = "https://copyparty.local/uploaded.txt" | ||
| 201 | |||
| 202 | with patch('os.path.exists', return_value=True): | ||
| 203 | with patch('os.remove'): | ||
| 204 | with patch.object(ctx, 'defer', new_callable=AsyncMock): | ||
| 205 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 206 | await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) | ||
| 207 | mock_respond.assert_called_once_with("https://copyparty.local/uploaded.txt") | ||
| 208 | |||
| 209 | |||
| 210 | @pytest.mark.asyncio | ||
| 211 | async def test_upload_slash_command_failure(uploader_cog, mock_channel, mock_guild, bot): | ||
| 212 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 213 | |||
| 214 | mock_attachment = MagicMock() | ||
| 215 | mock_attachment.filename = "test.txt" | ||
| 216 | mock_attachment.save = AsyncMock() | ||
| 217 | |||
| 218 | with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: | ||
| 219 | mock_upload.return_value = None | ||
| 220 | |||
| 221 | with patch('os.path.exists', return_value=True): | ||
| 222 | with patch('os.remove'): | ||
| 223 | with patch.object(ctx, 'defer', new_callable=AsyncMock): | ||
| 224 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 225 | await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) | ||
| 226 | 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..17ff839 100644 --- a/tests/cogs/test_vclogs.py +++ b/tests/cogs/test_vclogs.py | |||
| @@ -1,7 +1,6 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import json | 3 | from conftest import MockApplicationContext |
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | 4 | ||
| 6 | 5 | ||
| 7 | def test_format_duration(): | 6 | def test_format_duration(): |
| @@ -13,20 +12,26 @@ def test_format_duration(): | |||
| 13 | 12 | ||
| 14 | 13 | ||
| 15 | def test_load_activity_data(): | 14 | def test_load_activity_data(): |
| 16 | with patch('builtins.open', MagicMock()): | 15 | with patch("cogs.vclogs.get_connection") as mock_conn: |
| 17 | with patch('os.path.exists', return_value=True): | 16 | mock_cursor = MagicMock() |
| 18 | with patch('json.load', return_value={"123": {"active": True}}): | 17 | mock_conn.return_value.cursor.return_value = mock_cursor |
| 19 | from cogs.vclogs import load_activity_data | 18 | mock_conn.return_value.close.return_value = None |
| 20 | data = load_activity_data() | 19 | mock_cursor.fetchall.return_value = [] |
| 21 | assert isinstance(data, dict) | 20 | |
| 21 | from cogs.vclogs import load_activity_data | ||
| 22 | data = load_activity_data() | ||
| 23 | assert isinstance(data, dict) | ||
| 22 | 24 | ||
| 23 | 25 | ||
| 24 | def test_save_activity_data(): | 26 | def test_save_activity_data(): |
| 25 | with patch('builtins.open', MagicMock()) as mock_open: | 27 | with patch("cogs.vclogs.get_connection") as mock_conn: |
| 26 | with patch('json.dump'): | 28 | mock_cursor = MagicMock() |
| 27 | from cogs.vclogs import save_activity_data | 29 | mock_conn.return_value.cursor.return_value = mock_cursor |
| 28 | save_activity_data({"123": {"active": True}}) | 30 | mock_conn.return_value.commit.return_value = None |
| 29 | mock_open.assert_called_once() | 31 | mock_conn.return_value.close.return_value = None |
| 32 | |||
| 33 | from cogs.vclogs import save_activity_data | ||
| 34 | save_activity_data({"123": {"active": True}}) | ||
| 30 | 35 | ||
| 31 | 36 | ||
| 32 | @pytest.fixture | 37 | @pytest.fixture |
| @@ -80,4 +85,4 @@ async def test_handle_state_change_mute_false(vclogs_cog): | |||
| 80 | 85 | ||
| 81 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None) | 86 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None) |
| 82 | 87 | ||
| 83 | assert "zrobił jajecznice" in tts_text | 88 | assert "zrobił jajecznice" in tts_text \ No newline at end of file |
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 @@ | |||
| 1 | import pytest | ||
| 2 | import asyncio | ||
| 3 | from unittest.mock import MagicMock, AsyncMock, patch | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def vclogs_cog(bot): | ||
| 8 | from cogs.vclogs import VcLogs | ||
| 9 | cog = VcLogs(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.mark.slow | ||
| 14 | @pytest.mark.asyncio | ||
| 15 | async def test_full_voice_session_tracking(vclogs_cog, monkeypatch): | ||
| 16 | """Test complete voice session with AFK transitions using real time sleeps.""" | ||
| 17 | cog = vclogs_cog | ||
| 18 | |||
| 19 | # Set up environment | ||
| 20 | monkeypatch.setenv("STATUS_CHANNEL_ID", "123456") | ||
| 21 | |||
| 22 | member = MagicMock() | ||
| 23 | member.id = 123 | ||
| 24 | member.guild.id = 456 | ||
| 25 | member.bot = False | ||
| 26 | member.display_name = "TestUser" | ||
| 27 | member.guild.get_member.return_value = member | ||
| 28 | |||
| 29 | # Create a mock log channel | ||
| 30 | log_channel = MagicMock() | ||
| 31 | log_channel.send = AsyncMock(return_value=MagicMock()) | ||
| 32 | |||
| 33 | # Mock bot.get_channel to return our log channel | ||
| 34 | with patch.object(vclogs_cog.bot, 'get_channel', return_value=log_channel): | ||
| 35 | # Join voice | ||
| 36 | before = MagicMock() | ||
| 37 | before.channel = None | ||
| 38 | after_join = MagicMock() | ||
| 39 | after_join.channel = MagicMock() | ||
| 40 | after_join.channel.id = 789 | ||
| 41 | after_join.channel.name = "Test Voice" | ||
| 42 | after_join.self_deaf = False | ||
| 43 | after_join.self_mute = False | ||
| 44 | after_join.deaf = False | ||
| 45 | after_join.mute = False | ||
| 46 | await cog.on_voice_state_update(member, before, after_join) | ||
| 47 | |||
| 48 | await asyncio.sleep(2) # Simulate 2s active | ||
| 49 | |||
| 50 | # Go AFK (self_deaf) | ||
| 51 | before_afk = after_join | ||
| 52 | after_afk = MagicMock() | ||
| 53 | after_afk.channel = before_afk.channel | ||
| 54 | after_afk.self_deaf = True | ||
| 55 | after_afk.self_mute = False | ||
| 56 | after_afk.deaf = True | ||
| 57 | after_afk.mute = False | ||
| 58 | await cog.on_voice_state_update(member, before_afk, after_afk) | ||
| 59 | |||
| 60 | await asyncio.sleep(3) # Simulate 3s AFK | ||
| 61 | |||
| 62 | # Return from AFK | ||
| 63 | before_back = after_afk | ||
| 64 | after_back = MagicMock() | ||
| 65 | after_back.channel = before_back.channel | ||
| 66 | after_back.self_deaf = False | ||
| 67 | after_back.self_mute = False | ||
| 68 | after_back.deaf = False | ||
| 69 | after_back.mute = False | ||
| 70 | await cog.on_voice_state_update(member, before_back, after_back) | ||
| 71 | |||
| 72 | await asyncio.sleep(1) # Simulate 1s active | ||
| 73 | |||
| 74 | # Leave voice | ||
| 75 | before_leave = after_back | ||
| 76 | after_leave = MagicMock() | ||
| 77 | after_leave.channel = None | ||
| 78 | after_leave.self_deaf = False | ||
| 79 | after_leave.self_mute = False | ||
| 80 | await cog.on_voice_state_update(member, before_leave, after_leave) | ||
| 81 | |||
| 82 | # Verify activity was saved (check DB) | ||
| 83 | from cogs.vclogs import load_activity_data | ||
| 84 | activity_data = load_activity_data() | ||
| 85 | # After leaving, data should be deleted, so check the log channel was called | ||
| 86 | 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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | import asyncio | 3 | import asyncio |
| 4 | from tests.conftest import MockApplicationContext | 4 | from conftest import MockApplicationContext |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | @pytest.mark.asyncio | 7 | @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 @@ | |||
| 1 | import pytest | 1 | import pytest |
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | 2 | from unittest.mock import AsyncMock, patch, MagicMock |
| 3 | from cogs.web import Web | 3 | from cogs.web import Web |
| 4 | from tests.conftest import MockApplicationContext | 4 | from conftest import MockApplicationContext |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | @pytest.fixture | 7 | @pytest.fixture |
diff --git a/tests/conftest.py b/tests/conftest.py index e9f0f6f..6b7c3bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py | |||
| @@ -1,10 +1,32 @@ | |||
| 1 | import asyncio | 1 | import asyncio |
| 2 | import os | ||
| 3 | import sys | ||
| 4 | import tempfile | ||
| 2 | from unittest.mock import AsyncMock, MagicMock | 5 | from unittest.mock import AsyncMock, MagicMock |
| 3 | import pytest | 6 | import pytest |
| 4 | 7 | ||
| 5 | import discord | 8 | import discord |
| 6 | from discord.ext import commands | 9 | from discord.ext import commands |
| 7 | 10 | ||
| 11 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.fixture(autouse=True, scope="function") | ||
| 15 | def setup_test_env(monkeypatch): | ||
| 16 | test_db = os.path.join(tempfile.gettempdir(), "test_senator.db") | ||
| 17 | if os.path.exists(test_db): | ||
| 18 | os.remove(test_db) | ||
| 19 | monkeypatch.setenv("SENATOR_DB_PATH", test_db) | ||
| 20 | reload_database() | ||
| 21 | yield | ||
| 22 | if os.path.exists(test_db): | ||
| 23 | os.remove(test_db) | ||
| 24 | |||
| 25 | |||
| 26 | def reload_database(): | ||
| 27 | import cogs.utils.database as db_module | ||
| 28 | db_module._initialized = False | ||
| 29 | |||
| 8 | 30 | ||
| 9 | class MockUser: | 31 | class MockUser: |
| 10 | def __init__(self, id=12345, name="testuser", discriminator="0001"): | 32 | def __init__(self, id=12345, name="testuser", discriminator="0001"): |
