diff options
| author | salmonik <salmonik@noreply.codeberg.org> | 2026-04-19 18:10:09 +0200 |
|---|---|---|
| committer | salmonik <salmonik@noreply.codeberg.org> | 2026-04-19 18:10:09 +0200 |
| commit | f8e1cf36e1efa0602bf2e450835f2a67654762a6 (patch) | |
| tree | fe6b12226d7eebe2a0dc6525de3414d0c3718cc8 | |
| parent | add support for x^2+y^2=0,4 and alike (diff) | |
| parent | fix: proper aiohttp async context manager mocking in test (diff) | |
| download | senator-sperma-f8e1cf36e1efa0602bf2e450835f2a67654762a6.tar.gz senator-sperma-f8e1cf36e1efa0602bf2e450835f2a67654762a6.zip | |
Merge pull request 'massive changes' (#3) from dev into main
Reviewed-on: https://codeberg.org/salmonik/senator-sperma/pulls/3
stable enough
Diffstat (limited to '')
42 files changed, 2103 insertions, 292 deletions
| @@ -3,4 +3,5 @@ __pycache__/ | |||
| 3 | data/ | 3 | data/ |
| 4 | .idea/ | 4 | .idea/ |
| 5 | .venv/ | 5 | .venv/ |
| 6 | .zed | 6 | .zed/ |
| 7 | .pytest_cache/ | ||
diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..44be9d5 --- /dev/null +++ b/AGENTS.md | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | # Agent Instructions for Senator Sperma | ||
| 2 | |||
| 3 | ## Required Setup | ||
| 4 | - Requires **Python 3.13** (3.14 not supported - py-cord incompatibility) | ||
| 5 | - System dependencies: `ffmpeg`, `zbar` library (installed system-wide) | ||
| 6 | - Bot token in `.env` file (copy from `.env.example`) | ||
| 7 | |||
| 8 | ## Commands | ||
| 9 | - Run bot: `.venv/bin/python main.py` | ||
| 10 | - Type check: `.venv/bin/basedpyright` | ||
| 11 | - Test: `.venv/bin/pytest` | ||
| 12 | |||
| 13 | ## Architecture | ||
| 14 | - Slash commands defined as cogs in `cogs/*.py` | ||
| 15 | - Discord API uses `discord.ApplicationContext` | ||
| 16 | - Music auto-complete queries copyparty server (`COPYPARTY_URL` config) | ||
| 17 | |||
| 18 | ## Environment | ||
| 19 | - Copyparty URL for read-only access is COPYPARTY_URL | ||
| 20 | - Copyparty URL for write-only access is COPYPARTY_URL_UPLOAD | ||
| 21 | - Above should be accessed using COPYPARTY_PASSWORD as a cookie | ||
| 22 | - Channel for vclogs and alike is STATUS_CHANNEL_ID | ||
| 23 | - Trigger word for assistant is ASSISTANT_KEYWORD | ||
| 24 | |||
| 25 | ## Testing | ||
| 26 | - Tests in `tests/cogs/test_*.py` | ||
| 27 | - Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` | ||
| 28 | - `conftest.py` provides fixtures for mocking Discord objects | ||
| @@ -83,7 +83,6 @@ All settings are optional - defaults are provided if not set. | |||
| 83 | - `/download` - Download video/audio | 83 | - `/download` - Download video/audio |
| 84 | - `/tts` - Text to speech | 84 | - `/tts` - Text to speech |
| 85 | - `/qr_koduj` / `qr_dekoduj` - QR encode/decode | 85 | - `/qr_koduj` / `qr_dekoduj` - QR encode/decode |
| 86 | - `/funkcja` - Math plot | ||
| 87 | - `/kompresja` - Compress images | 86 | - `/kompresja` - Compress images |
| 88 | - `/konwersja` - Convert audio/video | 87 | - `/konwersja` - Convert audio/video |
| 89 | 88 | ||
| @@ -7,7 +7,7 @@ import os | |||
| 7 | import discord | 7 | import discord |
| 8 | from discord.ext import commands | 8 | from discord.ext import commands |
| 9 | import logging | 9 | import logging |
| 10 | import requests | 10 | import httpx |
| 11 | from gtts import gTTS | 11 | from gtts import gTTS |
| 12 | from cogs.music import audio_playback | 12 | from cogs.music import audio_playback |
| 13 | import io | 13 | import io |
| @@ -52,23 +52,24 @@ class AI(commands.Cog): | |||
| 52 | ], | 52 | ], |
| 53 | 53 | ||
| 54 | } | 54 | } |
| 55 | response = requests.post(url=url, headers=headers, json=payload) | 55 | async with httpx.AsyncClient() as client: |
| 56 | response_data = response.json() | 56 | response = await client.post(url=url, headers=headers, json=payload) |
| 57 | logger.info(response_data) | 57 | response_data = response.json() |
| 58 | if not response_data["choices"]: | 58 | logger.info(response_data) |
| 59 | await ctx.send_followup("bot siem zesroł") | 59 | if not response_data["choices"]: |
| 60 | else: | 60 | await ctx.send_followup("bot siem zesroł") |
| 61 | response_text = response_data["choices"][0]["message"]["content"] | ||
| 62 | if tts: | ||
| 63 | # Generate TTS | ||
| 64 | tts_obj = gTTS(text=response_text, lang='pl') | ||
| 65 | tts_buffer = io.BytesIO() | ||
| 66 | tts_obj.write_to_fp(tts_buffer) | ||
| 67 | tts_buffer.seek(0) | ||
| 68 | await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) | ||
| 69 | |||
| 70 | else: | 61 | else: |
| 71 | await ctx.send_followup(response_text) | 62 | response_text = response_data["choices"][0]["message"]["content"] |
| 63 | if tts: | ||
| 64 | # Generate TTS | ||
| 65 | tts_obj = gTTS(text=response_text, lang='pl') | ||
| 66 | tts_buffer = io.BytesIO() | ||
| 67 | tts_obj.write_to_fp(tts_buffer) | ||
| 68 | tts_buffer.seek(0) | ||
| 69 | await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) | ||
| 70 | |||
| 71 | else: | ||
| 72 | await ctx.send_followup(response_text) | ||
| 72 | 73 | ||
| 73 | 74 | ||
| 74 | 75 | ||
diff --git a/cogs/assistant.py b/cogs/assistant.py index 71f5150..21e4989 100644 --- a/cogs/assistant.py +++ b/cogs/assistant.py | |||
| @@ -6,7 +6,7 @@ | |||
| 6 | import re | 6 | import re |
| 7 | import discord | 7 | import discord |
| 8 | from discord.ext import commands | 8 | from discord.ext import commands |
| 9 | import requests | 9 | import httpx |
| 10 | import json | 10 | import json |
| 11 | import logging | 11 | import logging |
| 12 | import shutil | 12 | import shutil |
| @@ -133,11 +133,12 @@ class Jarvis(commands.Cog): | |||
| 133 | 133 | ||
| 134 | @staticmethod | 134 | @staticmethod |
| 135 | async def _make_api_request(payload, headers): | 135 | async def _make_api_request(payload, headers): |
| 136 | url = "https://openrouter.ai/api/v1/chat/completions" | 136 | async with httpx.AsyncClient() as client: |
| 137 | response = requests.post(url=url, headers=headers, json=payload) | 137 | url = "https://openrouter.ai/api/v1/chat/completions" |
| 138 | response_data = response.json() | 138 | response = await client.post(url=url, headers=headers, json=payload) |
| 139 | logger.info(response_data) | 139 | response_data = response.json() |
| 140 | return response_data | 140 | logger.info(response_data) |
| 141 | return response_data | ||
| 141 | 142 | ||
| 142 | @staticmethod | 143 | @staticmethod |
| 143 | def _parse_response(response_data): | 144 | def _parse_response(response_data): |
diff --git a/cogs/compression.py b/cogs/compression.py index 150239f..acc9eda 100644 --- a/cogs/compression.py +++ b/cogs/compression.py | |||
| @@ -275,6 +275,7 @@ class Compression(commands.Cog): | |||
| 275 | if file_type == "Image": | 275 | if file_type == "Image": |
| 276 | command, output_path = command | 276 | command, output_path = command |
| 277 | elif file_type == "Audio": | 277 | elif file_type == "Audio": |
| 278 | # Audio output path remains unchanged - already processed in _process_audio | ||
| 278 | pass | 279 | pass |
| 279 | 280 | ||
| 280 | success = await self._execute_compression(command) | 281 | success = await self._execute_compression(command) |
diff --git a/cogs/cron.py b/cogs/cron.py index 6b83866..da8127b 100644 --- a/cogs/cron.py +++ b/cogs/cron.py | |||
| @@ -74,7 +74,7 @@ class Cron(commands.Cog): | |||
| 74 | prev_time = cron.get_prev(datetime) | 74 | prev_time = cron.get_prev(datetime) |
| 75 | time_diff = abs((now - prev_time).total_seconds()) | 75 | time_diff = abs((now - prev_time).total_seconds()) |
| 76 | result = time_diff <= 30 | 76 | result = time_diff <= 30 |
| 77 | logger.info( | 77 | logger.debug( |
| 78 | f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}" | 78 | f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}" |
| 79 | ) | 79 | ) |
| 80 | return result | 80 | return result |
| @@ -92,13 +92,13 @@ class Cron(commands.Cog): | |||
| 92 | 92 | ||
| 93 | channel = self.bot.get_channel(channel_id) | 93 | channel = self.bot.get_channel(channel_id) |
| 94 | if not channel: | 94 | if not channel: |
| 95 | logger.warning(f"Channel {channel_id} not found for task {task['id']}") | 95 | logger.warning(f"Channel {channel_id} not found for task ID: {task.get('id')}") |
| 96 | return | 96 | return |
| 97 | 97 | ||
| 98 | logger.info(f"Found channel {channel} (type: {type(channel).__name__})") | 98 | logger.info(f"Found channel {channel} (type: {type(channel).__name__})") |
| 99 | 99 | ||
| 100 | perms = channel.permissions_for(channel.guild.me) | 100 | perms = channel.permissions_for(channel.guild.me) |
| 101 | logger.info( | 101 | logger.debug( |
| 102 | f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}" | 102 | f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}" |
| 103 | ) | 103 | ) |
| 104 | 104 | ||
| @@ -151,7 +151,7 @@ class Cron(commands.Cog): | |||
| 151 | message = task["message"] | 151 | message = task["message"] |
| 152 | logger.info(f"Message to send: '{message}' | Channel: {channel.id}") | 152 | logger.info(f"Message to send: '{message}' | Channel: {channel.id}") |
| 153 | await channel.send(message) | 153 | await channel.send(message) |
| 154 | logger.info("Message sent successfully") | 154 | logger.debug("Message sent successfully") |
| 155 | 155 | ||
| 156 | async def audio_autocomplete(self, ctx: discord.AutocompleteContext): | 156 | async def audio_autocomplete(self, ctx: discord.AutocompleteContext): |
| 157 | """Autocomplete for audio files from file_search""" | 157 | """Autocomplete for audio files from file_search""" |
diff --git a/cogs/downloader.py b/cogs/downloader.py index d364b3a..36bd723 100644 --- a/cogs/downloader.py +++ b/cogs/downloader.py | |||
| @@ -195,7 +195,7 @@ class Downloader(commands.Cog): | |||
| 195 | } | 195 | } |
| 196 | 196 | ||
| 197 | @staticmethod | 197 | @staticmethod |
| 198 | def _create_error_response(content: str, details: str, temp_dir: str) -> dict: | 198 | def _create_error_response(content: str, details: str, temp_dir: str | None) -> dict: |
| 199 | return { | 199 | return { |
| 200 | "status": "error", | 200 | "status": "error", |
| 201 | "type": "error", | 201 | "type": "error", |
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py index 1267fa7..28c6118 100644 --- a/cogs/music/audio_playback.py +++ b/cogs/music/audio_playback.py | |||
| @@ -60,17 +60,18 @@ class AudioPlaybackManager: | |||
| 60 | """Connect or move to voice channel. Returns voice client.""" | 60 | """Connect or move to voice channel. Returns voice client.""" |
| 61 | try: | 61 | try: |
| 62 | # Check if vc is valid and actually connected | 62 | # Check if vc is valid and actually connected |
| 63 | is_connected = vc is not None and vc.is_connected() | 63 | if vc is not None and vc.is_connected(): |
| 64 | # Connect to voice channel if not connected | 64 | # Already connected - check if need to move |
| 65 | if not is_connected: | 65 | if vc.channel != target_channel: |
| 66 | logger.info(f"Connecting to voice channel: {target_channel.name}") | 66 | logger.info(f"Moving to voice channel: {target_channel.name}") |
| 67 | vc = await target_channel.connect() | 67 | await vc.move_to(target_channel) |
| 68 | logger.info(f"Connected to voice channel: {target_channel.name}") | 68 | logger.info(f"Moved to voice channel: {target_channel.name}") |
| 69 | # Move to user's channel if in a different one | 69 | return vc |
| 70 | elif vc.channel != target_channel: | 70 | |
| 71 | logger.info(f"Moving to voice channel: {target_channel.name}") | 71 | # Not connected - connect now |
| 72 | await vc.move_to(target_channel) | 72 | logger.info(f"Connecting to voice channel: {target_channel.name}") |
| 73 | logger.info(f"Moved to voice channel: {target_channel.name}") | 73 | vc = await target_channel.connect() |
| 74 | logger.info(f"Connected to voice channel: {target_channel.name}") | ||
| 74 | return vc | 75 | return vc |
| 75 | except Exception as e: | 76 | except Exception as e: |
| 76 | logger.error(f"Error connecting to voice channel: {str(e)}") | 77 | logger.error(f"Error connecting to voice channel: {str(e)}") |
| @@ -138,15 +139,35 @@ class AudioPlaybackManager: | |||
| 138 | @staticmethod | 139 | @staticmethod |
| 139 | async def execute_playback(ctx, source, display_name, is_url): | 140 | async def execute_playback(ctx, source, display_name, is_url): |
| 140 | """Execute the actual audio playback.""" | 141 | """Execute the actual audio playback.""" |
| 141 | vc = ctx.voice_client | ||
| 142 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) | 142 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) |
| 143 | if not target_channel: | 143 | if not target_channel: |
| 144 | return False | 144 | return False |
| 145 | 145 | ||
| 146 | # Get existing voice client from bot or connect | ||
| 147 | guild = ctx.guild | ||
| 148 | vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) | ||
| 149 | |||
| 146 | try: | 150 | try: |
| 147 | vc = await AudioPlaybackManager.connect_to_voice_channel( | 151 | # Handle various connection states |
| 148 | ctx, vc, target_channel | 152 | if vc is None: |
| 149 | ) | 153 | logger.info("No existing voice client, connecting...") |
| 154 | try: | ||
| 155 | vc = await target_channel.connect() | ||
| 156 | except discord.ClientException: | ||
| 157 | vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) | ||
| 158 | elif not vc.is_connected(): | ||
| 159 | logger.info("Voice client exists but disconnected, reconnecting...") | ||
| 160 | try: | ||
| 161 | await vc.disconnect() | ||
| 162 | except Exception: | ||
| 163 | pass | ||
| 164 | try: | ||
| 165 | vc = await target_channel.connect() | ||
| 166 | except discord.ClientException: | ||
| 167 | vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) | ||
| 168 | elif vc.channel != target_channel: | ||
| 169 | logger.info(f"Moving to voice channel: {target_channel.name}") | ||
| 170 | await vc.move_to(target_channel) | ||
| 150 | 171 | ||
| 151 | # Stop any currently playing audio | 172 | # Stop any currently playing audio |
| 152 | if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] | 173 | if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] |
| @@ -171,7 +192,7 @@ class AudioPlaybackManager: | |||
| 171 | # Play the audio | 192 | # Play the audio |
| 172 | vc.play(audio_source, after=after_playing) | 193 | vc.play(audio_source, after=after_playing) |
| 173 | logger.info(f"Playing: {final_display_name}") | 194 | logger.info(f"Playing: {final_display_name}") |
| 174 | await ctx.respond(f"grom: {final_display_name}") | 195 | await ctx.followup.send(f"grom: {final_display_name}") |
| 175 | 196 | ||
| 176 | # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds | 197 | # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds |
| 177 | await asyncio.sleep(5) | 198 | await asyncio.sleep(5) |
| @@ -195,7 +216,7 @@ class AudioPlaybackManager: | |||
| 195 | 216 | ||
| 196 | except Exception as e: | 217 | except Exception as e: |
| 197 | logger.error(f"Error in play: {str(e)}", exc_info=True) | 218 | logger.error(f"Error in play: {str(e)}", exc_info=True) |
| 198 | await ctx.respond(f"eror: {str(e)}", ephemeral=True) | 219 | await ctx.followup.send(f"eror: {str(e)}", ephemeral=True) |
| 199 | return False | 220 | return False |
| 200 | 221 | ||
| 201 | @staticmethod | 222 | @staticmethod |
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index fd5113a..5373436 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -6,18 +6,25 @@ | |||
| 6 | import logging | 6 | import logging |
| 7 | import os | 7 | import os |
| 8 | import urllib.parse | 8 | import urllib.parse |
| 9 | import asyncio | ||
| 9 | from os import PathLike | 10 | from os import PathLike |
| 10 | from typing import Awaitable, Callable, List, Tuple | 11 | from typing import Awaitable, Callable, List, Tuple |
| 12 | import re | ||
| 13 | from yt_dlp import YoutubeDL | ||
| 11 | 14 | ||
| 12 | import discord | 15 | import discord |
| 13 | from discord.commands import OptionChoice | 16 | from discord.commands import OptionChoice |
| 14 | from discord.ext import commands | 17 | from discord.ext import commands |
| 15 | 18 | ||
| 16 | from cogs.music import audio_playback, media_processing | 19 | from cogs.music import audio_playback, media_processing |
| 17 | from cogs.utils import file_search as search_utils | 20 | from cogs.utils.file_search import UniversalSearcher |
| 18 | 21 | ||
| 19 | logger = logging.getLogger(__name__) | 22 | logger = logging.getLogger(__name__) |
| 20 | 23 | ||
| 24 | EXTERNAL_URL_PATTERN = re.compile( | ||
| 25 | r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" | ||
| 26 | ) | ||
| 27 | |||
| 21 | 28 | ||
| 22 | class PlayCommands(commands.Cog): | 29 | class PlayCommands(commands.Cog): |
| 23 | """Discord slash commands for music playback.""" | 30 | """Discord slash commands for music playback.""" |
| @@ -88,9 +95,12 @@ class PlayCommands(commands.Cog): | |||
| 88 | self, ctx: discord.AutocompleteContext | 95 | self, ctx: discord.AutocompleteContext |
| 89 | ) -> List[OptionChoice]: | 96 | ) -> List[OptionChoice]: |
| 90 | """Autocomplete handler for search queries.""" | 97 | """Autocomplete handler for search queries.""" |
| 98 | if ctx.value.lower().startswith(("http://", "https://")): | ||
| 99 | return [] | ||
| 100 | |||
| 91 | query = ctx.value.lower() | 101 | query = ctx.value.lower() |
| 92 | try: | 102 | try: |
| 93 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( | 103 | results = await UniversalSearcher.search_copyparty_autocomplete_audio( |
| 94 | query, max_results=25 | 104 | query, max_results=25 |
| 95 | ) | 105 | ) |
| 96 | choices = [] | 106 | choices = [] |
| @@ -139,9 +149,10 @@ class PlayCommands(commands.Cog): | |||
| 139 | local_path = os.path.join(self.temp_dir, filename) | 149 | local_path = os.path.join(self.temp_dir, filename) |
| 140 | os.makedirs(self.temp_dir, exist_ok=True) | 150 | os.makedirs(self.temp_dir, exist_ok=True) |
| 141 | 151 | ||
| 152 | loop = asyncio.get_event_loop() | ||
| 142 | with open(local_path, "wb") as f: | 153 | with open(local_path, "wb") as f: |
| 143 | async for chunk in response.content.iter_chunked(8192): | 154 | async for chunk in response.content.iter_chunked(8192): |
| 144 | f.write(chunk) | 155 | await loop.run_in_executor(None, f.write, chunk) |
| 145 | 156 | ||
| 146 | compressed_path = await media_processing.AudioCompressor.compress( | 157 | compressed_path = await media_processing.AudioCompressor.compress( |
| 147 | ctx, local_path | 158 | ctx, local_path |
| @@ -162,6 +173,96 @@ class PlayCommands(commands.Cog): | |||
| 162 | except Exception: | 173 | except Exception: |
| 163 | pass | 174 | pass |
| 164 | 175 | ||
| 176 | async def _handle_external_url_play( | ||
| 177 | self, ctx: discord.ApplicationContext, url: str | ||
| 178 | ) -> None: | ||
| 179 | """Download from external URL, upload to copyparty, then play.""" | ||
| 180 | local_path = None | ||
| 181 | try: | ||
| 182 | ytdl_opts = { | ||
| 183 | "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"), | ||
| 184 | "postprocessors": [ | ||
| 185 | { | ||
| 186 | "key": "FFmpegExtractAudio", | ||
| 187 | "preferredcodec": "mp3", | ||
| 188 | "preferredquality": "192", | ||
| 189 | } | ||
| 190 | ], | ||
| 191 | "force_ipv4": True, | ||
| 192 | } | ||
| 193 | |||
| 194 | loop = asyncio.get_event_loop() | ||
| 195 | with YoutubeDL(ytdl_opts) as ydl: | ||
| 196 | info_dict = await loop.run_in_executor( | ||
| 197 | None, lambda: ydl.extract_info(url, download=True) | ||
| 198 | ) | ||
| 199 | title = info_dict.get("title", "nieznany") | ||
| 200 | base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] | ||
| 201 | local_path = f"{base_filename}.mp3" | ||
| 202 | |||
| 203 | if not os.path.exists(local_path): | ||
| 204 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) | ||
| 205 | return | ||
| 206 | |||
| 207 | upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) | ||
| 208 | |||
| 209 | await audio_playback.AudioPlaybackManager.play( | ||
| 210 | ctx, local_path, delete_after_play=True | ||
| 211 | ) | ||
| 212 | |||
| 213 | try: | ||
| 214 | await asyncio.wait_for(upload_task, timeout=30.0) | ||
| 215 | except asyncio.TimeoutError: | ||
| 216 | logger.warning("Upload timed out") | ||
| 217 | except Exception as e: | ||
| 218 | logger.error(f"Error downloading external URL: {str(e)}", exc_info=True) | ||
| 219 | if local_path and os.path.exists(local_path): | ||
| 220 | try: | ||
| 221 | os.remove(local_path) | ||
| 222 | except Exception: | ||
| 223 | pass | ||
| 224 | await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True) | ||
| 225 | |||
| 226 | async def _upload_to_copyparty_async(self, file_path: str) -> None: | ||
| 227 | """Upload file to copyparty asynchronously (fire and forget).""" | ||
| 228 | import aiohttp | ||
| 229 | |||
| 230 | try: | ||
| 231 | logger.info(f"Starting async upload: {file_path}") | ||
| 232 | baseurl = os.getenv("COPYPARTY_URL_UPLOAD") | ||
| 233 | password = os.getenv("COPYPARTY_PASSWORD", "") | ||
| 234 | |||
| 235 | logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") | ||
| 236 | |||
| 237 | if not baseurl: | ||
| 238 | logger.error("COPYPARTY_URL_UPLOAD not set!") | ||
| 239 | return | ||
| 240 | |||
| 241 | with open(file_path, "rb") as f: | ||
| 242 | content = f.read() | ||
| 243 | |||
| 244 | files = aiohttp.FormData() | ||
| 245 | files.add_field( | ||
| 246 | "f", | ||
| 247 | content, | ||
| 248 | filename=os.path.basename(file_path), | ||
| 249 | content_type="application/octet-stream", | ||
| 250 | ) | ||
| 251 | headers = {"Cookie": f"cppwd={password}"} | ||
| 252 | params = {"j": "1", "ck": "b2"} | ||
| 253 | |||
| 254 | logger.info(f"Posting to {baseurl}") | ||
| 255 | async with aiohttp.ClientSession() as session: | ||
| 256 | async with session.post(baseurl, data=files, headers=headers, params=params) as response: | ||
| 257 | text = await response.text() | ||
| 258 | logger.info(f"Upload response: {response.status} - {text[:200] if text else 'empty'}") | ||
| 259 | |||
| 260 | if response.status in [200, 201]: | ||
| 261 | result = await response.json() | ||
| 262 | logger.info(f"Uploaded to copyparty: {result.get('fileurl')}") | ||
| 263 | except Exception as e: | ||
| 264 | logger.error(f"Async upload failed: {e}", exc_info=True) | ||
| 265 | |||
| 165 | async def _handle_local_file_play( | 266 | async def _handle_local_file_play( |
| 166 | self, ctx: discord.ApplicationContext, file: str, compressed: bool | 267 | self, ctx: discord.ApplicationContext, file: str, compressed: bool |
| 167 | ) -> None: | 268 | ) -> None: |
| @@ -209,7 +310,10 @@ class PlayCommands(commands.Cog): | |||
| 209 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) | 310 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) |
| 210 | else: | 311 | else: |
| 211 | try: | 312 | try: |
| 212 | await self._handle_local_file_play(ctx, melodia, compressed) | 313 | if EXTERNAL_URL_PATTERN.match(melodia.lower()): |
| 314 | await self._handle_external_url_play(ctx, melodia) | ||
| 315 | else: | ||
| 316 | await self._handle_local_file_play(ctx, melodia, compressed) | ||
| 213 | except Exception as e: | 317 | except Exception as e: |
| 214 | logger.error(f"Error playing: {str(e)}", exc_info=True) | 318 | logger.error(f"Error playing: {str(e)}", exc_info=True) |
| 215 | await ctx.respond( | 319 | await ctx.respond( |
diff --git a/cogs/plot.py b/cogs/plot.py deleted file mode 100644 index 4fcde44..0000000 --- a/cogs/plot.py +++ /dev/null | |||
| @@ -1,188 +0,0 @@ | |||
| 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 asyncio | ||
| 7 | import logging | ||
| 8 | import math | ||
| 9 | import os | ||
| 10 | import re | ||
| 11 | |||
| 12 | import discord | ||
| 13 | from discord.ext import commands | ||
| 14 | |||
| 15 | logger = logging.getLogger(__name__) | ||
| 16 | |||
| 17 | _plt_module = None | ||
| 18 | _plot_available = False | ||
| 19 | |||
| 20 | |||
| 21 | def _ensure_plot(): | ||
| 22 | global _plt_module, _plot_available | ||
| 23 | |||
| 24 | if _plt_module is not None: | ||
| 25 | return _plot_available | ||
| 26 | |||
| 27 | try: | ||
| 28 | import matplotlib | ||
| 29 | matplotlib.use("Agg") | ||
| 30 | import matplotlib.pyplot as _plt_module | ||
| 31 | _plot_available = True | ||
| 32 | except ImportError: | ||
| 33 | _plot_available = False | ||
| 34 | logger.warning("matplotlib not available, plot command will be disabled") | ||
| 35 | return _plot_available | ||
| 36 | |||
| 37 | |||
| 38 | def parse_expression(expr: str) -> str: | ||
| 39 | expr = expr.replace("^", "**") | ||
| 40 | expr = re.sub(r"(\d)([x])", r"\1*\2", expr) | ||
| 41 | expr = re.sub(r"([x])(\d)", r"\1*\2", expr) | ||
| 42 | expr = re.sub(r"(\d)([y])", r"\1*\2", expr) | ||
| 43 | expr = re.sub(r"([y])(\d)", r"\1*\2", expr) | ||
| 44 | return expr | ||
| 45 | |||
| 46 | |||
| 47 | def is_implicit(expr: str) -> bool: | ||
| 48 | return "=" in expr and "y" in expr and "x" in expr | ||
| 49 | |||
| 50 | |||
| 51 | def parse_implicit(expr: str) -> tuple[str, str]: | ||
| 52 | lhs, rhs = expr.split("=", 1) | ||
| 53 | lhs = parse_expression(lhs.strip()) | ||
| 54 | rhs = parse_expression(rhs.strip()) | ||
| 55 | return lhs, rhs | ||
| 56 | |||
| 57 | |||
| 58 | class Plot(commands.Cog): | ||
| 59 | def __init__(self, bot): | ||
| 60 | self.bot = bot | ||
| 61 | |||
| 62 | def _generate_plot(self, func_expr: str, output_path: str): | ||
| 63 | global _plt_module | ||
| 64 | |||
| 65 | if not _ensure_plot(): | ||
| 66 | raise RuntimeError("matplotlib not available") | ||
| 67 | |||
| 68 | plt = _plt_module | ||
| 69 | |||
| 70 | if is_implicit(func_expr): | ||
| 71 | self._generate_implicit_plot(func_expr, output_path) | ||
| 72 | return | ||
| 73 | |||
| 74 | parsed_expr = parse_expression(func_expr) | ||
| 75 | |||
| 76 | x = [i * 0.1 for i in range(-100, 101)] | ||
| 77 | y = [] | ||
| 78 | |||
| 79 | for xi in x: | ||
| 80 | try: | ||
| 81 | result = eval( | ||
| 82 | parsed_expr, | ||
| 83 | { | ||
| 84 | "x": xi, | ||
| 85 | "math": math, | ||
| 86 | "sin": math.sin, | ||
| 87 | "cos": math.cos, | ||
| 88 | "tan": math.tan, | ||
| 89 | "sqrt": math.sqrt, | ||
| 90 | "log": math.log, | ||
| 91 | "exp": math.exp, | ||
| 92 | "pi": math.pi, | ||
| 93 | "e": math.e, | ||
| 94 | }, | ||
| 95 | ) | ||
| 96 | if math.isfinite(result): | ||
| 97 | y.append(result) | ||
| 98 | else: | ||
| 99 | y.append(float("nan")) | ||
| 100 | except Exception: | ||
| 101 | y.append(float("nan")) | ||
| 102 | |||
| 103 | fig, ax = plt.subplots(figsize=(10, 6)) | ||
| 104 | ax.plot(x, y, linewidth=2, color="#00ff00") | ||
| 105 | ax.set_title(f"y = {func_expr}", fontsize=14) | ||
| 106 | ax.set_xlabel("x", fontsize=12) | ||
| 107 | ax.set_ylabel("y", fontsize=12) | ||
| 108 | ax.grid(True, alpha=0.3) | ||
| 109 | ax.axhline(y=0, color="white", linewidth=0.5) | ||
| 110 | ax.axvline(x=0, color="white", linewidth=0.5) | ||
| 111 | ax.set_facecolor("#1a1a1a") | ||
| 112 | fig.patch.set_facecolor("#1a1a1a") | ||
| 113 | |||
| 114 | plt.savefig(output_path, dpi=100, bbox_inches="tight") | ||
| 115 | plt.close() | ||
| 116 | |||
| 117 | def _generate_implicit_plot(self, func_expr: str, output_path: str): | ||
| 118 | global _plt_module | ||
| 119 | plt = _plt_module | ||
| 120 | |||
| 121 | lhs, rhs = parse_implicit(func_expr) | ||
| 122 | |||
| 123 | x_range = 20 | ||
| 124 | y_range = 20 | ||
| 125 | |||
| 126 | x_vals = [] | ||
| 127 | y_vals = [] | ||
| 128 | |||
| 129 | for xi in range(-x_range * 10, x_range * 10 + 1): | ||
| 130 | x = xi / 10.0 | ||
| 131 | for yi in range(-y_range * 10, y_range * 10 + 1): | ||
| 132 | y = yi / 10.0 | ||
| 133 | try: | ||
| 134 | left_val = eval(lhs, {"x": x, "y": y, "math": math, "sqrt": math.sqrt, "pi": math.pi, "e": math.e}) | ||
| 135 | right_val = eval(rhs, {"x": x, "y": y, "math": math, "sqrt": math.sqrt, "pi": math.pi, "e": math.e}) | ||
| 136 | diff = abs(left_val - right_val) | ||
| 137 | if math.isfinite(diff) and diff < 0.3: | ||
| 138 | x_vals.append(x) | ||
| 139 | y_vals.append(y) | ||
| 140 | except Exception: | ||
| 141 | pass | ||
| 142 | |||
| 143 | fig, ax = plt.subplots(figsize=(10, 10)) | ||
| 144 | ax.scatter(x_vals, y_vals, s=1, color="#00ff00", alpha=0.5) | ||
| 145 | ax.set_title(f"{func_expr}", fontsize=14) | ||
| 146 | ax.set_xlabel("x", fontsize=12) | ||
| 147 | ax.set_ylabel("y", fontsize=12) | ||
| 148 | ax.grid(True, alpha=0.3) | ||
| 149 | ax.axhline(y=0, color="white", linewidth=0.5) | ||
| 150 | ax.axvline(x=0, color="white", linewidth=0.5) | ||
| 151 | ax.set_xlim(-x_range, x_range) | ||
| 152 | ax.set_ylim(-y_range, y_range) | ||
| 153 | ax.set_aspect("equal") | ||
| 154 | ax.set_facecolor("#1a1a1a") | ||
| 155 | fig.patch.set_facecolor("#1a1a1a") | ||
| 156 | |||
| 157 | plt.savefig(output_path, dpi=100, bbox_inches="tight") | ||
| 158 | plt.close() | ||
| 159 | |||
| 160 | @commands.slash_command(name="funkcja", description="Generuje wykres funkcji matematycznej") | ||
| 161 | async def plot(self, ctx, funkcja: str = discord.Option(str, "Funkcja np. x^3, sin(x), x^2 + 2*x - 1", required=True)): | ||
| 162 | if not _ensure_plot(): | ||
| 163 | await ctx.respond("Generowanie wykresów nie jest dostępne (brak matplotlib)", ephemeral=True) | ||
| 164 | return | ||
| 165 | |||
| 166 | await ctx.defer() | ||
| 167 | |||
| 168 | from cogs.utils.temp import get_temp_dir | ||
| 169 | temp_path = os.path.join(get_temp_dir(), f"plot_{os.getpid()}_{asyncio.get_event_loop().time()}.png") | ||
| 170 | |||
| 171 | try: | ||
| 172 | loop = asyncio.get_event_loop() | ||
| 173 | await loop.run_in_executor(None, self._generate_plot, funkcja, temp_path) | ||
| 174 | await ctx.respond(file=discord.File(temp_path, filename="plot.png")) | ||
| 175 | logger.info(f"Generated plot for function: {funkcja}") | ||
| 176 | except Exception as e: | ||
| 177 | logger.error(f"Plot generation error: {e}") | ||
| 178 | await ctx.respond(f"Błąd generowania wykresu: {str(e)}", ephemeral=True) | ||
| 179 | finally: | ||
| 180 | try: | ||
| 181 | if os.path.exists(temp_path): | ||
| 182 | os.unlink(temp_path) | ||
| 183 | except Exception as e: | ||
| 184 | logger.error(f"Error cleaning up temp file: {e}") | ||
| 185 | |||
| 186 | |||
| 187 | def setup(bot): | ||
| 188 | bot.add_cog(Plot(bot)) \ No newline at end of file | ||
| @@ -22,10 +22,10 @@ _decode_available = False | |||
| 22 | 22 | ||
| 23 | def _ensure_qr(): | 23 | def _ensure_qr(): |
| 24 | global _pil_image, _decode_func, _decode_available | 24 | global _pil_image, _decode_func, _decode_available |
| 25 | 25 | ||
| 26 | if _pil_image is not None: | 26 | if _pil_image is not None: |
| 27 | return _decode_available | 27 | return _decode_available |
| 28 | 28 | ||
| 29 | try: | 29 | try: |
| 30 | from PIL import Image | 30 | from PIL import Image |
| 31 | from pyzbar.pyzbar import decode as _decode_func | 31 | from pyzbar.pyzbar import decode as _decode_func |
| @@ -48,7 +48,7 @@ class QR(commands.Cog): | |||
| 48 | if response.status == 200: | 48 | if response.status == 200: |
| 49 | content = await response.read() | 49 | content = await response.read() |
| 50 | with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f: | 50 | with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f: |
| 51 | f.write(content) | 51 | await asyncio.get_event_loop().run_in_executor(None, f.write, content) |
| 52 | return f.name | 52 | return f.name |
| 53 | return None | 53 | return None |
| 54 | 54 | ||
| @@ -137,7 +137,7 @@ class QR(commands.Cog): | |||
| 137 | 137 | ||
| 138 | def _decode_qr(self, image_path): | 138 | def _decode_qr(self, image_path): |
| 139 | """Decode QR code from image (runs in executor)""" | 139 | """Decode QR code from image (runs in executor)""" |
| 140 | if not _ensure_qr(): | 140 | if not _ensure_qr() or _decode_func is None: |
| 141 | return None | 141 | return None |
| 142 | 142 | ||
| 143 | try: | 143 | try: |
diff --git a/cogs/trigger.py b/cogs/trigger.py index 0e76cef..02e5573 100644 --- a/cogs/trigger.py +++ b/cogs/trigger.py | |||
| @@ -15,6 +15,12 @@ logger = logging.getLogger(__name__) | |||
| 15 | 15 | ||
| 16 | DB_PATH = 'data/triggers.db' | 16 | DB_PATH = 'data/triggers.db' |
| 17 | 17 | ||
| 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=?" | ||
| 20 | _SELECT_TRIGGER_ID_SQL = "SELECT id FROM triggers WHERE name=?" | ||
| 21 | _TRIGGER_NOT_FOUND_MSG = "Trigger not found." | ||
| 22 | _TRIGGER_NAME_OPTION = "the name of the trigger" | ||
| 23 | |||
| 18 | 24 | ||
| 19 | def get_connection(): | 25 | def get_connection(): |
| 20 | return sqlite3.connect(DB_PATH) | 26 | return sqlite3.connect(DB_PATH) |
| @@ -112,10 +118,10 @@ class Trigger(commands.Cog): | |||
| 112 | c = conn.cursor() | 118 | c = conn.cursor() |
| 113 | c.execute("SELECT id, name FROM triggers") | 119 | c.execute("SELECT id, name FROM triggers") |
| 114 | for trigger_id, name in c.fetchall(): | 120 | for trigger_id, name in c.fetchall(): |
| 115 | c.execute("SELECT keyword FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) | 121 | c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) |
| 116 | keywords = [row[0] for row in c.fetchall()] | 122 | keywords = [row[0] for row in c.fetchall()] |
| 117 | if any(keyword in content for keyword in keywords): | 123 | if any(keyword in content for keyword in keywords): |
| 118 | c.execute("SELECT response_text FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) | 124 | c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) |
| 119 | responses = [row[0] for row in c.fetchall()] | 125 | responses = [row[0] for row in c.fetchall()] |
| 120 | if responses: | 126 | if responses: |
| 121 | await message.channel.send(random.choice(responses).replace('\\n', '\n')) | 127 | await message.channel.send(random.choice(responses).replace('\\n', '\n')) |
| @@ -137,7 +143,7 @@ class Trigger(commands.Cog): | |||
| 137 | ctx: discord.ApplicationContext, | 143 | ctx: discord.ApplicationContext, |
| 138 | name = discord.Option( | 144 | name = discord.Option( |
| 139 | str, | 145 | str, |
| 140 | "the name of the trigger", | 146 | _TRIGGER_NAME_OPTION, |
| 141 | autocomplete= discord.utils.basic_autocomplete(get_type), | 147 | autocomplete= discord.utils.basic_autocomplete(get_type), |
| 142 | required=True | 148 | required=True |
| 143 | ) | 149 | ) |
| @@ -157,7 +163,7 @@ class Trigger(commands.Cog): | |||
| 157 | ctx: discord.ApplicationContext, | 163 | ctx: discord.ApplicationContext, |
| 158 | name = discord.Option( | 164 | name = discord.Option( |
| 159 | str, | 165 | str, |
| 160 | "the name of the trigger", | 166 | _TRIGGER_NAME_OPTION, |
| 161 | autocomplete= discord.utils.basic_autocomplete(get_type), | 167 | autocomplete= discord.utils.basic_autocomplete(get_type), |
| 162 | required=True | 168 | required=True |
| 163 | ), | 169 | ), |
| @@ -174,10 +180,10 @@ class Trigger(commands.Cog): | |||
| 174 | ): | 180 | ): |
| 175 | with get_connection() as conn: | 181 | with get_connection() as conn: |
| 176 | c = conn.cursor() | 182 | c = conn.cursor() |
| 177 | c.execute("SELECT id FROM triggers WHERE name=?", (name,)) | 183 | c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) |
| 178 | row = c.fetchone() | 184 | row = c.fetchone() |
| 179 | if not row: | 185 | if not row: |
| 180 | await ctx.respond("Trigger not found.") | 186 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) |
| 181 | logger.warning(f"Trigger not found: {name}") | 187 | logger.warning(f"Trigger not found: {name}") |
| 182 | return | 188 | return |
| 183 | trigger_id = row[0] | 189 | trigger_id = row[0] |
| @@ -204,7 +210,7 @@ class Trigger(commands.Cog): | |||
| 204 | ctx: discord.ApplicationContext, | 210 | ctx: discord.ApplicationContext, |
| 205 | name = discord.Option( | 211 | name = discord.Option( |
| 206 | str, | 212 | str, |
| 207 | "the name of the trigger", | 213 | _TRIGGER_NAME_OPTION, |
| 208 | autocomplete= discord.utils.basic_autocomplete(get_type), | 214 | autocomplete= discord.utils.basic_autocomplete(get_type), |
| 209 | required=True | 215 | required=True |
| 210 | ), | 216 | ), |
| @@ -221,10 +227,10 @@ class Trigger(commands.Cog): | |||
| 221 | ): | 227 | ): |
| 222 | with get_connection() as conn: | 228 | with get_connection() as conn: |
| 223 | c = conn.cursor() | 229 | c = conn.cursor() |
| 224 | c.execute("SELECT id FROM triggers WHERE name=?", (name,)) | 230 | c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) |
| 225 | row = c.fetchone() | 231 | row = c.fetchone() |
| 226 | if not row: | 232 | if not row: |
| 227 | await ctx.respond("Trigger not found.") | 233 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) |
| 228 | logger.warning(f"Trigger not found: {name}") | 234 | logger.warning(f"Trigger not found: {name}") |
| 229 | return | 235 | return |
| 230 | trigger_id = row[0] | 236 | trigger_id = row[0] |
| @@ -245,17 +251,17 @@ class Trigger(commands.Cog): | |||
| 245 | ctx: discord.ApplicationContext, | 251 | ctx: discord.ApplicationContext, |
| 246 | name = discord.Option( | 252 | name = discord.Option( |
| 247 | str, | 253 | str, |
| 248 | "the name of the trigger", | 254 | _TRIGGER_NAME_OPTION, |
| 249 | autocomplete= discord.utils.basic_autocomplete(get_type), | 255 | autocomplete= discord.utils.basic_autocomplete(get_type), |
| 250 | required=True | 256 | required=True |
| 251 | ) | 257 | ) |
| 252 | ): | 258 | ): |
| 253 | with get_connection() as conn: | 259 | with get_connection() as conn: |
| 254 | c = conn.cursor() | 260 | c = conn.cursor() |
| 255 | c.execute("SELECT id FROM triggers WHERE name=?", (name,)) | 261 | c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) |
| 256 | row = c.fetchone() | 262 | row = c.fetchone() |
| 257 | if not row: | 263 | if not row: |
| 258 | await ctx.respond("Trigger not found.") | 264 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) |
| 259 | return | 265 | return |
| 260 | trigger_id = row[0] | 266 | trigger_id = row[0] |
| 261 | c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) | 267 | c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) |
| @@ -275,7 +281,7 @@ class Trigger(commands.Cog): | |||
| 275 | ctx: discord.ApplicationContext, | 281 | ctx: discord.ApplicationContext, |
| 276 | name = discord.Option( | 282 | name = discord.Option( |
| 277 | str, | 283 | str, |
| 278 | "the name of the trigger", | 284 | _TRIGGER_NAME_OPTION, |
| 279 | autocomplete=discord.utils.basic_autocomplete(get_type), | 285 | autocomplete=discord.utils.basic_autocomplete(get_type), |
| 280 | required=True | 286 | required=True |
| 281 | ) | 287 | ) |
| @@ -286,13 +292,13 @@ class Trigger(commands.Cog): | |||
| 286 | c.execute("SELECT id FROM triggers WHERE name=?", (name,)) | 292 | c.execute("SELECT id FROM triggers WHERE name=?", (name,)) |
| 287 | row = c.fetchone() | 293 | row = c.fetchone() |
| 288 | if not row: | 294 | if not row: |
| 289 | await ctx.respond("Trigger not found.") | 295 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) |
| 290 | logger.warning(f"Trigger not found: {name}") | 296 | logger.warning(f"Trigger not found: {name}") |
| 291 | return | 297 | return |
| 292 | trigger_id = row[0] | 298 | trigger_id = row[0] |
| 293 | c.execute("SELECT keyword FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) | 299 | c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) |
| 294 | triggers = [row[0] for row in c.fetchall()] | 300 | triggers = [row[0] for row in c.fetchall()] |
| 295 | c.execute("SELECT response_text FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) | 301 | c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) |
| 296 | responses = [row[0] for row in c.fetchall()] | 302 | responses = [row[0] for row in c.fetchall()] |
| 297 | trigger_list = "\n".join(triggers) | 303 | trigger_list = "\n".join(triggers) |
| 298 | response_list = "\n".join(responses) | 304 | response_list = "\n".join(responses) |
| @@ -303,13 +309,13 @@ class Trigger(commands.Cog): | |||
| 303 | c.execute("SELECT id, name FROM triggers") | 309 | c.execute("SELECT id, name FROM triggers") |
| 304 | messages = [] | 310 | messages = [] |
| 305 | for trigger_id, name in c.fetchall(): | 311 | for trigger_id, name in c.fetchall(): |
| 306 | c.execute("SELECT keyword FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) | 312 | c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) |
| 307 | triggers = ", ".join([row[0] for row in c.fetchall()]) | 313 | triggers = ", ".join([row[0] for row in c.fetchall()]) |
| 308 | c.execute("SELECT response_text FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) | 314 | c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) |
| 309 | responses = ", ".join([row[0] for row in c.fetchall()]) | 315 | responses = ", ".join([row[0] for row in c.fetchall()]) |
| 310 | messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}") | 316 | messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}") |
| 311 | await ctx.respond("\n\n".join(messages)) | 317 | await ctx.respond("\n\n".join(messages)) |
| 312 | logger.info(f"Triggers listed") | 318 | logger.info("Triggers listed") |
| 313 | 319 | ||
| 314 | @trigger_group.command( | 320 | @trigger_group.command( |
| 315 | description="how to use trigger group?" | 321 | description="how to use trigger group?" |
diff --git a/cogs/tts.py b/cogs/tts.py index 3dc4c09..68eb27f 100644 --- a/cogs/tts.py +++ b/cogs/tts.py | |||
| @@ -54,19 +54,17 @@ class TTS(commands.Cog): | |||
| 54 | if not voice_client: | 54 | if not voice_client: |
| 55 | await ctx.respond("Failed to connect to voice channel", ephemeral=True) | 55 | await ctx.respond("Failed to connect to voice channel", ephemeral=True) |
| 56 | return | 56 | return |
| 57 | 57 | ||
| 58 | # Play the TTS audio with volume adjustment | 58 | if voice_client.is_playing(): |
| 59 | await ctx.respond("Audio is already playing, try again later", ephemeral=True) | ||
| 60 | return | ||
| 61 | |||
| 59 | audio_source = discord.FFmpegPCMAudio( | 62 | audio_source = discord.FFmpegPCMAudio( |
| 60 | temp_path, | 63 | temp_path, |
| 61 | options=f'-filter:a "volume={volume}"' | 64 | options=f'-filter:a "volume={volume}"' |
| 62 | ) | 65 | ) |
| 63 | voice_client.play(audio_source) | ||
| 64 | logger.info(f"Playing TTS: {text}") | 66 | logger.info(f"Playing TTS: {text}") |
| 65 | 67 | ||
| 66 | # Wait for the audio to finish playing | ||
| 67 | while voice_client.is_playing(): | ||
| 68 | await asyncio.sleep(0.1) | ||
| 69 | |||
| 70 | except Exception as e: | 68 | except Exception as e: |
| 71 | logger.error(f"Error in voice channel handling: {e}") | 69 | logger.error(f"Error in voice channel handling: {e}") |
| 72 | await ctx.respond("eror :c", ephemeral=True) | 70 | await ctx.respond("eror :c", ephemeral=True) |
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py index 984ec16..d66ce7f 100644 --- a/cogs/utils/settings.py +++ b/cogs/utils/settings.py | |||
| @@ -12,18 +12,19 @@ from dataclasses import dataclass, field | |||
| 12 | logger = logging.getLogger(__name__) | 12 | logger = logging.getLogger(__name__) |
| 13 | 13 | ||
| 14 | settings_file = "data/settings.json" | 14 | settings_file = "data/settings.json" |
| 15 | DEFAULT_BASEURL = "http://localhost:8080" | ||
| 15 | 16 | ||
| 16 | 17 | ||
| 17 | @dataclass | 18 | @dataclass |
| 18 | class Settings: | 19 | class Settings: |
| 19 | volume: float = 1.0 | 20 | volume: float = 1.0 |
| 20 | baseurl: str = "http://localhost:8080" | 21 | baseurl: str = DEFAULT_BASEURL |
| 21 | _data: dict = field(default_factory=dict, init=False) | 22 | _data: dict = field(default_factory=dict, init=False) |
| 22 | 23 | ||
| 23 | async def load(self) -> "Settings": | 24 | async def load(self) -> "Settings": |
| 24 | default_settings = { | 25 | default_settings = { |
| 25 | "volume": 1.0, | 26 | "volume": 1.0, |
| 26 | "baseurl": "http://localhost:8080", | 27 | "baseurl": DEFAULT_BASEURL, |
| 27 | } | 28 | } |
| 28 | 29 | ||
| 29 | try: | 30 | try: |
| @@ -44,7 +45,7 @@ class Settings: | |||
| 44 | self._sync_from_data() | 45 | self._sync_from_data() |
| 45 | await self.save() | 46 | await self.save() |
| 46 | logger.info("Created default settings.json") | 47 | logger.info("Created default settings.json") |
| 47 | except (json.JSONDecodeError, Exception) as e: | 48 | except json.JSONDecodeError as e: |
| 48 | logger.error(f"Error loading settings: {e}") | 49 | logger.error(f"Error loading settings: {e}") |
| 49 | self._data = default_settings.copy() | 50 | self._data = default_settings.copy() |
| 50 | self._sync_from_data() | 51 | self._sync_from_data() |
| @@ -53,7 +54,7 @@ class Settings: | |||
| 53 | 54 | ||
| 54 | def _sync_from_data(self): | 55 | def _sync_from_data(self): |
| 55 | self.volume = self._data.get("volume", 1.0) | 56 | self.volume = self._data.get("volume", 1.0) |
| 56 | self.baseurl = self._data.get("baseurl", "http://localhost:8080") | 57 | self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) |
| 57 | 58 | ||
| 58 | def _sync_to_data(self): | 59 | def _sync_to_data(self): |
| 59 | self._data["volume"] = self.volume | 60 | self._data["volume"] = self.volume |
| @@ -89,4 +90,4 @@ def get_volume() -> float: | |||
| 89 | 90 | ||
| 90 | 91 | ||
| 91 | def get_baseurl() -> str: | 92 | def get_baseurl() -> str: |
| 92 | return _settings_instance.baseurl.rstrip("/") if _settings_instance else "http://localhost:8080" | 93 | return _settings_instance.baseurl.rstrip("/") if _settings_instance else DEFAULT_BASEURL |
diff --git a/cogs/utils/temp.py b/cogs/utils/temp.py index 528d6a3..0b2c8d7 100644 --- a/cogs/utils/temp.py +++ b/cogs/utils/temp.py | |||
| @@ -8,11 +8,11 @@ import tempfile | |||
| 8 | 8 | ||
| 9 | def get_temp_dir(subdir: str = "senator_sperma") -> str: | 9 | def get_temp_dir(subdir: str = "senator_sperma") -> str: |
| 10 | """Get or create a temp subdirectory. | 10 | """Get or create a temp subdirectory. |
| 11 | 11 | ||
| 12 | Uses system temp directory (/tmp on Linux, %TEMP% on Windows). | 12 | Uses system temp directory (/tmp on Linux, %TEMP% on Windows). |
| 13 | OS handles cleanup automatically. | 13 | OS handles cleanup automatically. |
| 14 | """ | 14 | """ |
| 15 | base_temp = tempfile.gettempdir() | 15 | base_temp = tempfile.gettempdir() |
| 16 | temp_path = os.path.join(base_temp, subdir) | 16 | temp_path = os.path.join(base_temp, subdir) |
| 17 | os.makedirs(temp_path, exist_ok=True) | 17 | os.makedirs(temp_path, exist_ok=True) |
| 18 | return temp_path \ No newline at end of file | 18 | return temp_path |
diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 397d296..fb0a2e0 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py | |||
| @@ -14,9 +14,11 @@ from gtts import gTTS | |||
| 14 | from cogs.voice import get_voice_manager | 14 | from cogs.voice import get_voice_manager |
| 15 | from cogs.utils.settings import get_volume, save_settings | 15 | from cogs.utils.settings import get_volume, save_settings |
| 16 | 16 | ||
| 17 | import tempfile | ||
| 18 | |||
| 17 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| 18 | 20 | ||
| 19 | ACTIVITY_FILE = 'temp/voice_activity.json' | 21 | ACTIVITY_FILE = os.path.join(tempfile.gettempdir(), 'voice_activity.json') |
| 20 | 22 | ||
| 21 | 23 | ||
| 22 | def load_activity_data(): | 24 | def load_activity_data(): |
| @@ -100,12 +102,20 @@ class VcLogs(commands.Cog): | |||
| 100 | volume = get_volume() | 102 | volume = get_volume() |
| 101 | logger.info(f"connected: {voice_client}") | 103 | logger.info(f"connected: {voice_client}") |
| 102 | 104 | ||
| 103 | if voice_client and not is_playing: | 105 | if voice_client: |
| 106 | if is_playing: | ||
| 107 | logger.info("voice already playing, stopping current audio") | ||
| 108 | voice_client.stop() | ||
| 109 | await asyncio.sleep(0.1) | ||
| 110 | |||
| 104 | logger.info("preparing to play audio") | 111 | logger.info("preparing to play audio") |
| 105 | audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') | 112 | audio_source = discord.FFmpegPCMAudio(tts_file) |
| 113 | if volume != 1.0: | ||
| 114 | audio_source = discord.PCMVolumeTransformer(audio_source) | ||
| 115 | audio_source.volume = volume | ||
| 106 | voice_client.play(audio_source) | 116 | voice_client.play(audio_source) |
| 107 | while voice_client.is_playing(): | 117 | while voice_client.is_playing(): |
| 108 | await asyncio.sleep(0.5) | 118 | await asyncio.sleep(0.1) |
| 109 | except Exception as e: | 119 | except Exception as e: |
| 110 | logger.error(f"TTS playback error: {e}") | 120 | logger.error(f"TTS playback error: {e}") |
| 111 | finally: | 121 | finally: |
| @@ -125,7 +135,16 @@ class VcLogs(commands.Cog): | |||
| 125 | 135 | ||
| 126 | @commands.Cog.listener() | 136 | @commands.Cog.listener() |
| 127 | async def on_voice_state_update(self, member, before, after): | 137 | async def on_voice_state_update(self, member, before, after): |
| 128 | log_channel = self.bot.get_channel(1213843195457183885) | 138 | log_channel_id = os.getenv("STATUS_CHANNEL_ID") |
| 139 | if not log_channel_id: | ||
| 140 | logger.warning("STATUS_CHANNEL_ID not set in environment") | ||
| 141 | return | ||
| 142 | |||
| 143 | log_channel = self.bot.get_channel(int(log_channel_id)) | ||
| 144 | if log_channel is None: | ||
| 145 | logger.warning("Log channel not found or not accessible") | ||
| 146 | return | ||
| 147 | |||
| 129 | current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") | 148 | current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
| 130 | current_ts = datetime.datetime.now().timestamp() | 149 | current_ts = datetime.datetime.now().timestamp() |
| 131 | tts_text = None | 150 | tts_text = None |
diff --git a/cogs/voice.py b/cogs/voice.py index 07015cc..319bb16 100644 --- a/cogs/voice.py +++ b/cogs/voice.py | |||
| @@ -95,7 +95,7 @@ class VoiceManager: | |||
| 95 | if not voice_client: | 95 | if not voice_client: |
| 96 | return False | 96 | return False |
| 97 | 97 | ||
| 98 | if volume != 1.0: | 98 | if abs(volume - 1.0) > 1e-9: |
| 99 | audio_source = discord.PCMVolumeTransformer(audio_source) | 99 | audio_source = discord.PCMVolumeTransformer(audio_source) |
| 100 | audio_source.volume = volume | 100 | audio_source.volume = volume |
| 101 | 101 | ||
| @@ -197,4 +197,4 @@ class Voice(commands.Cog): | |||
| 197 | 197 | ||
| 198 | 198 | ||
| 199 | def setup(bot): | 199 | def setup(bot): |
| 200 | bot.add_cog(Voice(bot)) \ No newline at end of file | 200 | bot.add_cog(Voice(bot)) |
diff --git a/cogs/web.py b/cogs/web.py index e003399..b072e99 100644 --- a/cogs/web.py +++ b/cogs/web.py | |||
| @@ -97,4 +97,4 @@ class Web(commands.Cog): | |||
| 97 | 97 | ||
| 98 | 98 | ||
| 99 | def setup(bot): | 99 | def setup(bot): |
| 100 | bot.add_cog(Web(bot)) \ No newline at end of file | 100 | bot.add_cog(Web(bot)) |
| @@ -19,11 +19,17 @@ from cogs.utils import settings | |||
| 19 | def setup_logging(debug: bool = False): | 19 | def setup_logging(debug: bool = False): |
| 20 | """Configure logging based on debug flag.""" | 20 | """Configure logging based on debug flag.""" |
| 21 | level = logging.DEBUG if debug else logging.INFO | 21 | level = logging.DEBUG if debug else logging.INFO |
| 22 | |||
| 23 | class DecryptionFilter(logging.Filter): | ||
| 24 | def filter(self, record): | ||
| 25 | return "Decryption failed" not in record.getMessage() | ||
| 26 | |||
| 22 | logging.basicConfig( | 27 | logging.basicConfig( |
| 23 | level=level, | 28 | level=level, |
| 24 | format="%(levelname)s - %(filename)s - %(name)s - %(message)s", | 29 | format="%(levelname)s - %(filename)s - %(name)s - %(message)s", |
| 25 | handlers=[logging.StreamHandler(sys.stdout)], | 30 | handlers=[logging.StreamHandler(sys.stdout)], |
| 26 | ) | 31 | ) |
| 32 | logging.getLogger().addFilter(DecryptionFilter()) | ||
| 27 | 33 | ||
| 28 | 34 | ||
| 29 | # Create a new event loop and set it as the current loop | 35 | # Create a new event loop and set it as the current loop |
| @@ -39,12 +45,6 @@ async def on_ready(): | |||
| 39 | logger = logging.getLogger(__name__) | 45 | logger = logging.getLogger(__name__) |
| 40 | logger.info("ŁUKASZ SPERMA AKTYWACJA") | 46 | logger.info("ŁUKASZ SPERMA AKTYWACJA") |
| 41 | 47 | ||
| 42 | channel_id = os.getenv("STATUS_CHANNEL_ID") | ||
| 43 | if channel_id: | ||
| 44 | channel = bot.get_channel(int(channel_id)) | ||
| 45 | if channel: | ||
| 46 | await channel.send("ŁUKASZ SPERMA AKTYWACJA") | ||
| 47 | |||
| 48 | await settings.load_settings() | 48 | await settings.load_settings() |
| 49 | 49 | ||
| 50 | 50 | ||
| @@ -74,7 +74,6 @@ if __name__ == "__main__": | |||
| 74 | "cogs.trigger", | 74 | "cogs.trigger", |
| 75 | "cogs.cron", | 75 | "cogs.cron", |
| 76 | "cogs.qr", | 76 | "cogs.qr", |
| 77 | "cogs.plot", | ||
| 78 | "cogs.voice", | 77 | "cogs.voice", |
| 79 | ] | 78 | ] |
| 80 | 79 | ||
| @@ -93,4 +92,4 @@ if __name__ == "__main__": | |||
| 93 | 92 | ||
| 94 | except Exception as e: | 93 | except Exception as e: |
| 95 | logging.getLogger(__name__).error(f"Error starting the bot: {e}") | 94 | logging.getLogger(__name__).error(f"Error starting the bot: {e}") |
| 96 | sys.exit(1) \ No newline at end of file | 95 | sys.exit(1) |
diff --git a/pyrightconfig.json b/pyrightconfig.json index f0b2a2d..7722ae1 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json | |||
| @@ -15,4 +15,5 @@ | |||
| 15 | "reportAttributeAccessIssue": false, | 15 | "reportAttributeAccessIssue": false, |
| 16 | "reportPossiblyUnboundVariable": false, | 16 | "reportPossiblyUnboundVariable": false, |
| 17 | "reportArgumentType": false, | 17 | "reportArgumentType": false, |
| 18 | "reportUnreachable": false, | ||
| 18 | } | 19 | } |
diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..03a070f --- /dev/null +++ b/pytest.ini | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | [pytest] | ||
| 2 | asyncio_default_fixture_loop_scope = function | ||
| 3 | python_files = test_*.py | ||
| 4 | python_classes = Test* | ||
| 5 | python_functions = test_* | ||
| 6 | testpaths = tests | ||
| 7 | pythonpath = . \ No newline at end of file | ||
diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..abba086 --- /dev/null +++ b/requirements-dev.txt | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | pytest==8.3.4 | ||
| 2 | pytest-asyncio==0.25.2 | ||
| 3 | pytest-mock==3.14.0 | ||
| 4 | aiofiles==25.1.0 | ||
| 5 | aiohappyeyeballs==2.6.1 | ||
| 6 | aiohttp==3.13.3 | ||
| 7 | aiosignal==1.4.0 | ||
| 8 | anyio==4.12.1 | ||
| 9 | attrs==25.4.0 | ||
| 10 | basedpyright==1.39.2 | ||
| 11 | beautifulsoup4==4.14.3 | ||
| 12 | brotli==1.2.0 | ||
| 13 | bs4==0.0.2 | ||
| 14 | certifi==2026.2.25 | ||
| 15 | cffi==2.0.0 | ||
| 16 | charset-normalizer==3.4.6 | ||
| 17 | click==8.1.8 | ||
| 18 | contourpy==1.3.3 | ||
| 19 | croniter==6.2.2 | ||
| 20 | cycler==0.12.1 | ||
| 21 | davey==0.1.4 | ||
| 22 | dotenv==0.9.9 | ||
| 23 | fonttools==4.62.1 | ||
| 24 | frozenlist==1.8.0 | ||
| 25 | gTTS==2.5.4 | ||
| 26 | h11==0.16.0 | ||
| 27 | httpcore==1.0.9 | ||
| 28 | httpx==0.28.1 | ||
| 29 | idna==3.11 | ||
| 30 | kiwisolver==1.5.0 | ||
| 31 | matplotlib==3.10.8 | ||
| 32 | multidict==6.7.1 | ||
| 33 | mutagen==1.47.0 | ||
| 34 | nodejs-wheel-binaries==24.14.1 | ||
| 35 | numpy==2.4.4 | ||
| 36 | packaging==26.1 | ||
| 37 | pillow==12.2.0 | ||
| 38 | propcache==0.4.1 | ||
| 39 | py-cord @ file:///home/salmon/Documents/botcode/senator-sperma/pycord | ||
| 40 | pycparser==3.0 | ||
| 41 | pycryptodomex==3.23.0 | ||
| 42 | PyNaCl==1.6.2 | ||
| 43 | pyparsing==3.3.2 | ||
| 44 | python-dateutil==2.9.0.post0 | ||
| 45 | python-dotenv==1.2.2 | ||
| 46 | pytube==15.0.0 | ||
| 47 | pyzbar==0.1.9 | ||
| 48 | qrcode==8.2 | ||
| 49 | RapidFuzz==3.14.3 | ||
| 50 | requests==2.32.5 | ||
| 51 | six==1.17.0 | ||
| 52 | soupsieve==2.8.3 | ||
| 53 | typing_extensions==4.15.0 | ||
| 54 | urllib3==2.6.3 | ||
| 55 | websockets==16.0 | ||
| 56 | yarl==1.23.0 | ||
| 57 | youtube-search-python==1.6.6 | ||
| 58 | yt-dlp==2026.3.17 | ||
| 59 | yt-dlp-ejs==0.8.0 | ||
diff --git a/tests/cogs/test_ai.py b/tests/cogs/test_ai.py new file mode 100644 index 0000000..a1c3fd4 --- /dev/null +++ b/tests/cogs/test_ai.py | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import discord | ||
| 4 | import httpx | ||
| 5 | from tests.conftest import MockApplicationContext | ||
| 6 | |||
| 7 | |||
| 8 | @pytest.fixture | ||
| 9 | def ai_cog(bot): | ||
| 10 | from cogs.ai import AI | ||
| 11 | cog = AI(bot) | ||
| 12 | return cog | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.fixture | ||
| 16 | def mock_ai_response(): | ||
| 17 | return { | ||
| 18 | "choices": [ | ||
| 19 | { | ||
| 20 | "message": { | ||
| 21 | "content": "Test response from AI" | ||
| 22 | } | ||
| 23 | } | ||
| 24 | ] | ||
| 25 | } | ||
| 26 | |||
| 27 | |||
| 28 | @pytest.mark.asyncio | ||
| 29 | async def test_senator_command_success(ai_cog, mock_channel, mock_guild, bot, mock_ai_response): | ||
| 30 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 31 | |||
| 32 | mock_response = MagicMock() | ||
| 33 | mock_response.json = MagicMock(return_value=mock_ai_response) | ||
| 34 | |||
| 35 | mock_client = MagicMock() | ||
| 36 | mock_client.post = AsyncMock(return_value=mock_response) | ||
| 37 | mock_client.__aenter__ = AsyncMock(return_value=mock_response) | ||
| 38 | mock_client.__aexit__ = AsyncMock(return_value=None) | ||
| 39 | |||
| 40 | with patch('cogs.ai.httpx.AsyncClient') as mock_httpx: | ||
| 41 | instance = mock_httpx.return_value.__aenter__.return_value | ||
| 42 | instance.post = AsyncMock(return_value=mock_response) | ||
| 43 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 44 | await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) | ||
| 45 | mock_followup.assert_called_once_with("Test response from AI") | ||
| 46 | |||
| 47 | |||
| 48 | @pytest.mark.asyncio | ||
| 49 | async def test_senator_command_empty_choices(ai_cog, mock_channel, mock_guild, bot): | ||
| 50 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 51 | |||
| 52 | mock_response = MagicMock() | ||
| 53 | mock_response.json = MagicMock(return_value={"choices": []}) | ||
| 54 | |||
| 55 | mock_client = MagicMock() | ||
| 56 | mock_client.post = AsyncMock(return_value=mock_response) | ||
| 57 | mock_client.__aenter__ = AsyncMock(return_value=mock_response) | ||
| 58 | mock_client.__aexit__ = AsyncMock(return_value=None) | ||
| 59 | |||
| 60 | with patch('cogs.ai.httpx.AsyncClient') as mock_httpx: | ||
| 61 | instance = mock_httpx.return_value.__aenter__.return_value | ||
| 62 | instance.post = AsyncMock(return_value=mock_response) | ||
| 63 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 64 | await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) | ||
| 65 | mock_followup.assert_called_once_with("bot siem zesroł") | ||
| 66 | |||
| 67 | |||
| 68 | @pytest.mark.asyncio | ||
| 69 | async def test_senator_command_no_choices_key(ai_cog, mock_channel, mock_guild, bot): | ||
| 70 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 71 | |||
| 72 | mock_response = MagicMock() | ||
| 73 | mock_response.json = MagicMock(return_value={}) | ||
| 74 | |||
| 75 | with patch('cogs.ai.httpx.AsyncClient') as mock_httpx: | ||
| 76 | instance = mock_httpx.return_value.__aenter__.return_value | ||
| 77 | instance.post = AsyncMock(return_value=mock_response) | ||
| 78 | with patch('cogs.ai.logger') as mock_logger: | ||
| 79 | try: | ||
| 80 | await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) | ||
| 81 | except KeyError: | ||
| 82 | pass | ||
| 83 | assert mock_logger.info.called | ||
diff --git a/tests/cogs/test_assistant.py b/tests/cogs/test_assistant.py new file mode 100644 index 0000000..9a33232 --- /dev/null +++ b/tests/cogs/test_assistant.py | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from tests.conftest import MockApplicationContext | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def assistant_cog(): | ||
| 8 | with patch('os.getenv', return_value="test_keyword"): | ||
| 9 | from cogs.assistant import Jarvis | ||
| 10 | |||
| 11 | mock_bot = MagicMock() | ||
| 12 | mock_bot.user = MagicMock() | ||
| 13 | mock_bot.user.id = 111 | ||
| 14 | |||
| 15 | cog = Jarvis(mock_bot) | ||
| 16 | return cog | ||
| 17 | |||
| 18 | |||
| 19 | def test_assistant_keyword_default(assistant_cog): | ||
| 20 | assert assistant_cog.keyword == "test_keyword" | ||
| 21 | |||
| 22 | |||
| 23 | @pytest.mark.asyncio | ||
| 24 | async def test_assistant_on_message_ignored_bot_message(assistant_cog): | ||
| 25 | mock_author = MagicMock() | ||
| 26 | mock_author.id = 456 | ||
| 27 | mock_author.bot = True | ||
| 28 | |||
| 29 | mock_message = MagicMock() | ||
| 30 | mock_message.author = mock_author | ||
| 31 | |||
| 32 | with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: | ||
| 33 | await assistant_cog.on_message(mock_message) | ||
| 34 | mock_assistant.assert_not_called() | ||
| 35 | |||
| 36 | |||
| 37 | @pytest.mark.asyncio | ||
| 38 | async def test_assistant_on_message_mentions_bot(assistant_cog): | ||
| 39 | mock_message = MagicMock() | ||
| 40 | mock_message.author = MagicMock() | ||
| 41 | mock_message.author.id = 123 | ||
| 42 | |||
| 43 | mock_mention = MagicMock() | ||
| 44 | mock_mention.id = 111 | ||
| 45 | mock_message.mentions = [mock_mention] | ||
| 46 | mock_message.content = "test" | ||
| 47 | |||
| 48 | with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: | ||
| 49 | await assistant_cog.on_message(mock_message) | ||
| 50 | mock_assistant.assert_called_once_with(mock_message) | ||
| 51 | |||
| 52 | |||
| 53 | @pytest.mark.asyncio | ||
| 54 | async def test_assistant_on_message_keyword_match(assistant_cog): | ||
| 55 | assistant_cog.keyword = "test" | ||
| 56 | |||
| 57 | mock_message = MagicMock() | ||
| 58 | mock_message.author = MagicMock() | ||
| 59 | mock_message.author.id = 123 | ||
| 60 | |||
| 61 | mock_message.mentions = [] | ||
| 62 | mock_message.content = "test message with test keyword" | ||
| 63 | |||
| 64 | with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: | ||
| 65 | await assistant_cog.on_message(mock_message) | ||
| 66 | mock_assistant.assert_called_once_with(mock_message) | ||
diff --git a/tests/cogs/test_compression_conversion.py b/tests/cogs/test_compression_conversion.py new file mode 100644 index 0000000..f9d3427 --- /dev/null +++ b/tests/cogs/test_compression_conversion.py | |||
| @@ -0,0 +1,139 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import os | ||
| 4 | import discord | ||
| 5 | from tests.conftest import MockApplicationContext | ||
| 6 | |||
| 7 | |||
| 8 | def test_compression_filetype_video(): | ||
| 9 | from cogs.compression import filetype | ||
| 10 | assert filetype("test.mp4") == "Video" | ||
| 11 | assert filetype("test.avi") == "Video" | ||
| 12 | |||
| 13 | |||
| 14 | def test_compression_filetype_audio(): | ||
| 15 | from cogs.compression import filetype | ||
| 16 | assert filetype("test.mp3") == "Audio" | ||
| 17 | assert filetype("test.wav") == "Audio" | ||
| 18 | |||
| 19 | |||
| 20 | def test_compression_filetype_image(): | ||
| 21 | from cogs.compression import filetype | ||
| 22 | assert filetype("test.jpg") == "Image" | ||
| 23 | assert filetype("test.png") == "Image" | ||
| 24 | |||
| 25 | |||
| 26 | def test_conversion_filetype(): | ||
| 27 | from cogs.conversion import filetype | ||
| 28 | assert filetype("test.mp4") == "Video" | ||
| 29 | assert filetype("test.mp3") == "Audio" | ||
| 30 | assert filetype("test.jpg") == "Image" | ||
| 31 | assert filetype("test.xyz") == "Unknown" | ||
| 32 | |||
| 33 | |||
| 34 | @pytest.mark.asyncio | ||
| 35 | async def test_build_conversion_command_video(): | ||
| 36 | with patch('os.getenv', return_value="https://example.com"): | ||
| 37 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 38 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 39 | import importlib | ||
| 40 | import cogs.conversion | ||
| 41 | importlib.reload(cogs.conversion) | ||
| 42 | from cogs.conversion import Conversion | ||
| 43 | |||
| 44 | cog = Conversion.__new__(Conversion) | ||
| 45 | cog._baseurl = "https://example.com" | ||
| 46 | cog._uploadurl = "https://example.com" | ||
| 47 | |||
| 48 | cmd = cog._build_conversion_command("Video", "/tmp/test.mp4", "/tmp/output.mp4", "") | ||
| 49 | assert cmd is not None | ||
| 50 | assert "ffmpeg" in cmd | ||
| 51 | |||
| 52 | |||
| 53 | @pytest.mark.asyncio | ||
| 54 | async def test_build_conversion_command_image(): | ||
| 55 | with patch('os.getenv', return_value="https://example.com"): | ||
| 56 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 57 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 58 | import importlib | ||
| 59 | import cogs.conversion | ||
| 60 | importlib.reload(cogs.conversion) | ||
| 61 | from cogs.conversion import Conversion | ||
| 62 | |||
| 63 | cog = Conversion.__new__(Conversion) | ||
| 64 | cog._baseurl = "https://example.com" | ||
| 65 | cog._uploadurl = "https://example.com" | ||
| 66 | |||
| 67 | cmd = cog._build_conversion_command("Image", "/tmp/test.jpg", "/tmp/output.png", "") | ||
| 68 | assert cmd is not None | ||
| 69 | assert "magick" in cmd | ||
| 70 | |||
| 71 | |||
| 72 | @pytest.mark.asyncio | ||
| 73 | async def test_build_conversion_command_unknown(): | ||
| 74 | with patch('os.getenv', return_value="https://example.com"): | ||
| 75 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 76 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 77 | import importlib | ||
| 78 | import cogs.conversion | ||
| 79 | importlib.reload(cogs.conversion) | ||
| 80 | from cogs.conversion import Conversion | ||
| 81 | |||
| 82 | cog = Conversion.__new__(Conversion) | ||
| 83 | cog._baseurl = "https://example.com" | ||
| 84 | cog._uploadurl = "https://example.com" | ||
| 85 | |||
| 86 | cmd = cog._build_conversion_command("Unknown", "/tmp/test.xyz", "/tmp/output.abc", "") | ||
| 87 | assert cmd is None | ||
| 88 | |||
| 89 | |||
| 90 | @pytest.mark.asyncio | ||
| 91 | async def test_compression_get_command_video(): | ||
| 92 | with patch('os.getenv', return_value="https://example.com"): | ||
| 93 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 94 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 95 | import importlib | ||
| 96 | import cogs.compression | ||
| 97 | importlib.reload(cogs.compression) | ||
| 98 | from cogs.compression import Compression | ||
| 99 | |||
| 100 | cog = Compression.__new__(Compression) | ||
| 101 | cog.temp_dir = "/tmp" | ||
| 102 | |||
| 103 | cmd = cog._get_compression_command("Video", "/tmp/test.mp4", "/tmp/output.mp4") | ||
| 104 | assert cmd is not None | ||
| 105 | assert "ffmpeg" in cmd | ||
| 106 | |||
| 107 | |||
| 108 | @pytest.mark.asyncio | ||
| 109 | async def test_compression_get_command_image(): | ||
| 110 | with patch('os.getenv', return_value="https://example.com"): | ||
| 111 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 112 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 113 | import importlib | ||
| 114 | import cogs.compression | ||
| 115 | importlib.reload(cogs.compression) | ||
| 116 | from cogs.compression import Compression | ||
| 117 | |||
| 118 | cog = Compression.__new__(Compression) | ||
| 119 | cog.temp_dir = "/tmp" | ||
| 120 | |||
| 121 | cmd = cog._get_compression_command("Image", "/tmp/test.jpg", "/tmp/output.jpg") | ||
| 122 | assert cmd is not None | ||
| 123 | |||
| 124 | |||
| 125 | @pytest.mark.asyncio | ||
| 126 | async def test_compression_get_command_unknown(): | ||
| 127 | with patch('os.getenv', return_value="https://example.com"): | ||
| 128 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 129 | with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): | ||
| 130 | import importlib | ||
| 131 | import cogs.compression | ||
| 132 | importlib.reload(cogs.compression) | ||
| 133 | from cogs.compression import Compression | ||
| 134 | |||
| 135 | cog = Compression.__new__(Compression) | ||
| 136 | cog.temp_dir = "/tmp" | ||
| 137 | |||
| 138 | cmd = cog._get_compression_command("Unknown", "/tmp/test.xyz", "/tmp/output.xyz") | ||
| 139 | assert cmd is None | ||
diff --git a/tests/cogs/test_cron.py b/tests/cogs/test_cron.py new file mode 100644 index 0000000..93031f8 --- /dev/null +++ b/tests/cogs/test_cron.py | |||
| @@ -0,0 +1,166 @@ | |||
| 1 | import json | ||
| 2 | import os | ||
| 3 | import pytest | ||
| 4 | from datetime import datetime | ||
| 5 | from unittest.mock import MagicMock, patch | ||
| 6 | |||
| 7 | from cogs.cron import Cron | ||
| 8 | |||
| 9 | |||
| 10 | @pytest.fixture | ||
| 11 | def mock_bot(): | ||
| 12 | bot = MagicMock() | ||
| 13 | bot.loop = MagicMock() | ||
| 14 | bot.is_closed = MagicMock(return_value=False) | ||
| 15 | bot.wait_until_ready = MagicMock() | ||
| 16 | bot.loop.create_task = MagicMock(return_value=MagicMock()) | ||
| 17 | return bot | ||
| 18 | |||
| 19 | |||
| 20 | def create_cog(mock_bot, tmp_path): | ||
| 21 | with patch.object(Cron, "__init__", lambda self, bot: None): | ||
| 22 | cog = Cron.__new__(Cron) | ||
| 23 | cog.bot = mock_bot | ||
| 24 | cog.scheduled_tasks = [] | ||
| 25 | cog.cron_file = str(tmp_path / "cron_jobs.json") | ||
| 26 | cog.task = MagicMock() | ||
| 27 | return cog | ||
| 28 | |||
| 29 | |||
| 30 | class TestLoadTasks: | ||
| 31 | def test_load_tasks_file_not_exists(self, mock_bot, tmp_path): | ||
| 32 | cog = create_cog(mock_bot, tmp_path) | ||
| 33 | cog.cron_file = str(tmp_path / "nonexistent.json") | ||
| 34 | cog.load_tasks() | ||
| 35 | assert cog.scheduled_tasks == [] | ||
| 36 | |||
| 37 | def test_load_tasks_empty_file(self, mock_bot, tmp_path): | ||
| 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 | |||
| 55 | def test_load_tasks_io_error(self, mock_bot, tmp_path): | ||
| 56 | cog = create_cog(mock_bot, tmp_path) | ||
| 57 | with patch("builtins.open", side_effect=IOError("test error")): | ||
| 58 | cog.load_tasks() | ||
| 59 | assert cog.scheduled_tasks == [] | ||
| 60 | |||
| 61 | |||
| 62 | class TestSaveTasks: | ||
| 63 | def test_save_tasks_with_real_file(self, mock_bot, tmp_path): | ||
| 64 | cron_file = tmp_path / "cron_jobs.json" | ||
| 65 | cog = create_cog(mock_bot, tmp_path) | ||
| 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 | |||
| 74 | def test_save_tasks_creates_directory_structure(self, mock_bot, tmp_path): | ||
| 75 | cog = create_cog(mock_bot, tmp_path) | ||
| 76 | new_dir = tmp_path / "new_dir" | ||
| 77 | cron_file = new_dir / "cron_jobs.json" | ||
| 78 | cog.cron_file = str(cron_file) | ||
| 79 | cog.scheduled_tasks = [{"id": 1}] | ||
| 80 | |||
| 81 | cog.save_tasks() | ||
| 82 | |||
| 83 | assert os.path.exists(new_dir) | ||
| 84 | |||
| 85 | |||
| 86 | class TestShouldRunTask: | ||
| 87 | def test_should_run_task_invalid_cron(self, mock_bot, tmp_path): | ||
| 88 | cog = create_cog(mock_bot, tmp_path) | ||
| 89 | task = {"cron_expression": "invalid"} | ||
| 90 | now = datetime.now() | ||
| 91 | result = cog.should_run_task(task, now) | ||
| 92 | assert result is False | ||
| 93 | |||
| 94 | def test_should_run_task_missing_cron_expression(self, mock_bot, tmp_path): | ||
| 95 | cog = create_cog(mock_bot, tmp_path) | ||
| 96 | task = {} | ||
| 97 | now = datetime.now() | ||
| 98 | result = cog.should_run_task(task, now) | ||
| 99 | assert result is False | ||
| 100 | |||
| 101 | |||
| 102 | class TestCogUnload: | ||
| 103 | def test_cog_unload_cancels_task(self, mock_bot, tmp_path): | ||
| 104 | with patch.object(Cron, "__init__", lambda self, bot: None): | ||
| 105 | cog = Cron.__new__(Cron) | ||
| 106 | cog.bot = mock_bot | ||
| 107 | cog.task = MagicMock() | ||
| 108 | |||
| 109 | cog.cog_unload() | ||
| 110 | cog.task.cancel.assert_called_once() | ||
| 111 | |||
| 112 | |||
| 113 | class TestTaskManagement: | ||
| 114 | def test_add_task_increments_id(self, mock_bot, tmp_path): | ||
| 115 | cog = create_cog(mock_bot, tmp_path) | ||
| 116 | cog.scheduled_tasks = [ | ||
| 117 | {"id": 1, "cron_expression": "* * * * *"}, | ||
| 118 | {"id": 2, "cron_expression": "0 * * * *"}, | ||
| 119 | ] | ||
| 120 | |||
| 121 | new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1 | ||
| 122 | assert new_id == 3 | ||
| 123 | |||
| 124 | def test_find_task_by_id(self, mock_bot, tmp_path): | ||
| 125 | cog = create_cog(mock_bot, tmp_path) | ||
| 126 | cog.scheduled_tasks = [ | ||
| 127 | {"id": 1, "cron_expression": "* * * * *"}, | ||
| 128 | {"id": 2, "cron_expression": "0 * * * *"}, | ||
| 129 | ] | ||
| 130 | |||
| 131 | found_task = next((t for t in cog.scheduled_tasks if t["id"] == 2), None) | ||
| 132 | assert found_task is not None | ||
| 133 | assert found_task["cron_expression"] == "0 * * * *" | ||
| 134 | |||
| 135 | def test_remove_task_by_index(self, mock_bot, tmp_path): | ||
| 136 | cog = create_cog(mock_bot, tmp_path) | ||
| 137 | cog.scheduled_tasks = [ | ||
| 138 | {"id": 1, "cron_expression": "* * * * *"}, | ||
| 139 | {"id": 2, "cron_expression": "0 * * * *"}, | ||
| 140 | ] | ||
| 141 | |||
| 142 | cog.scheduled_tasks.pop(0) | ||
| 143 | assert len(cog.scheduled_tasks) == 1 | ||
| 144 | assert cog.scheduled_tasks[0]["id"] == 2 | ||
| 145 | |||
| 146 | def test_toggle_task_active(self, mock_bot, tmp_path): | ||
| 147 | cog = create_cog(mock_bot, tmp_path) | ||
| 148 | cog.scheduled_tasks = [{"id": 1, "active": True}] | ||
| 149 | |||
| 150 | cog.scheduled_tasks[0]["active"] = False | ||
| 151 | assert cog.scheduled_tasks[0]["active"] is False | ||
| 152 | |||
| 153 | cog.scheduled_tasks[0]["active"] = True | ||
| 154 | assert cog.scheduled_tasks[0]["active"] is True | ||
| 155 | |||
| 156 | |||
| 157 | class TestTaskTypes: | ||
| 158 | def test_task_type_audio_aliases(self, mock_bot, tmp_path): | ||
| 159 | audio_types = ("zagraj_audio", "audio") | ||
| 160 | for task_type in audio_types: | ||
| 161 | assert task_type in ("zagraj_audio", "audio") | ||
| 162 | |||
| 163 | def test_task_type_message_aliases(self, mock_bot, tmp_path): | ||
| 164 | message_types = ("wyślij_wiadomość", "send_message") | ||
| 165 | for task_type in message_types: | ||
| 166 | assert task_type in ("wyślij_wiadomość", "send_message") | ||
diff --git a/tests/cogs/test_downloader.py b/tests/cogs/test_downloader.py new file mode 100644 index 0000000..6ac8ae7 --- /dev/null +++ b/tests/cogs/test_downloader.py | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import discord | ||
| 4 | from tests.conftest import MockApplicationContext, MockMessage | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def downloader_cog(bot): | ||
| 9 | from cogs.downloader import Downloader | ||
| 10 | cog = Downloader(bot) | ||
| 11 | return cog | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.mark.asyncio | ||
| 15 | async def test_download_command_success_file(downloader_cog, mock_channel, mock_guild, bot): | ||
| 16 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 17 | |||
| 18 | mock_file_path = "/tmp/test_video.mp4" | ||
| 19 | |||
| 20 | with patch('cogs.downloader.Downloader.download_standalone', return_value={ | ||
| 21 | "status": "success", | ||
| 22 | "type": "file", | ||
| 23 | "content": mock_file_path, | ||
| 24 | "filename": "test_video.mp4" | ||
| 25 | }): | ||
| 26 | with patch('discord.File', return_value=MagicMock()) as mock_file: | ||
| 27 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 28 | await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=False, fformat="mp4", filename_user=None) | ||
| 29 | mock_respond.assert_called_once() | ||
| 30 | call_args = mock_respond.call_args | ||
| 31 | assert 'file' in str(call_args) or mock_file.called | ||
| 32 | |||
| 33 | |||
| 34 | @pytest.mark.asyncio | ||
| 35 | async def test_download_command_success_url(downloader_cog, mock_channel, mock_guild, bot): | ||
| 36 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 37 | |||
| 38 | with patch('cogs.downloader.Downloader.download_standalone', return_value={ | ||
| 39 | "status": "success", | ||
| 40 | "type": "url", | ||
| 41 | "content": "https://catbox.2137697.xyz/sperma/test.mp4" | ||
| 42 | }): | ||
| 43 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 44 | await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=True, fformat="mp4", filename_user=None) | ||
| 45 | mock_respond.assert_called_once() | ||
| 46 | call_args = mock_respond.call_args | ||
| 47 | assert "pobioron" in str(call_args) | ||
| 48 | |||
| 49 | |||
| 50 | @pytest.mark.asyncio | ||
| 51 | async def test_download_command_error(downloader_cog, mock_channel, mock_guild, bot): | ||
| 52 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 53 | |||
| 54 | with patch('cogs.downloader.Downloader.download_standalone', return_value={ | ||
| 55 | "status": "error", | ||
| 56 | "type": "error", | ||
| 57 | "content": "Download failed", | ||
| 58 | "details": "Connection timeout" | ||
| 59 | }): | ||
| 60 | with patch('cogs.downloader.logger') as mock_logger: | ||
| 61 | await downloader_cog.download.callback(downloader_cog, ctx, url="https://badurl.com", catbox=False, fformat="mp4", filename_user=None) | ||
| 62 | assert mock_logger.error.called | ||
| 63 | |||
| 64 | |||
| 65 | @pytest.mark.asyncio | ||
| 66 | async def test_message_download_with_url(downloader_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 67 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 68 | |||
| 69 | mock_message = MagicMock() | ||
| 70 | mock_message.content = "Check this out https://example.com/video" | ||
| 71 | |||
| 72 | with patch('cogs.downloader.Downloader.download_standalone', return_value={ | ||
| 73 | "status": "success", | ||
| 74 | "type": "file", | ||
| 75 | "content": "/tmp/test.mp4" | ||
| 76 | }): | ||
| 77 | with patch.object(downloader_cog, 'download', new_callable=AsyncMock) as mock_download: | ||
| 78 | await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message) | ||
| 79 | mock_download.assert_called_once() | ||
| 80 | |||
| 81 | |||
| 82 | @pytest.mark.asyncio | ||
| 83 | async def test_message_download_no_url(downloader_cog, mock_channel, mock_guild, bot): | ||
| 84 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 85 | |||
| 86 | mock_message = MagicMock() | ||
| 87 | mock_message.content = "No URL here" | ||
| 88 | |||
| 89 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 90 | await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message) | ||
| 91 | mock_respond.assert_called_once() | ||
| 92 | call_args = mock_respond.call_args | ||
| 93 | assert "Brak" in str(call_args) | ||
diff --git a/tests/cogs/test_math_commands.py b/tests/cogs/test_math_commands.py new file mode 100644 index 0000000..5c78027 --- /dev/null +++ b/tests/cogs/test_math_commands.py | |||
| @@ -0,0 +1,55 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from tests.conftest import MockApplicationContext | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def math_cog(bot): | ||
| 8 | from cogs.math_commands import Math | ||
| 9 | cog = Math(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.mark.asyncio | ||
| 14 | async def test_math_command_valid_expression(math_cog, mock_channel, mock_guild, bot): | ||
| 15 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 16 | |||
| 17 | mock_process = MagicMock() | ||
| 18 | mock_process.returncode = 0 | ||
| 19 | mock_process.communicate = AsyncMock(return_value=(b"4", b"")) | ||
| 20 | |||
| 21 | with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process) as mock_subprocess: | ||
| 22 | with patch('cogs.math_commands.re.search', return_value=None): | ||
| 23 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 24 | await math_cog.math.callback(math_cog, ctx, equation="2+2") | ||
| 25 | mock_subprocess.assert_called_once() | ||
| 26 | mock_respond.assert_called_once() | ||
| 27 | |||
| 28 | |||
| 29 | @pytest.mark.asyncio | ||
| 30 | async def test_math_command_invalid_expression(math_cog, mock_channel, mock_guild, bot): | ||
| 31 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 32 | |||
| 33 | with patch('cogs.math_commands.re.search', return_value=MagicMock()): | ||
| 34 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 35 | await math_cog.math.callback(math_cog, ctx, equation="eval(1)") | ||
| 36 | mock_respond.assert_called_once() | ||
| 37 | args = mock_respond.call_args | ||
| 38 | assert "Invalid" in str(args) | ||
| 39 | |||
| 40 | |||
| 41 | @pytest.mark.asyncio | ||
| 42 | async def test_math_command_process_error(math_cog, mock_channel, mock_guild, bot): | ||
| 43 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 44 | |||
| 45 | mock_process = MagicMock() | ||
| 46 | mock_process.returncode = 1 | ||
| 47 | mock_process.communicate = AsyncMock(return_value=(b"", b"Error: invalid expression")) | ||
| 48 | |||
| 49 | with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process): | ||
| 50 | with patch('cogs.math_commands.re.search', return_value=None): | ||
| 51 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 52 | await math_cog.math.callback(math_cog, ctx, equation="2++") | ||
| 53 | mock_respond.assert_called_once() | ||
| 54 | args = mock_respond.call_args | ||
| 55 | assert "error" in str(args).lower() or "Error" in str(args) | ||
diff --git a/tests/cogs/test_music_audio.py b/tests/cogs/test_music_audio.py new file mode 100644 index 0000000..7ea902e --- /dev/null +++ b/tests/cogs/test_music_audio.py | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import os | ||
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.mark.asyncio | ||
| 8 | async def test_validate_voice_channel_not_in_voice(): | ||
| 9 | from cogs.music.audio_playback import AudioPlaybackManager | ||
| 10 | |||
| 11 | mock_ctx = MagicMock() | ||
| 12 | mock_ctx.author.voice = None | ||
| 13 | mock_ctx.respond = AsyncMock() | ||
| 14 | |||
| 15 | result = await AudioPlaybackManager.validate_voice_channel(mock_ctx) | ||
| 16 | |||
| 17 | assert result is None | ||
| 18 | mock_ctx.respond.assert_called_once() | ||
| 19 | |||
| 20 | |||
| 21 | @pytest.mark.asyncio | ||
| 22 | async def test_validate_voice_channel_in_voice(): | ||
| 23 | from cogs.music.audio_playback import AudioPlaybackManager | ||
| 24 | |||
| 25 | mock_channel = MagicMock() | ||
| 26 | mock_ctx = MagicMock() | ||
| 27 | mock_ctx.author.voice = MagicMock() | ||
| 28 | mock_ctx.author.voice.channel = mock_channel | ||
| 29 | mock_ctx.respond = AsyncMock() | ||
| 30 | |||
| 31 | result = await AudioPlaybackManager.validate_voice_channel(mock_ctx) | ||
| 32 | |||
| 33 | assert result == mock_channel | ||
| 34 | |||
| 35 | |||
| 36 | @pytest.mark.asyncio | ||
| 37 | async def test_prepare_file_source_file_not_found(): | ||
| 38 | from cogs.music.audio_playback import AudioPlaybackManager | ||
| 39 | |||
| 40 | display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/nonexistent/file.mp3") | ||
| 41 | |||
| 42 | assert display_name is None | ||
| 43 | assert absolute_path is None | ||
| 44 | |||
| 45 | |||
| 46 | @pytest.mark.asyncio | ||
| 47 | async def test_prepare_file_source_absolute_path(): | ||
| 48 | from cogs.music.audio_playback import AudioPlaybackManager | ||
| 49 | |||
| 50 | with patch('os.path.exists', return_value=True): | ||
| 51 | with patch('os.path.isabs', return_value=True): | ||
| 52 | display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/tmp/test.mp3") | ||
| 53 | |||
| 54 | assert display_name == "test" | ||
| 55 | assert absolute_path == "/tmp/test.mp3" | ||
| 56 | |||
| 57 | |||
| 58 | @pytest.mark.asyncio | ||
| 59 | async def test_prepare_source_url(): | ||
| 60 | from cogs.music.audio_playback import AudioPlaybackManager | ||
| 61 | |||
| 62 | source, _, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "https://example.com/stream") | ||
| 63 | |||
| 64 | assert is_url == True | ||
| 65 | assert source == "https://example.com/stream" | ||
| 66 | |||
| 67 | |||
| 68 | @pytest.mark.asyncio | ||
| 69 | async def test_prepare_source_local_file(): | ||
| 70 | from cogs.music.audio_playback import AudioPlaybackManager | ||
| 71 | |||
| 72 | with patch('os.path.exists', return_value=True): | ||
| 73 | source, display_name, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "/tmp/test.mp3") | ||
| 74 | |||
| 75 | assert is_url == False | ||
| 76 | assert display_name == "test" | ||
| 77 | |||
| 78 | |||
| 79 | @pytest.mark.asyncio | ||
| 80 | async def test_cleanup_temp_file(): | ||
| 81 | from cogs.music.audio_playback import AudioPlaybackManager | ||
| 82 | |||
| 83 | with patch('os.path.exists', return_value=True): | ||
| 84 | with patch('os.remove'): | ||
| 85 | await AudioPlaybackManager.cleanup_temp_file("/tmp/test.mp3") | ||
| 86 | |||
| 87 | |||
| 88 | @pytest.mark.asyncio | ||
| 89 | async def test_cleanup_temp_file_not_exists(): | ||
| 90 | from cogs.music.audio_playback import AudioPlaybackManager | ||
| 91 | |||
| 92 | with patch('os.path.exists', return_value=False): | ||
| 93 | await AudioPlaybackManager.cleanup_temp_file("/nonexistent/file.mp3") | ||
diff --git a/tests/cogs/test_music_external_play.py b/tests/cogs/test_music_external_play.py new file mode 100644 index 0000000..fd4b625 --- /dev/null +++ b/tests/cogs/test_music_external_play.py | |||
| @@ -0,0 +1,116 @@ | |||
| 1 | import pytest | ||
| 2 | import os | ||
| 3 | import asyncio | ||
| 4 | import tempfile | ||
| 5 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 6 | from tests.conftest import MockApplicationContext | ||
| 7 | |||
| 8 | |||
| 9 | @pytest.fixture | ||
| 10 | def play_commands_cog(bot): | ||
| 11 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 12 | from cogs.music.play_commands import PlayCommands | ||
| 13 | cog = PlayCommands(bot) | ||
| 14 | return cog | ||
| 15 | |||
| 16 | |||
| 17 | @pytest.fixture | ||
| 18 | def temp_mp3_file(): | ||
| 19 | with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: | ||
| 20 | f.write(b'fake mp3 content') | ||
| 21 | temp_path = f.name | ||
| 22 | yield temp_path | ||
| 23 | if os.path.exists(temp_path): | ||
| 24 | os.remove(temp_path) | ||
| 25 | |||
| 26 | |||
| 27 | @pytest.mark.asyncio | ||
| 28 | async def test_external_url_pattern_matches_youtube(play_commands_cog): | ||
| 29 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 30 | |||
| 31 | assert EXTERNAL_URL_PATTERN.match("https://youtube.com/watch?v=test") | ||
| 32 | assert EXTERNAL_URL_PATTERN.match("https://youtu.be/test") | ||
| 33 | assert EXTERNAL_URL_PATTERN.match("https://www.youtube.com/watch?v=test") | ||
| 34 | |||
| 35 | |||
| 36 | @pytest.mark.asyncio | ||
| 37 | async def test_external_url_pattern_matches_soundcloud(play_commands_cog): | ||
| 38 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 39 | |||
| 40 | assert EXTERNAL_URL_PATTERN.match("https://soundcloud.com/artist/track") | ||
| 41 | assert EXTERNAL_URL_PATTERN.match("https://api.soundcloud.com/tracks") | ||
| 42 | |||
| 43 | |||
| 44 | @pytest.mark.asyncio | ||
| 45 | async def test_external_url_pattern_matches_bandcamp(play_commands_cog): | ||
| 46 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 47 | |||
| 48 | assert EXTERNAL_URL_PATTERN.match("https://artist.bandcamp.com/track") | ||
| 49 | |||
| 50 | |||
| 51 | @pytest.mark.asyncio | ||
| 52 | async def test_external_url_pattern_rejects_copyparty(play_commands_cog): | ||
| 53 | from cogs.music.play_commands import EXTERNAL_URL_PATTERN | ||
| 54 | |||
| 55 | assert not EXTERNAL_URL_PATTERN.match("https://catbox.2137697.xyz/sperma/file.mp3") | ||
| 56 | assert not EXTERNAL_URL_PATTERN.match("some search query") | ||
| 57 | |||
| 58 | |||
| 59 | @pytest.mark.asyncio | ||
| 60 | async def test_play_command_calls_external_handler(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 61 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 62 | |||
| 63 | with patch.object(play_commands_cog, '_handle_external_url_play', new_callable=AsyncMock) as mock_handler: | ||
| 64 | mock_handler.return_value = None | ||
| 65 | mock_defer = AsyncMock() | ||
| 66 | ctx.defer = mock_defer | ||
| 67 | ctx.respond = AsyncMock() | ||
| 68 | |||
| 69 | await play_commands_cog.play.callback( | ||
| 70 | play_commands_cog, ctx, | ||
| 71 | melodia="https://youtube.com/watch?v=test", | ||
| 72 | file=None, compressed=False | ||
| 73 | ) | ||
| 74 | |||
| 75 | mock_handler.assert_called_once_with(ctx, "https://youtube.com/watch?v=test") | ||
| 76 | |||
| 77 | |||
| 78 | @pytest.mark.asyncio | ||
| 79 | async def test_play_command_calls_local_handler(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 80 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 81 | |||
| 82 | with patch.object(play_commands_cog, '_handle_local_file_play', new_callable=AsyncMock) as mock_handler: | ||
| 83 | mock_handler.return_value = None | ||
| 84 | mock_defer = AsyncMock() | ||
| 85 | ctx.defer = mock_defer | ||
| 86 | ctx.respond = AsyncMock() | ||
| 87 | |||
| 88 | await play_commands_cog.play.callback( | ||
| 89 | play_commands_cog, ctx, | ||
| 90 | melodia="some search query", | ||
| 91 | file=None, compressed=False | ||
| 92 | ) | ||
| 93 | |||
| 94 | mock_handler.assert_called_once_with(ctx, "some search query", False) | ||
| 95 | |||
| 96 | |||
| 97 | @pytest.mark.asyncio | ||
| 98 | async def test_upload_to_copyparty_async(play_commands_cog, temp_mp3_file, monkeypatch): | ||
| 99 | monkeypatch.setenv("COPYPARTY_URL_UPLOAD", "https://catbox.2137697.xyz/sperma/") | ||
| 100 | monkeypatch.setenv("COPYPARTY_PASSWORD", "testpassword") | ||
| 101 | |||
| 102 | mock_response = AsyncMock() | ||
| 103 | mock_response.status = 200 | ||
| 104 | mock_response.text = AsyncMock(return_value="") | ||
| 105 | mock_response.json = AsyncMock(return_value={"fileurl": "https://example.com/file.mp3"}) | ||
| 106 | mock_response.__aenter__ = AsyncMock(return_value=mock_response) | ||
| 107 | mock_response.__aexit__ = AsyncMock() | ||
| 108 | |||
| 109 | mock_session_context = AsyncMock() | ||
| 110 | mock_session_context.__aenter__ = AsyncMock(return_value=mock_session_context) | ||
| 111 | mock_session_context.__aexit__ = AsyncMock() | ||
| 112 | mock_session_context.post = MagicMock(return_value=mock_response) | ||
| 113 | |||
| 114 | with patch('aiohttp.ClientSession', return_value=mock_session_context): | ||
| 115 | await play_commands_cog._upload_to_copyparty_async(temp_mp3_file) | ||
| 116 | mock_session_context.post.assert_called_once() | ||
diff --git a/tests/cogs/test_music_join.py b/tests/cogs/test_music_join.py new file mode 100644 index 0000000..3c7ca6e --- /dev/null +++ b/tests/cogs/test_music_join.py | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from tests.conftest import MockApplicationContext | ||
| 4 | |||
| 5 | |||
| 6 | @pytest.fixture | ||
| 7 | def join_cog(bot): | ||
| 8 | from cogs.music.join import Join | ||
| 9 | cog = Join(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.fixture | ||
| 14 | def stop_cog(bot): | ||
| 15 | from cogs.music.stop import Stop | ||
| 16 | cog = Stop(bot) | ||
| 17 | return cog | ||
| 18 | |||
| 19 | |||
| 20 | @pytest.mark.asyncio | ||
| 21 | async def test_join_command_success(join_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 22 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 23 | ctx.user = mock_user | ||
| 24 | ctx.author = mock_user | ||
| 25 | |||
| 26 | mock_voice_channel = MagicMock() | ||
| 27 | mock_voice = MagicMock() | ||
| 28 | mock_voice_channel.connect = AsyncMock(return_value=mock_voice) | ||
| 29 | |||
| 30 | ctx.author.voice = MagicMock() | ||
| 31 | ctx.author.voice.channel = mock_voice_channel | ||
| 32 | |||
| 33 | with patch.object(mock_voice_channel, 'connect', new_callable=AsyncMock, return_value=mock_voice): | ||
| 34 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | ||
| 35 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | ||
| 36 | await join_cog.join.callback(join_cog, ctx) | ||
| 37 | |||
| 38 | |||
| 39 | @pytest.mark.asyncio | ||
| 40 | async def test_stop_command_success(stop_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 41 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 42 | ctx.user = mock_user | ||
| 43 | ctx.author = mock_user | ||
| 44 | |||
| 45 | mock_voice_client = MagicMock() | ||
| 46 | mock_voice_client.disconnect = AsyncMock() | ||
| 47 | ctx.voice_client = mock_voice_client | ||
| 48 | |||
| 49 | with patch.object(ctx, 'respond', new_callable=AsyncMock): | ||
| 50 | with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): | ||
| 51 | await stop_cog.stop.callback(stop_cog, ctx) | ||
| 52 | |||
| 53 | mock_voice_client.disconnect.assert_called_once() | ||
diff --git a/tests/cogs/test_music_play.py b/tests/cogs/test_music_play.py new file mode 100644 index 0000000..e720c9a --- /dev/null +++ b/tests/cogs/test_music_play.py | |||
| @@ -0,0 +1,104 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import os | ||
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def play_commands_cog(bot): | ||
| 9 | with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): | ||
| 10 | from cogs.music.play_commands import PlayCommands | ||
| 11 | cog = PlayCommands(bot) | ||
| 12 | return cog | ||
| 13 | |||
| 14 | |||
| 15 | @pytest.fixture | ||
| 16 | def loop_cog(bot): | ||
| 17 | from cogs.music.loop import Loop | ||
| 18 | cog = Loop(bot) | ||
| 19 | return cog | ||
| 20 | |||
| 21 | |||
| 22 | @pytest.mark.asyncio | ||
| 23 | async def test_create_search_embed_empty(play_commands_cog): | ||
| 24 | embed, view = await play_commands_cog._create_search_embed([]) | ||
| 25 | |||
| 26 | assert embed.title == "Wyniki wyszukiwania" | ||
| 27 | assert len(view.children) == 0 | ||
| 28 | |||
| 29 | |||
| 30 | @pytest.mark.asyncio | ||
| 31 | async def test_create_search_embed_with_candidates(play_commands_cog): | ||
| 32 | candidates = [ | ||
| 33 | "https://example.com/test1.mp3", | ||
| 34 | "https://example.com/test2.mp3" | ||
| 35 | ] | ||
| 36 | |||
| 37 | embed, view = await play_commands_cog._create_search_embed(candidates) | ||
| 38 | |||
| 39 | assert embed.title == "Wyniki wyszukiwania" | ||
| 40 | assert len(embed.fields) == 2 | ||
| 41 | |||
| 42 | |||
| 43 | @pytest.mark.asyncio | ||
| 44 | async def test_play_command_no_file_or_search(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 45 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 46 | |||
| 47 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 48 | await play_commands_cog.play.callback(play_commands_cog, ctx, melodia=None, file=None, compressed=False) | ||
| 49 | mock_respond.assert_called_once() | ||
| 50 | args = mock_respond.call_args | ||
| 51 | assert "Musisz podać" in str(args) | ||
| 52 | |||
| 53 | |||
| 54 | @pytest.mark.asyncio | ||
| 55 | async def test_play_command_both_file_and_search(play_commands_cog, mock_channel, mock_guild, bot): | ||
| 56 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 57 | |||
| 58 | mock_file = MagicMock() | ||
| 59 | mock_file.filename = "test.mp3" | ||
| 60 | |||
| 61 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 62 | await play_commands_cog.play.callback(play_commands_cog, ctx, melodia="test", file=mock_file, compressed=False) | ||
| 63 | mock_respond.assert_called_once() | ||
| 64 | args = mock_respond.call_args | ||
| 65 | assert "nie oba" in str(args) or "LUB" in str(args) | ||
| 66 | |||
| 67 | |||
| 68 | def test_loop_cog_is_looping_initial(loop_cog): | ||
| 69 | assert loop_cog.is_looping(123) == False | ||
| 70 | |||
| 71 | |||
| 72 | def test_loop_cog_set_looping(loop_cog): | ||
| 73 | loop_cog.set_looping(123, True) | ||
| 74 | assert loop_cog.is_looping(123) == True | ||
| 75 | |||
| 76 | |||
| 77 | @pytest.mark.asyncio | ||
| 78 | async def test_loop_command_toggle_on(loop_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 79 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 80 | ctx.user = mock_user | ||
| 81 | ctx.author = mock_user | ||
| 82 | ctx.guild.id = 123 | ||
| 83 | |||
| 84 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 85 | await loop_cog.loop.callback(loop_cog, ctx) | ||
| 86 | mock_respond.assert_called_once() | ||
| 87 | args = mock_respond.call_args | ||
| 88 | assert "włączony" in str(args) | ||
| 89 | |||
| 90 | |||
| 91 | @pytest.mark.asyncio | ||
| 92 | async def test_loop_command_toggle_off(loop_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 93 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 94 | ctx.user = mock_user | ||
| 95 | ctx.author = mock_user | ||
| 96 | ctx.guild.id = 456 | ||
| 97 | |||
| 98 | loop_cog.set_looping(456, True) | ||
| 99 | |||
| 100 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 101 | await loop_cog.loop.callback(loop_cog, ctx) | ||
| 102 | mock_respond.assert_called_once() | ||
| 103 | args = mock_respond.call_args | ||
| 104 | assert "wyłączony" in str(args) | ||
diff --git a/tests/cogs/test_qr.py b/tests/cogs/test_qr.py new file mode 100644 index 0000000..2a132ca --- /dev/null +++ b/tests/cogs/test_qr.py | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock, mock_open | ||
| 3 | import os | ||
| 4 | from cogs.qr import QR | ||
| 5 | from tests.conftest import MockApplicationContext | ||
| 6 | |||
| 7 | |||
| 8 | @pytest.fixture | ||
| 9 | def qr_cog(bot): | ||
| 10 | cog = QR(bot) | ||
| 11 | return cog | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.fixture | ||
| 15 | def mock_temp_file(): | ||
| 16 | with patch('tempfile.NamedTemporaryFile') as mock: | ||
| 17 | mock_file = MagicMock() | ||
| 18 | mock_file.name = "/tmp/test_qr.png" | ||
| 19 | mock.return_value = mock_file | ||
| 20 | yield mock_file | ||
| 21 | |||
| 22 | |||
| 23 | @pytest.mark.asyncio | ||
| 24 | async def test_qr_generate_success(qr_cog, mock_channel, mock_guild, bot, mock_temp_file): | ||
| 25 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 26 | |||
| 27 | with patch('cogs.qr.QR._generate_qr') as mock_generate: | ||
| 28 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 29 | with patch('os.path.exists', return_value=True): | ||
| 30 | with patch('os.unlink'): | ||
| 31 | await qr_cog.qr.callback(qr_cog, ctx, text="https://example.com") | ||
| 32 | mock_generate.assert_called_once() | ||
| 33 | mock_respond.assert_called_once() | ||
| 34 | |||
| 35 | |||
| 36 | @pytest.mark.asyncio | ||
| 37 | async def test_qr_generate_empty_text(qr_cog, mock_channel, mock_guild, bot): | ||
| 38 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 39 | |||
| 40 | with patch('cogs.qr.QR._generate_qr'): | ||
| 41 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 42 | with patch('os.path.exists', return_value=False): | ||
| 43 | await qr_cog.qr.callback(qr_cog, ctx, text="") | ||
| 44 | mock_respond.assert_called_once() | ||
| 45 | |||
| 46 | |||
| 47 | @pytest.mark.asyncio | ||
| 48 | async def test_qr_decode_not_available(qr_cog, mock_channel, mock_guild, bot): | ||
| 49 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 50 | |||
| 51 | mock_attachment = MagicMock() | ||
| 52 | mock_attachment.url = "https://example.com/qr.png" | ||
| 53 | |||
| 54 | with patch('cogs.qr._ensure_qr', return_value=False): | ||
| 55 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 56 | await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment) | ||
| 57 | mock_respond.assert_called_once() | ||
| 58 | args = mock_respond.call_args | ||
| 59 | assert "nie jest dostępne" in str(args) or "not available" in str(args).lower() | ||
| 60 | |||
| 61 | |||
| 62 | @pytest.mark.asyncio | ||
| 63 | async def test_qr_decode_download_failure(qr_cog, mock_channel, mock_guild, bot): | ||
| 64 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 65 | |||
| 66 | mock_attachment = MagicMock() | ||
| 67 | mock_attachment.url = "https://example.com/qr.png" | ||
| 68 | |||
| 69 | with patch('cogs.qr._ensure_qr', return_value=True): | ||
| 70 | with patch.object(qr_cog, '_download_image', return_value=None): | ||
| 71 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 72 | await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment) | ||
| 73 | mock_respond.assert_called_once() | ||
| 74 | args = mock_respond.call_args | ||
| 75 | assert "Nie udało się" in str(args) or "failed" in str(args).lower() | ||
diff --git a/tests/cogs/test_say.py b/tests/cogs/test_say.py new file mode 100644 index 0000000..13cad40 --- /dev/null +++ b/tests/cogs/test_say.py | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch | ||
| 3 | from cogs.say import Say | ||
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def say_cog(bot): | ||
| 9 | cog = Say(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.mark.asyncio | ||
| 14 | async def test_say_command(say_cog, mock_channel, mock_guild, bot): | ||
| 15 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 16 | |||
| 17 | with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: | ||
| 18 | await say_cog.say.callback(say_cog, ctx, "Hello World") | ||
| 19 | mock_send.assert_called_once_with(content="Hello World") | ||
| 20 | |||
| 21 | |||
| 22 | @pytest.mark.asyncio | ||
| 23 | async def test_say_command_empty_text(say_cog, mock_channel, mock_guild, bot): | ||
| 24 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 25 | |||
| 26 | with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: | ||
| 27 | await say_cog.say.callback(say_cog, ctx, "") | ||
| 28 | mock_send.assert_called_once_with(content="") | ||
| 29 | |||
| 30 | |||
| 31 | @pytest.mark.asyncio | ||
| 32 | async def test_say_command_long_text(say_cog, mock_channel, mock_guild, bot): | ||
| 33 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 34 | long_text = "A" * 2000 | ||
| 35 | |||
| 36 | with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: | ||
| 37 | await say_cog.say.callback(say_cog, ctx, long_text) | ||
| 38 | mock_send.assert_called_once_with(content=long_text) | ||
diff --git a/tests/cogs/test_search.py b/tests/cogs/test_search.py new file mode 100644 index 0000000..8c04dfc --- /dev/null +++ b/tests/cogs/test_search.py | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import discord | ||
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def search_cog(bot): | ||
| 9 | from cogs.search import Search | ||
| 10 | cog = Search(bot) | ||
| 11 | return cog | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.mark.asyncio | ||
| 15 | async def test_search_command_success(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 16 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 17 | ctx.user = mock_user | ||
| 18 | ctx.author = mock_user | ||
| 19 | |||
| 20 | search_cog._cached_results[mock_user.id] = { | ||
| 21 | "testfile.mp4": "https://example.com/files/testfile.mp4" | ||
| 22 | } | ||
| 23 | |||
| 24 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 25 | await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4") | ||
| 26 | mock_followup.assert_called_once_with("https://example.com/files/testfile.mp4") | ||
| 27 | assert mock_user.id not in search_cog._cached_results | ||
| 28 | |||
| 29 | |||
| 30 | @pytest.mark.asyncio | ||
| 31 | async def test_search_command_no_cache(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 32 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 33 | ctx.user = mock_user | ||
| 34 | ctx.author = mock_user | ||
| 35 | |||
| 36 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 37 | await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4") | ||
| 38 | mock_followup.assert_called_once_with("Search results expired. Please search again.") | ||
| 39 | |||
| 40 | |||
| 41 | @pytest.mark.asyncio | ||
| 42 | async def test_search_command_file_not_found(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 43 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 44 | ctx.user = mock_user | ||
| 45 | ctx.author = mock_user | ||
| 46 | |||
| 47 | search_cog._cached_results[mock_user.id] = { | ||
| 48 | "testfile.mp4": "https://example.com/files/testfile.mp4" | ||
| 49 | } | ||
| 50 | |||
| 51 | with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: | ||
| 52 | await search_cog.search.callback(search_cog, ctx, filename="nonexistent.mp4") | ||
| 53 | mock_followup.assert_called_once_with("File 'nonexistent.mp4' not found.") | ||
| 54 | |||
| 55 | |||
| 56 | @pytest.mark.asyncio | ||
| 57 | async def test_search_command_empty_filename(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 58 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 59 | ctx.user = mock_user | ||
| 60 | ctx.author = mock_user | ||
| 61 | |||
| 62 | with patch.object(ctx, 'reply', new_callable=AsyncMock) as mock_reply: | ||
| 63 | await search_cog.search.callback(search_cog, ctx, filename="") | ||
| 64 | mock_reply.assert_called_once_with("Please select a file from the autocomplete options.") | ||
| 65 | |||
| 66 | |||
| 67 | @pytest.mark.asyncio | ||
| 68 | async def test_file_autocomplete(search_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 69 | mock_autocomplete_ctx = MagicMock() | ||
| 70 | mock_autocomplete_ctx.value = "test" | ||
| 71 | mock_autocomplete_ctx.interaction = MagicMock() | ||
| 72 | mock_autocomplete_ctx.interaction.user = mock_user | ||
| 73 | |||
| 74 | with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: | ||
| 75 | mock_search.return_value = [ | ||
| 76 | "https://example.com/files/test1.mp4", | ||
| 77 | "https://example.com/files/test2.mp4" | ||
| 78 | ] | ||
| 79 | |||
| 80 | choices = await search_cog.file_autocomplete(mock_autocomplete_ctx) | ||
| 81 | |||
| 82 | assert len(choices) == 2 | ||
| 83 | assert mock_user.id in search_cog._cached_results | ||
diff --git a/tests/cogs/test_trigger.py b/tests/cogs/test_trigger.py new file mode 100644 index 0000000..6aaaa11 --- /dev/null +++ b/tests/cogs/test_trigger.py | |||
| @@ -0,0 +1,106 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import sqlite3 | ||
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def trigger_cog(bot): | ||
| 9 | with patch('cogs.trigger.get_connection') as mock_conn: | ||
| 10 | mock_conn.return_value.__enter__ = MagicMock(return_value=MagicMock()) | ||
| 11 | mock_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 12 | from cogs.trigger import Trigger | ||
| 13 | cog = Trigger(bot) | ||
| 14 | return cog | ||
| 15 | |||
| 16 | |||
| 17 | @pytest.fixture | ||
| 18 | def mock_db_connection(): | ||
| 19 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 20 | mock_conn = MagicMock() | ||
| 21 | mock_cursor = MagicMock() | ||
| 22 | mock_conn.cursor.return_value = mock_cursor | ||
| 23 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 24 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 25 | yield mock_conn, mock_cursor | ||
| 26 | |||
| 27 | |||
| 28 | @pytest.mark.asyncio | ||
| 29 | async def test_trigger_create_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): | ||
| 30 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 31 | |||
| 32 | mock_conn, mock_cursor = mock_db_connection | ||
| 33 | mock_cursor.fetchone.return_value = None | ||
| 34 | |||
| 35 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 36 | mock_conn = MagicMock() | ||
| 37 | mock_cursor = MagicMock() | ||
| 38 | mock_conn.cursor.return_value = mock_cursor | ||
| 39 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 40 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 41 | |||
| 42 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 43 | await trigger_cog.create.callback(trigger_cog, ctx, name="test_trigger") | ||
| 44 | mock_respond.assert_called_once_with("trigger added") | ||
| 45 | |||
| 46 | |||
| 47 | @pytest.mark.asyncio | ||
| 48 | async def test_trigger_add_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): | ||
| 49 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 50 | |||
| 51 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 52 | mock_conn = MagicMock() | ||
| 53 | mock_cursor = MagicMock() | ||
| 54 | mock_cursor.fetchone.return_value = (1,) # trigger exists | ||
| 55 | mock_conn.cursor.return_value = mock_cursor | ||
| 56 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 57 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 58 | |||
| 59 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 60 | await trigger_cog.add.callback(trigger_cog, ctx, name="test_trigger", trigger="hello", response="Hi there!") | ||
| 61 | mock_respond.assert_called_once_with("trigger updated") | ||
| 62 | |||
| 63 | |||
| 64 | @pytest.mark.asyncio | ||
| 65 | async def test_trigger_add_command_not_found(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): | ||
| 66 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 67 | |||
| 68 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 69 | mock_conn = MagicMock() | ||
| 70 | mock_cursor = MagicMock() | ||
| 71 | mock_cursor.fetchone.return_value = None # trigger not found | ||
| 72 | mock_conn.cursor.return_value = mock_cursor | ||
| 73 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 74 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 75 | |||
| 76 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 77 | await trigger_cog.add.callback(trigger_cog, ctx, name="nonexistent", trigger="hello", response="Hi") | ||
| 78 | mock_respond.assert_called_once_with("Trigger not found.") | ||
| 79 | |||
| 80 | |||
| 81 | @pytest.mark.asyncio | ||
| 82 | async def test_trigger_delete_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): | ||
| 83 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 84 | |||
| 85 | with patch('cogs.trigger.get_connection') as mock_get_conn: | ||
| 86 | mock_conn = MagicMock() | ||
| 87 | mock_cursor = MagicMock() | ||
| 88 | mock_cursor.fetchone.return_value = (1,) # trigger exists | ||
| 89 | mock_conn.cursor.return_value = mock_cursor | ||
| 90 | mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) | ||
| 91 | mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) | ||
| 92 | |||
| 93 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 94 | await trigger_cog.delete.callback(trigger_cog, ctx, name="test_trigger") | ||
| 95 | mock_respond.assert_called_once_with("trigger deleted") | ||
| 96 | |||
| 97 | |||
| 98 | @pytest.mark.asyncio | ||
| 99 | async def test_trigger_help_command(trigger_cog, mock_channel, mock_guild, bot): | ||
| 100 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 101 | |||
| 102 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 103 | await trigger_cog.help.callback(trigger_cog, ctx) | ||
| 104 | mock_respond.assert_called_once() | ||
| 105 | args = mock_respond.call_args | ||
| 106 | assert "Trigger create" in str(args) | ||
diff --git a/tests/cogs/test_tts.py b/tests/cogs/test_tts.py new file mode 100644 index 0000000..de68580 --- /dev/null +++ b/tests/cogs/test_tts.py | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import discord | ||
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def tts_cog(bot): | ||
| 9 | from cogs.tts import TTS | ||
| 10 | cog = TTS(bot) | ||
| 11 | return cog | ||
| 12 | |||
| 13 | |||
| 14 | @pytest.mark.asyncio | ||
| 15 | async def test_tts_file_output(tts_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 16 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 17 | ctx.user = mock_user | ||
| 18 | ctx.author = mock_user | ||
| 19 | |||
| 20 | "/tmp/test_tts.mp3" | ||
| 21 | |||
| 22 | with patch('cogs.tts.gTTS') as _: | ||
| 23 | with patch('os.path.exists', return_value=True): | ||
| 24 | with patch('os.unlink'): | ||
| 25 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 26 | with patch('cogs.tts.get_volume', return_value=1.0): | ||
| 27 | await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file") | ||
| 28 | mock_respond.assert_called_once() | ||
| 29 | |||
| 30 | |||
| 31 | @pytest.mark.asyncio | ||
| 32 | async def test_tts_voice_output_not_in_channel(tts_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 33 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 34 | ctx.user = mock_user | ||
| 35 | ctx.author = mock_user | ||
| 36 | ctx.author.voice = None | ||
| 37 | |||
| 38 | with patch('cogs.tts.gTTS'): | ||
| 39 | with patch('os.path.exists', return_value=True): | ||
| 40 | with patch('os.unlink'): | ||
| 41 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 42 | with patch('cogs.tts.get_volume', return_value=1.0): | ||
| 43 | await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="vc") | ||
| 44 | |||
| 45 | |||
| 46 | @pytest.mark.asyncio | ||
| 47 | async def test_tts_error_handling(tts_cog, mock_channel, mock_guild, bot, mock_user): | ||
| 48 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 49 | ctx.user = mock_user | ||
| 50 | ctx.author = mock_user | ||
| 51 | |||
| 52 | with patch('cogs.tts.gTTS', side_effect=Exception("TTS Error")): | ||
| 53 | with patch('os.path.exists', return_value=False): | ||
| 54 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 55 | await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file") | ||
| 56 | mock_respond.assert_called_once() | ||
| 57 | args = mock_respond.call_args | ||
| 58 | assert "Error" in str(args) or "eror" in str(args) | ||
diff --git a/tests/cogs/test_vclogs.py b/tests/cogs/test_vclogs.py new file mode 100644 index 0000000..2003478 --- /dev/null +++ b/tests/cogs/test_vclogs.py | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import json | ||
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | def test_format_duration(): | ||
| 8 | from cogs.vclogs import format_duration | ||
| 9 | |||
| 10 | assert format_duration(30) == "30s" | ||
| 11 | assert format_duration(90) == "1m 30s" | ||
| 12 | assert format_duration(3661) == "1h 1m 1s" | ||
| 13 | |||
| 14 | |||
| 15 | def test_load_activity_data(): | ||
| 16 | with patch('builtins.open', MagicMock()): | ||
| 17 | with patch('os.path.exists', return_value=True): | ||
| 18 | with patch('json.load', return_value={"123": {"active": True}}): | ||
| 19 | from cogs.vclogs import load_activity_data | ||
| 20 | data = load_activity_data() | ||
| 21 | assert isinstance(data, dict) | ||
| 22 | |||
| 23 | |||
| 24 | def test_save_activity_data(): | ||
| 25 | with patch('builtins.open', MagicMock()) as mock_open: | ||
| 26 | with patch('json.dump'): | ||
| 27 | from cogs.vclogs import save_activity_data | ||
| 28 | save_activity_data({"123": {"active": True}}) | ||
| 29 | mock_open.assert_called_once() | ||
| 30 | |||
| 31 | |||
| 32 | @pytest.fixture | ||
| 33 | def vclogs_cog(bot): | ||
| 34 | with patch('cogs.vclogs.get_voice_manager'): | ||
| 35 | with patch('cogs.vclogs.get_volume', return_value=1.0): | ||
| 36 | with patch('cogs.vclogs.save_settings'): | ||
| 37 | from cogs.vclogs import VcLogs | ||
| 38 | cog = VcLogs(bot) | ||
| 39 | return cog | ||
| 40 | |||
| 41 | |||
| 42 | @pytest.mark.asyncio | ||
| 43 | async def test_handle_state_change_deaf_true(vclogs_cog): | ||
| 44 | mock_member = MagicMock() | ||
| 45 | mock_member.display_name = "TestUser" | ||
| 46 | mock_channel = MagicMock() | ||
| 47 | |||
| 48 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', True, None) | ||
| 49 | |||
| 50 | assert "jest bity przez ojca" in tts_text | ||
| 51 | |||
| 52 | |||
| 53 | @pytest.mark.asyncio | ||
| 54 | async def test_handle_state_change_deaf_false(vclogs_cog): | ||
| 55 | mock_member = MagicMock() | ||
| 56 | mock_member.display_name = "TestUser" | ||
| 57 | mock_channel = MagicMock() | ||
| 58 | |||
| 59 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', False, None) | ||
| 60 | |||
| 61 | assert "uciek od ojca" in tts_text | ||
| 62 | |||
| 63 | |||
| 64 | @pytest.mark.asyncio | ||
| 65 | async def test_handle_state_change_mute_true(vclogs_cog): | ||
| 66 | mock_member = MagicMock() | ||
| 67 | mock_member.display_name = "TestUser" | ||
| 68 | mock_channel = MagicMock() | ||
| 69 | |||
| 70 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', True, None) | ||
| 71 | |||
| 72 | assert "idzie robić jajecznice" in tts_text | ||
| 73 | |||
| 74 | |||
| 75 | @pytest.mark.asyncio | ||
| 76 | async def test_handle_state_change_mute_false(vclogs_cog): | ||
| 77 | mock_member = MagicMock() | ||
| 78 | mock_member.display_name = "TestUser" | ||
| 79 | mock_channel = MagicMock() | ||
| 80 | |||
| 81 | tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None) | ||
| 82 | |||
| 83 | assert "zrobił jajecznice" in tts_text | ||
diff --git a/tests/cogs/test_voice.py b/tests/cogs/test_voice.py new file mode 100644 index 0000000..ade29cb --- /dev/null +++ b/tests/cogs/test_voice.py | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | import asyncio | ||
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.mark.asyncio | ||
| 8 | async def test_get_voice_manager(): | ||
| 9 | from cogs.voice import get_voice_manager, VoiceManager | ||
| 10 | |||
| 11 | mock_bot = MagicMock() | ||
| 12 | mock_bot.user = MagicMock() | ||
| 13 | mock_bot.user.id = 12345 | ||
| 14 | |||
| 15 | manager1 = get_voice_manager(mock_bot) | ||
| 16 | manager2 = get_voice_manager(mock_bot) | ||
| 17 | |||
| 18 | assert isinstance(manager1, VoiceManager) | ||
| 19 | assert manager1 is manager2 | ||
| 20 | |||
| 21 | |||
| 22 | @pytest.mark.asyncio | ||
| 23 | async def test_voice_manager_join_channel_not_found(): | ||
| 24 | from cogs.voice import VoiceManager | ||
| 25 | |||
| 26 | mock_bot = MagicMock() | ||
| 27 | mock_bot.get_channel.return_value = None | ||
| 28 | |||
| 29 | manager = VoiceManager(mock_bot) | ||
| 30 | result = await manager.join_by_channel_id(999) | ||
| 31 | |||
| 32 | assert result is None | ||
| 33 | |||
| 34 | |||
| 35 | @pytest.mark.asyncio | ||
| 36 | async def test_voice_manager_join_invalid_channel(): | ||
| 37 | from cogs.voice import VoiceManager | ||
| 38 | |||
| 39 | mock_bot = MagicMock() | ||
| 40 | mock_channel = MagicMock() | ||
| 41 | mock_bot.get_channel.return_value = mock_channel | ||
| 42 | |||
| 43 | manager = VoiceManager(mock_bot) | ||
| 44 | result = await manager.join_by_channel_id(999) | ||
| 45 | |||
| 46 | assert result is None | ||
| 47 | |||
| 48 | |||
| 49 | @pytest.mark.asyncio | ||
| 50 | async def test_voice_manager_get_voice_status_not_connected(): | ||
| 51 | from cogs.voice import VoiceManager | ||
| 52 | |||
| 53 | mock_bot = MagicMock() | ||
| 54 | mock_guild = MagicMock() | ||
| 55 | |||
| 56 | with patch('cogs.voice.VoiceManager.get_voice_client', return_value=None): | ||
| 57 | manager = VoiceManager(mock_bot) | ||
| 58 | voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild) | ||
| 59 | |||
| 60 | assert voice_client is None | ||
| 61 | assert is_connected == False | ||
| 62 | assert is_playing == False | ||
| 63 | |||
| 64 | |||
| 65 | @pytest.mark.asyncio | ||
| 66 | async def test_voice_manager_stop(): | ||
| 67 | from cogs.voice import VoiceManager | ||
| 68 | |||
| 69 | mock_bot = MagicMock() | ||
| 70 | mock_guild = MagicMock() | ||
| 71 | mock_voice_client = MagicMock() | ||
| 72 | mock_voice_client.disconnect = AsyncMock() | ||
| 73 | |||
| 74 | with patch('cogs.voice.VoiceManager.get_voice_client', return_value=mock_voice_client): | ||
| 75 | manager = VoiceManager(mock_bot) | ||
| 76 | await manager.stop(mock_guild) | ||
| 77 | |||
| 78 | mock_voice_client.stop.assert_called_once() | ||
| 79 | mock_voice_client.disconnect.assert_called_once() | ||
diff --git a/tests/cogs/test_web.py b/tests/cogs/test_web.py new file mode 100644 index 0000000..e0d2208 --- /dev/null +++ b/tests/cogs/test_web.py | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | import pytest | ||
| 2 | from unittest.mock import AsyncMock, patch, MagicMock | ||
| 3 | from cogs.web import Web | ||
| 4 | from tests.conftest import MockApplicationContext | ||
| 5 | |||
| 6 | |||
| 7 | @pytest.fixture | ||
| 8 | def web_cog(bot): | ||
| 9 | cog = Web(bot) | ||
| 10 | return cog | ||
| 11 | |||
| 12 | |||
| 13 | @pytest.mark.asyncio | ||
| 14 | async def test_web_command_success(web_cog, mock_channel, mock_guild, bot): | ||
| 15 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 16 | |||
| 17 | mock_response = MagicMock() | ||
| 18 | mock_response.status_code = 200 | ||
| 19 | mock_response.text = ''' | ||
| 20 | <html> | ||
| 21 | <a class="result__a" href="https://example.com">Test Result</a> | ||
| 22 | <a class="result__a" href="https://example.com/2">Test Result 2</a> | ||
| 23 | </html> | ||
| 24 | ''' | ||
| 25 | |||
| 26 | with patch('cogs.web.requests.get', return_value=mock_response) as mock_get: | ||
| 27 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 28 | await web_cog.web.callback(web_cog, ctx, query="test query") | ||
| 29 | mock_get.assert_called_once() | ||
| 30 | mock_respond.assert_called_once() | ||
| 31 | |||
| 32 | |||
| 33 | @pytest.mark.asyncio | ||
| 34 | async def test_web_command_no_results(web_cog, mock_channel, mock_guild, bot): | ||
| 35 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 36 | |||
| 37 | mock_response = MagicMock() | ||
| 38 | mock_response.status_code = 200 | ||
| 39 | mock_response.text = '<html><body></body></html>' | ||
| 40 | |||
| 41 | with patch('cogs.web.requests.get', return_value=mock_response): | ||
| 42 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 43 | await web_cog.web.callback(web_cog, ctx, query="test") | ||
| 44 | mock_respond.assert_called_once_with(content="No results found.", ephemeral=True) | ||
| 45 | |||
| 46 | |||
| 47 | @pytest.mark.asyncio | ||
| 48 | async def test_web_command_http_error(web_cog, mock_channel, mock_guild, bot): | ||
| 49 | ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) | ||
| 50 | |||
| 51 | mock_response = MagicMock() | ||
| 52 | mock_response.status_code = 500 | ||
| 53 | |||
| 54 | with patch('cogs.web.requests.get', return_value=mock_response): | ||
| 55 | with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: | ||
| 56 | await web_cog.web.callback(web_cog, ctx, query="test") | ||
| 57 | mock_respond.assert_called_once_with(content="Error: 500", ephemeral=True) | ||
diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e9f0f6f --- /dev/null +++ b/tests/conftest.py | |||
| @@ -0,0 +1,206 @@ | |||
| 1 | import asyncio | ||
| 2 | from unittest.mock import AsyncMock, MagicMock | ||
| 3 | import pytest | ||
| 4 | |||
| 5 | import discord | ||
| 6 | from discord.ext import commands | ||
| 7 | |||
| 8 | |||
| 9 | class MockUser: | ||
| 10 | def __init__(self, id=12345, name="testuser", discriminator="0001"): | ||
| 11 | self.id = id | ||
| 12 | self.name = name | ||
| 13 | self.discriminator = discriminator | ||
| 14 | self.mention = f"@{name}" | ||
| 15 | self.display_name = name | ||
| 16 | self.avatar = None | ||
| 17 | self.bot = False | ||
| 18 | self.guild = None | ||
| 19 | self._state = MagicMock() | ||
| 20 | |||
| 21 | def __str__(self): | ||
| 22 | return f"{self.name}#{self.discriminator}" | ||
| 23 | |||
| 24 | |||
| 25 | class MockChannel: | ||
| 26 | def __init__(self, id=11111, name="test-channel", type=discord.ChannelType.text): | ||
| 27 | self.id = id | ||
| 28 | self.name = name | ||
| 29 | self.type = type | ||
| 30 | self.mention = f"#{name}" | ||
| 31 | self.guild = None | ||
| 32 | self.category = None | ||
| 33 | self._state = MagicMock() | ||
| 34 | |||
| 35 | def send(self, *args, **kwargs): | ||
| 36 | return MagicMock() | ||
| 37 | |||
| 38 | |||
| 39 | class MockTextChannel(MockChannel): | ||
| 40 | def __init__(self, id=11111, name="test-channel"): | ||
| 41 | super().__init__(id, name, discord.ChannelType.text) | ||
| 42 | |||
| 43 | |||
| 44 | class MockVoiceChannel(MockChannel): | ||
| 45 | def __init__(self, id=11112, name="test-voice"): | ||
| 46 | super().__init__(id, name, discord.ChannelType.voice) | ||
| 47 | self.voice_states = {} | ||
| 48 | |||
| 49 | |||
| 50 | class MockGuild: | ||
| 51 | def __init__(self, id=99999, name="Test Server"): | ||
| 52 | self.id = id | ||
| 53 | self.name = name | ||
| 54 | self.channels = [] | ||
| 55 | self.roles = [] | ||
| 56 | self.emojis = [] | ||
| 57 | self.me = MockUser(id=111111, name="Sen Bot") | ||
| 58 | self._state = MagicMock() | ||
| 59 | self._default_role = None | ||
| 60 | |||
| 61 | def get_channel(self, channel_id): | ||
| 62 | for ch in self.channels: | ||
| 63 | if ch.id == channel_id: | ||
| 64 | return ch | ||
| 65 | return None | ||
| 66 | |||
| 67 | def get_role(self, role_id): | ||
| 68 | for role in self.roles: | ||
| 69 | if role.id == role_id: | ||
| 70 | return role | ||
| 71 | return None | ||
| 72 | |||
| 73 | def get_member(self, member_id): | ||
| 74 | return None | ||
| 75 | |||
| 76 | |||
| 77 | class MockMessage: | ||
| 78 | def __init__(self, id=22222, content="test message", author=None, channel=None, guild=None): | ||
| 79 | self.id = id | ||
| 80 | self.content = content | ||
| 81 | self.author = author or MockUser() | ||
| 82 | self.channel = channel or MockChannel() | ||
| 83 | self.guild = guild | ||
| 84 | self.attachments = [] | ||
| 85 | self.embeds = [] | ||
| 86 | self.jump_url = "https://discord.com/channels/..." | ||
| 87 | self.created_at = None | ||
| 88 | |||
| 89 | async def reply(self, *args, **kwargs): | ||
| 90 | return MagicMock() | ||
| 91 | # Stub for reply - kept async to match Discord interface signature | ||
| 92 | |||
| 93 | |||
| 94 | class MockInteraction: | ||
| 95 | def __init__(self, user=None, channel=None, guild=None): | ||
| 96 | self.user = user or MockUser() | ||
| 97 | self.channel = channel or MockChannel() | ||
| 98 | self.guild = guild or MockGuild() | ||
| 99 | self.message = None | ||
| 100 | self.response = AsyncMock() | ||
| 101 | self.followup = AsyncMock() | ||
| 102 | self.client = None | ||
| 103 | self.id = 33333 | ||
| 104 | self.token = "test_token" | ||
| 105 | self._state = MagicMock() | ||
| 106 | |||
| 107 | async def defer(self, *args, **kwargs): | ||
| 108 | pass | ||
| 109 | |||
| 110 | async def respond(self, *args, **kwargs): | ||
| 111 | pass | ||
| 112 | |||
| 113 | async def delete_original_response(self, *args, **kwargs): | ||
| 114 | pass | ||
| 115 | |||
| 116 | async def send(self, *args, **kwargs): | ||
| 117 | return MagicMock() | ||
| 118 | |||
| 119 | |||
| 120 | # Stub for Discord interaction - test implementation only | ||
| 121 | class MockApplicationContext: | ||
| 122 | def __init__(self, bot=None, interaction=None, user=None, channel=None, guild=None): | ||
| 123 | self.bot = bot | ||
| 124 | self.interaction = interaction or MockInteraction(user=user, channel=channel, guild=guild) | ||
| 125 | self.user = user or MockUser() | ||
| 126 | self.channel = channel or MockChannel() | ||
| 127 | self.guild = guild or MockGuild() | ||
| 128 | self.message = None | ||
| 129 | self.response = AsyncMock() | ||
| 130 | self.followup = AsyncMock() | ||
| 131 | self.client = self.bot | ||
| 132 | self.id = 33333 | ||
| 133 | self.token = "test_token" | ||
| 134 | self._state = self.interaction._state | ||
| 135 | self.author = self.user | ||
| 136 | |||
| 137 | async def defer(self, *args, **kwargs): | ||
| 138 | pass | ||
| 139 | # Stub for defer - tests don't verify interaction state | ||
| 140 | |||
| 141 | async def respond(self, *args, **kwargs): | ||
| 142 | pass | ||
| 143 | # Stub for respond - tests mock the response | ||
| 144 | |||
| 145 | async def delete_original_response(self, *args, **kwargs): | ||
| 146 | pass | ||
| 147 | # Stub for delete_original_response - not used in tests | ||
| 148 | |||
| 149 | async def send(self, *args, **kwargs): | ||
| 150 | return MagicMock() | ||
| 151 | # Stub for send - not used in tests | ||
| 152 | |||
| 153 | async def reply(self, *args, **kwargs): | ||
| 154 | return MagicMock() | ||
| 155 | # Stub for reply - not used in tests | ||
| 156 | |||
| 157 | async def send_followup(self, *args, **kwargs): | ||
| 158 | return MagicMock() | ||
| 159 | # Stub for send_followup - not used in tests | ||
| 160 | |||
| 161 | |||
| 162 | @pytest.fixture | ||
| 163 | def mock_guild(): | ||
| 164 | return MockGuild() | ||
| 165 | |||
| 166 | |||
| 167 | @pytest.fixture | ||
| 168 | def mock_channel(mock_guild): | ||
| 169 | channel = MockTextChannel() | ||
| 170 | channel.guild = mock_guild | ||
| 171 | return channel | ||
| 172 | |||
| 173 | |||
| 174 | @pytest.fixture | ||
| 175 | def mock_voice_channel(mock_guild): | ||
| 176 | channel = MockVoiceChannel() | ||
| 177 | channel.guild = mock_guild | ||
| 178 | return channel | ||
| 179 | |||
| 180 | |||
| 181 | @pytest.fixture | ||
| 182 | def mock_user(): | ||
| 183 | return MockUser() | ||
| 184 | |||
| 185 | |||
| 186 | @pytest.fixture | ||
| 187 | def mock_member(mock_guild): | ||
| 188 | member = MockUser() | ||
| 189 | member.guild = mock_guild | ||
| 190 | return member | ||
| 191 | |||
| 192 | |||
| 193 | @pytest.fixture | ||
| 194 | def mock_message(mock_channel, mock_user, mock_guild): | ||
| 195 | return MockMessage(channel=mock_channel, author=mock_user, guild=mock_guild) | ||
| 196 | |||
| 197 | |||
| 198 | @pytest.fixture | ||
| 199 | def mock_interaction(mock_channel, mock_user, mock_guild): | ||
| 200 | return MockInteraction(user=mock_user, channel=mock_channel, guild=mock_guild) | ||
| 201 | |||
| 202 | |||
| 203 | @pytest.fixture | ||
| 204 | def bot(): | ||
| 205 | intents = discord.Intents.all() | ||
| 206 | return commands.Bot(intents=intents, command_prefix="!", loop=asyncio.new_event_loop()) | ||
