aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@noreply.codeberg.org>2026-04-19 18:10:09 +0200
committersalmonik <salmonik@noreply.codeberg.org>2026-04-19 18:10:09 +0200
commitf8e1cf36e1efa0602bf2e450835f2a67654762a6 (patch)
treefe6b12226d7eebe2a0dc6525de3414d0c3718cc8
parentadd support for x^2+y^2=0,4 and alike (diff)
parentfix: proper aiohttp async context manager mocking in test (diff)
downloadsenator-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 '')
-rw-r--r--.gitignore3
-rw-r--r--AGENTS.md28
-rw-r--r--README.md1
-rw-r--r--cogs/ai.py35
-rw-r--r--cogs/assistant.py13
-rw-r--r--cogs/compression.py1
-rw-r--r--cogs/cron.py8
-rw-r--r--cogs/downloader.py2
-rw-r--r--cogs/music/audio_playback.py55
-rw-r--r--cogs/music/play_commands.py112
-rw-r--r--cogs/plot.py188
-rw-r--r--cogs/qr.py8
-rw-r--r--cogs/trigger.py44
-rw-r--r--cogs/tts.py12
-rw-r--r--cogs/utils/settings.py11
-rw-r--r--cogs/utils/temp.py4
-rw-r--r--cogs/vclogs.py29
-rw-r--r--cogs/voice.py4
-rw-r--r--cogs/web.py2
-rw-r--r--main.py15
-rw-r--r--pyrightconfig.json1
-rw-r--r--pytest.ini7
-rw-r--r--requirements-dev.txt59
-rw-r--r--tests/cogs/test_ai.py83
-rw-r--r--tests/cogs/test_assistant.py66
-rw-r--r--tests/cogs/test_compression_conversion.py139
-rw-r--r--tests/cogs/test_cron.py166
-rw-r--r--tests/cogs/test_downloader.py93
-rw-r--r--tests/cogs/test_math_commands.py55
-rw-r--r--tests/cogs/test_music_audio.py93
-rw-r--r--tests/cogs/test_music_external_play.py116
-rw-r--r--tests/cogs/test_music_join.py53
-rw-r--r--tests/cogs/test_music_play.py104
-rw-r--r--tests/cogs/test_qr.py75
-rw-r--r--tests/cogs/test_say.py38
-rw-r--r--tests/cogs/test_search.py83
-rw-r--r--tests/cogs/test_trigger.py106
-rw-r--r--tests/cogs/test_tts.py58
-rw-r--r--tests/cogs/test_vclogs.py83
-rw-r--r--tests/cogs/test_voice.py79
-rw-r--r--tests/cogs/test_web.py57
-rw-r--r--tests/conftest.py206
42 files changed, 2103 insertions, 292 deletions
diff --git a/.gitignore b/.gitignore
index c9cc484..40d765c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,5 @@ __pycache__/
3data/ 3data/
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
diff --git a/README.md b/README.md
index 92468a7..8e71dbe 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/cogs/ai.py b/cogs/ai.py
index 4a04693..6f79ac5 100644
--- a/cogs/ai.py
+++ b/cogs/ai.py
@@ -7,7 +7,7 @@ import os
7import discord 7import discord
8from discord.ext import commands 8from discord.ext import commands
9import logging 9import logging
10import requests 10import httpx
11from gtts import gTTS 11from gtts import gTTS
12from cogs.music import audio_playback 12from cogs.music import audio_playback
13import io 13import 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 @@
6import re 6import re
7import discord 7import discord
8from discord.ext import commands 8from discord.ext import commands
9import requests 9import httpx
10import json 10import json
11import logging 11import logging
12import shutil 12import 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 @@
6import logging 6import logging
7import os 7import os
8import urllib.parse 8import urllib.parse
9import asyncio
9from os import PathLike 10from os import PathLike
10from typing import Awaitable, Callable, List, Tuple 11from typing import Awaitable, Callable, List, Tuple
12import re
13from yt_dlp import YoutubeDL
11 14
12import discord 15import discord
13from discord.commands import OptionChoice 16from discord.commands import OptionChoice
14from discord.ext import commands 17from discord.ext import commands
15 18
16from cogs.music import audio_playback, media_processing 19from cogs.music import audio_playback, media_processing
17from cogs.utils import file_search as search_utils 20from cogs.utils.file_search import UniversalSearcher
18 21
19logger = logging.getLogger(__name__) 22logger = logging.getLogger(__name__)
20 23
24EXTERNAL_URL_PATTERN = re.compile(
25 r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))"
26)
27
21 28
22class PlayCommands(commands.Cog): 29class 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
6import asyncio
7import logging
8import math
9import os
10import re
11
12import discord
13from discord.ext import commands
14
15logger = logging.getLogger(__name__)
16
17_plt_module = None
18_plot_available = False
19
20
21def _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
38def 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
47def is_implicit(expr: str) -> bool:
48 return "=" in expr and "y" in expr and "x" in expr
49
50
51def 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
58class 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
187def setup(bot):
188 bot.add_cog(Plot(bot)) \ No newline at end of file
diff --git a/cogs/qr.py b/cogs/qr.py
index c1fd499..32c574e 100644
--- a/cogs/qr.py
+++ b/cogs/qr.py
@@ -22,10 +22,10 @@ _decode_available = False
22 22
23def _ensure_qr(): 23def _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
16DB_PATH = 'data/triggers.db' 16DB_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
19def get_connection(): 25def 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
12logger = logging.getLogger(__name__) 12logger = logging.getLogger(__name__)
13 13
14settings_file = "data/settings.json" 14settings_file = "data/settings.json"
15DEFAULT_BASEURL = "http://localhost:8080"
15 16
16 17
17@dataclass 18@dataclass
18class Settings: 19class 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
91def get_baseurl() -> str: 92def 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
9def get_temp_dir(subdir: str = "senator_sperma") -> str: 9def 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
14from cogs.voice import get_voice_manager 14from cogs.voice import get_voice_manager
15from cogs.utils.settings import get_volume, save_settings 15from cogs.utils.settings import get_volume, save_settings
16 16
17import tempfile
18
17logger = logging.getLogger(__name__) 19logger = logging.getLogger(__name__)
18 20
19ACTIVITY_FILE = 'temp/voice_activity.json' 21ACTIVITY_FILE = os.path.join(tempfile.gettempdir(), 'voice_activity.json')
20 22
21 23
22def load_activity_data(): 24def 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
199def setup(bot): 199def 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
99def setup(bot): 99def setup(bot):
100 bot.add_cog(Web(bot)) \ No newline at end of file 100 bot.add_cog(Web(bot))
diff --git a/main.py b/main.py
index 6c8c35b..1f5e3f6 100644
--- a/main.py
+++ b/main.py
@@ -19,11 +19,17 @@ from cogs.utils import settings
19def setup_logging(debug: bool = False): 19def 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]
2asyncio_default_fixture_loop_scope = function
3python_files = test_*.py
4python_classes = Test*
5python_functions = test_*
6testpaths = tests
7pythonpath = . \ 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 @@
1pytest==8.3.4
2pytest-asyncio==0.25.2
3pytest-mock==3.14.0
4aiofiles==25.1.0
5aiohappyeyeballs==2.6.1
6aiohttp==3.13.3
7aiosignal==1.4.0
8anyio==4.12.1
9attrs==25.4.0
10basedpyright==1.39.2
11beautifulsoup4==4.14.3
12brotli==1.2.0
13bs4==0.0.2
14certifi==2026.2.25
15cffi==2.0.0
16charset-normalizer==3.4.6
17click==8.1.8
18contourpy==1.3.3
19croniter==6.2.2
20cycler==0.12.1
21davey==0.1.4
22dotenv==0.9.9
23fonttools==4.62.1
24frozenlist==1.8.0
25gTTS==2.5.4
26h11==0.16.0
27httpcore==1.0.9
28httpx==0.28.1
29idna==3.11
30kiwisolver==1.5.0
31matplotlib==3.10.8
32multidict==6.7.1
33mutagen==1.47.0
34nodejs-wheel-binaries==24.14.1
35numpy==2.4.4
36packaging==26.1
37pillow==12.2.0
38propcache==0.4.1
39py-cord @ file:///home/salmon/Documents/botcode/senator-sperma/pycord
40pycparser==3.0
41pycryptodomex==3.23.0
42PyNaCl==1.6.2
43pyparsing==3.3.2
44python-dateutil==2.9.0.post0
45python-dotenv==1.2.2
46pytube==15.0.0
47pyzbar==0.1.9
48qrcode==8.2
49RapidFuzz==3.14.3
50requests==2.32.5
51six==1.17.0
52soupsieve==2.8.3
53typing_extensions==4.15.0
54urllib3==2.6.3
55websockets==16.0
56yarl==1.23.0
57youtube-search-python==1.6.6
58yt-dlp==2026.3.17
59yt-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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import discord
4import httpx
5from tests.conftest import MockApplicationContext
6
7
8@pytest.fixture
9def ai_cog(bot):
10 from cogs.ai import AI
11 cog = AI(bot)
12 return cog
13
14
15@pytest.fixture
16def 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
29async 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
49async 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
69async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3from tests.conftest import MockApplicationContext
4
5
6@pytest.fixture
7def 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
19def test_assistant_keyword_default(assistant_cog):
20 assert assistant_cog.keyword == "test_keyword"
21
22
23@pytest.mark.asyncio
24async 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
38async 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
54async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import os
4import discord
5from tests.conftest import MockApplicationContext
6
7
8def test_compression_filetype_video():
9 from cogs.compression import filetype
10 assert filetype("test.mp4") == "Video"
11 assert filetype("test.avi") == "Video"
12
13
14def test_compression_filetype_audio():
15 from cogs.compression import filetype
16 assert filetype("test.mp3") == "Audio"
17 assert filetype("test.wav") == "Audio"
18
19
20def test_compression_filetype_image():
21 from cogs.compression import filetype
22 assert filetype("test.jpg") == "Image"
23 assert filetype("test.png") == "Image"
24
25
26def 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
35async 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
54async 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
73async 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
91async 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
109async 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
126async 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 @@
1import json
2import os
3import pytest
4from datetime import datetime
5from unittest.mock import MagicMock, patch
6
7from cogs.cron import Cron
8
9
10@pytest.fixture
11def 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
20def 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
30class 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
62class 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
86class 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
102class 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
113class 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
157class 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import discord
4from tests.conftest import MockApplicationContext, MockMessage
5
6
7@pytest.fixture
8def downloader_cog(bot):
9 from cogs.downloader import Downloader
10 cog = Downloader(bot)
11 return cog
12
13
14@pytest.mark.asyncio
15async 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
35async 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
51async 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
66async 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
83async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3from tests.conftest import MockApplicationContext
4
5
6@pytest.fixture
7def math_cog(bot):
8 from cogs.math_commands import Math
9 cog = Math(bot)
10 return cog
11
12
13@pytest.mark.asyncio
14async 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
30async 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
42async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import os
4from tests.conftest import MockApplicationContext
5
6
7@pytest.mark.asyncio
8async 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
22async 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
37async 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
47async 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
59async 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
69async 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
80async 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
89async 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 @@
1import pytest
2import os
3import asyncio
4import tempfile
5from unittest.mock import AsyncMock, patch, MagicMock
6from tests.conftest import MockApplicationContext
7
8
9@pytest.fixture
10def 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
18def 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
28async 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
37async 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
45async 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
52async 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
60async 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
79async 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
98async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3from tests.conftest import MockApplicationContext
4
5
6@pytest.fixture
7def join_cog(bot):
8 from cogs.music.join import Join
9 cog = Join(bot)
10 return cog
11
12
13@pytest.fixture
14def stop_cog(bot):
15 from cogs.music.stop import Stop
16 cog = Stop(bot)
17 return cog
18
19
20@pytest.mark.asyncio
21async 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
40async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import os
4from tests.conftest import MockApplicationContext
5
6
7@pytest.fixture
8def 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
16def loop_cog(bot):
17 from cogs.music.loop import Loop
18 cog = Loop(bot)
19 return cog
20
21
22@pytest.mark.asyncio
23async 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
31async 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
44async 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
55async 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
68def test_loop_cog_is_looping_initial(loop_cog):
69 assert loop_cog.is_looping(123) == False
70
71
72def 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
78async 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
92async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock, mock_open
3import os
4from cogs.qr import QR
5from tests.conftest import MockApplicationContext
6
7
8@pytest.fixture
9def qr_cog(bot):
10 cog = QR(bot)
11 return cog
12
13
14@pytest.fixture
15def 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
24async 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
37async 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
48async 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
63async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch
3from cogs.say import Say
4from tests.conftest import MockApplicationContext
5
6
7@pytest.fixture
8def say_cog(bot):
9 cog = Say(bot)
10 return cog
11
12
13@pytest.mark.asyncio
14async 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
23async 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
32async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import discord
4from tests.conftest import MockApplicationContext
5
6
7@pytest.fixture
8def search_cog(bot):
9 from cogs.search import Search
10 cog = Search(bot)
11 return cog
12
13
14@pytest.mark.asyncio
15async 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
31async 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
42async 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
57async 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
68async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import sqlite3
4from tests.conftest import MockApplicationContext
5
6
7@pytest.fixture
8def 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
18def 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
29async 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
48async 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
65async 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
82async 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
99async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import discord
4from tests.conftest import MockApplicationContext
5
6
7@pytest.fixture
8def tts_cog(bot):
9 from cogs.tts import TTS
10 cog = TTS(bot)
11 return cog
12
13
14@pytest.mark.asyncio
15async 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
32async 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
47async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import json
4from tests.conftest import MockApplicationContext
5
6
7def 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
15def 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
24def 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
33def 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
43async 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
54async 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
65async 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
76async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3import asyncio
4from tests.conftest import MockApplicationContext
5
6
7@pytest.mark.asyncio
8async 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
23async 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
36async 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
50async 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
66async 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 @@
1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock
3from cogs.web import Web
4from tests.conftest import MockApplicationContext
5
6
7@pytest.fixture
8def web_cog(bot):
9 cog = Web(bot)
10 return cog
11
12
13@pytest.mark.asyncio
14async 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
34async 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
48async 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 @@
1import asyncio
2from unittest.mock import AsyncMock, MagicMock
3import pytest
4
5import discord
6from discord.ext import commands
7
8
9class 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
25class 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
39class MockTextChannel(MockChannel):
40 def __init__(self, id=11111, name="test-channel"):
41 super().__init__(id, name, discord.ChannelType.text)
42
43
44class 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
50class 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
77class 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
94class 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
121class 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
163def mock_guild():
164 return MockGuild()
165
166
167@pytest.fixture
168def mock_channel(mock_guild):
169 channel = MockTextChannel()
170 channel.guild = mock_guild
171 return channel
172
173
174@pytest.fixture
175def mock_voice_channel(mock_guild):
176 channel = MockVoiceChannel()
177 channel.guild = mock_guild
178 return channel
179
180
181@pytest.fixture
182def mock_user():
183 return MockUser()
184
185
186@pytest.fixture
187def mock_member(mock_guild):
188 member = MockUser()
189 member.guild = mock_guild
190 return member
191
192
193@pytest.fixture
194def 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
199def 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
204def bot():
205 intents = discord.Intents.all()
206 return commands.Bot(intents=intents, command_prefix="!", loop=asyncio.new_event_loop())