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