aboutsummaryrefslogtreecommitdiffstats
path: root/cogs
diff options
context:
space:
mode:
Diffstat (limited to 'cogs')
-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
18 files changed, 587 insertions, 199 deletions
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