diff options
| author | salmonik <raisersoft@proton.me> | 2026-04-29 14:47:32 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-04-29 14:47:32 +0200 |
| commit | 8a4352ebb5e15ff4be82a9b4e111c1535aab349e (patch) | |
| tree | 2c4d5b4b65bdc8308b35c411cffa7f17b1bfeb4d /cogs | |
| parent | fix: fallback to plaintext if no json is provided for assistant.py (diff) | |
| parent | proper context for settings (diff) | |
| download | senator-sperma-8a4352ebb5e15ff4be82a9b4e111c1535aab349e.tar.gz senator-sperma-8a4352ebb5e15ff4be82a9b4e111c1535aab349e.zip | |
Merge branch 'dev'
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to 'cogs')
| -rw-r--r-- | cogs/ai.py | 2 | ||||
| -rw-r--r-- | cogs/assistant.py | 4 | ||||
| -rw-r--r-- | cogs/compression.py | 2 | ||||
| -rw-r--r-- | cogs/conversion.py | 6 | ||||
| -rw-r--r-- | cogs/cron.py | 98 | ||||
| -rw-r--r-- | cogs/downloader.py | 35 | ||||
| -rw-r--r-- | cogs/music/audio_playback.py | 42 | ||||
| -rw-r--r-- | cogs/music/media_processing.py | 2 | ||||
| -rw-r--r-- | cogs/music/play_commands.py | 10 | ||||
| -rw-r--r-- | cogs/trigger.py | 84 | ||||
| -rw-r--r-- | cogs/uploader.py | 155 | ||||
| -rw-r--r-- | cogs/utils/database.py | 126 | ||||
| -rw-r--r-- | cogs/utils/file_search.py | 18 | ||||
| -rw-r--r-- | cogs/utils/settings.py | 67 | ||||
| -rw-r--r-- | cogs/utils/url_helper.py | 21 | ||||
| -rw-r--r-- | cogs/vclogs.py | 72 | ||||
| -rw-r--r-- | cogs/voice.py | 22 | ||||
| -rw-r--r-- | cogs/web.py | 20 |
18 files changed, 587 insertions, 199 deletions
| @@ -66,7 +66,7 @@ class AI(commands.Cog): | |||
| 66 | tts_buffer = io.BytesIO() | 66 | tts_buffer = io.BytesIO() |
| 67 | tts_obj.write_to_fp(tts_buffer) | 67 | tts_obj.write_to_fp(tts_buffer) |
| 68 | tts_buffer.seek(0) | 68 | tts_buffer.seek(0) |
| 69 | await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) | 69 | await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True, interrupt=False) |
| 70 | 70 | ||
| 71 | else: | 71 | else: |
| 72 | await ctx.send_followup(response_text) | 72 | await ctx.send_followup(response_text) |
diff --git a/cogs/assistant.py b/cogs/assistant.py index a89c356..a91d027 100644 --- a/cogs/assistant.py +++ b/cogs/assistant.py | |||
| @@ -16,8 +16,6 @@ from cogs.downloader import Downloader | |||
| 16 | 16 | ||
| 17 | logger = logging.getLogger(__name__) | 17 | logger = logging.getLogger(__name__) |
| 18 | 18 | ||
| 19 | AI_MODEL = "z-ai/glm-4.5-air:free" | ||
| 20 | |||
| 21 | class Jarvis(commands.Cog): | 19 | class Jarvis(commands.Cog): |
| 22 | def __init__(self, bot): | 20 | def __init__(self, bot): |
| 23 | self.bot = bot | 21 | self.bot = bot |
| @@ -102,7 +100,7 @@ class Jarvis(commands.Cog): | |||
| 102 | @staticmethod | 100 | @staticmethod |
| 103 | def _build_payload(messages): | 101 | def _build_payload(messages): |
| 104 | return { | 102 | return { |
| 105 | "model": AI_MODEL, | 103 | "model": os.getenv("OPENROUTER_MODEL"), |
| 106 | "messages": messages, | 104 | "messages": messages, |
| 107 | "response_format": Jarvis._get_response_schema(), | 105 | "response_format": Jarvis._get_response_schema(), |
| 108 | "plugins": [{"id": "response-healing"}] | 106 | "plugins": [{"id": "response-healing"}] |
diff --git a/cogs/compression.py b/cogs/compression.py index acc9eda..7171abb 100644 --- a/cogs/compression.py +++ b/cogs/compression.py | |||
| @@ -38,7 +38,7 @@ class Compression(commands.Cog): | |||
| 38 | self.bot = bot | 38 | self.bot = bot |
| 39 | self.temp_dir = get_temp_dir() | 39 | self.temp_dir = get_temp_dir() |
| 40 | self._cached_urls = {} | 40 | self._cached_urls = {} |
| 41 | self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() | 41 | self._baseurl = get_baseurl() |
| 42 | 42 | ||
| 43 | async def _file_autocomplete(self, ctx: AutocompleteContext): | 43 | async def _file_autocomplete(self, ctx: AutocompleteContext): |
| 44 | query = ctx.value.lower() if ctx.value else "" | 44 | query = ctx.value.lower() if ctx.value else "" |
diff --git a/cogs/conversion.py b/cogs/conversion.py index 1117caf..455bfde 100644 --- a/cogs/conversion.py +++ b/cogs/conversion.py | |||
| @@ -35,11 +35,11 @@ class Conversion(commands.Cog): | |||
| 35 | def __init__(self, bot): | 35 | def __init__(self, bot): |
| 36 | self.bot = bot | 36 | self.bot = bot |
| 37 | from cogs.utils.temp import get_temp_dir | 37 | from cogs.utils.temp import get_temp_dir |
| 38 | from cogs.utils.settings import get_baseurl | 38 | from cogs.utils.settings import get_baseurl, get_uploadurl |
| 39 | self.temp_dir = get_temp_dir() | 39 | self.temp_dir = get_temp_dir() |
| 40 | self._cached_urls = {} | 40 | self._cached_urls = {} |
| 41 | self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() | 41 | self._baseurl = get_baseurl() |
| 42 | self._uploadurl = os.getenv("COPYPARTY_URL_UPLOAD") | 42 | self._uploadurl = get_uploadurl() |
| 43 | 43 | ||
| 44 | async def _file_autocomplete(self, ctx: AutocompleteContext): | 44 | async def _file_autocomplete(self, ctx: AutocompleteContext): |
| 45 | query = ctx.value.lower() if ctx.value else "" | 45 | query = ctx.value.lower() if ctx.value else "" |
diff --git a/cogs/cron.py b/cogs/cron.py index da8127b..aad43d2 100644 --- a/cogs/cron.py +++ b/cogs/cron.py | |||
| @@ -4,9 +4,7 @@ | |||
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import asyncio | 6 | import asyncio |
| 7 | import json | ||
| 8 | import logging | 7 | import logging |
| 9 | import os | ||
| 10 | from datetime import datetime | 8 | from datetime import datetime |
| 11 | from venv import logger | 9 | from venv import logger |
| 12 | 10 | ||
| @@ -16,37 +14,67 @@ from discord.ext import commands | |||
| 16 | 14 | ||
| 17 | from cogs.utils import file_search as search_utils | 15 | from cogs.utils import file_search as search_utils |
| 18 | from cogs.voice import get_voice_manager | 16 | from cogs.voice import get_voice_manager |
| 17 | from cogs.utils.database import get_connection, init_database | ||
| 19 | 18 | ||
| 20 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| 21 | 20 | ||
| 21 | init_database() | ||
| 22 | |||
| 22 | 23 | ||
| 23 | class Cron(commands.Cog): | 24 | class Cron(commands.Cog): |
| 24 | def __init__(self, bot): | 25 | def __init__(self, bot): |
| 25 | self.bot = bot | 26 | self.bot = bot |
| 26 | self.scheduled_tasks = [] | 27 | self.scheduled_tasks = [] |
| 27 | self.cron_file = "data/cron_jobs.json" | ||
| 28 | self.load_tasks() | 28 | self.load_tasks() |
| 29 | self.task = bot.loop.create_task(self.schedule_checker()) | 29 | self.task = bot.loop.create_task(self.schedule_checker()) |
| 30 | 30 | ||
| 31 | def load_tasks(self): | 31 | def load_tasks(self): |
| 32 | """Load scheduled tasks from file""" | ||
| 33 | try: | 32 | try: |
| 34 | if os.path.exists(self.cron_file): | 33 | with get_connection() as conn: |
| 35 | with open(self.cron_file, "r") as f: | 34 | c = conn.cursor() |
| 36 | self.scheduled_tasks = json.load(f) | 35 | c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") |
| 37 | logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs") | 36 | rows = c.fetchall() |
| 38 | else: | 37 | |
| 39 | self.scheduled_tasks = [] | 38 | self.scheduled_tasks = [ |
| 39 | { | ||
| 40 | "id": row[0], | ||
| 41 | "cron_expression": row[1], | ||
| 42 | "type": row[2], | ||
| 43 | "channel_id": row[3], | ||
| 44 | "active": bool(row[4]), | ||
| 45 | "audio_filename": row[5], | ||
| 46 | "message": row[6], | ||
| 47 | } | ||
| 48 | for row in rows | ||
| 49 | ] | ||
| 50 | logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database") | ||
| 40 | except Exception as e: | 51 | except Exception as e: |
| 41 | logger.error(f"Error loading cron jobs: {e}") | 52 | logger.error(f"Error loading cron jobs: {e}") |
| 42 | self.scheduled_tasks = [] | 53 | self.scheduled_tasks = [] |
| 43 | 54 | ||
| 44 | def save_tasks(self): | 55 | def save_tasks(self): |
| 45 | """Save scheduled tasks to file""" | ||
| 46 | try: | 56 | try: |
| 47 | os.makedirs(os.path.dirname(self.cron_file), exist_ok=True) | 57 | with get_connection() as conn: |
| 48 | with open(self.cron_file, "w") as f: | 58 | c = conn.cursor() |
| 49 | json.dump(self.scheduled_tasks, f, indent=2) | 59 | c.execute("PRAGMA foreign_keys = ON") |
| 60 | |||
| 61 | c.execute("DELETE FROM cron_jobs") | ||
| 62 | |||
| 63 | for task in self.scheduled_tasks: | ||
| 64 | c.execute( | ||
| 65 | """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message) | ||
| 66 | VALUES (?, ?, ?, ?, ?, ?, ?)""", | ||
| 67 | ( | ||
| 68 | task.get("id"), | ||
| 69 | task["cron_expression"], | ||
| 70 | task["type"], | ||
| 71 | task["channel_id"], | ||
| 72 | int(task.get("active", True)), | ||
| 73 | task.get("audio_filename"), | ||
| 74 | task.get("message"), | ||
| 75 | ), | ||
| 76 | ) | ||
| 77 | conn.commit() | ||
| 50 | except Exception as e: | 78 | except Exception as e: |
| 51 | logger.error(f"Error saving cron jobs: {e}") | 79 | logger.error(f"Error saving cron jobs: {e}") |
| 52 | 80 | ||
| @@ -104,7 +132,7 @@ class Cron(commands.Cog): | |||
| 104 | 132 | ||
| 105 | if task_type in ("zagraj_audio", "audio"): | 133 | if task_type in ("zagraj_audio", "audio"): |
| 106 | await self.play_audio_task(task, channel) | 134 | await self.play_audio_task(task, channel) |
| 107 | elif task_type in ("wyślij_wiadomość", "send_message"): | 135 | elif task_type in ("wyślij_wiadomość", "send_message", "wiadomość"): |
| 108 | await self.send_message_task(task, channel) | 136 | await self.send_message_task(task, channel) |
| 109 | 137 | ||
| 110 | logger.info(f"Executed task {task['id']} of type {task_type}") | 138 | logger.info(f"Executed task {task['id']} of type {task_type}") |
| @@ -132,11 +160,13 @@ class Cron(commands.Cog): | |||
| 132 | while voice_client.is_playing(): | 160 | while voice_client.is_playing(): |
| 133 | await asyncio.sleep(1) | 161 | await asyncio.sleep(1) |
| 134 | 162 | ||
| 163 | logger.info("Audio playback finished normally - disconnecting") | ||
| 135 | await voice_client.disconnect() | 164 | await voice_client.disconnect() |
| 136 | logger.info(f"Finished playing audio: {audio_url}") | 165 | logger.info(f"Finished playing audio: {audio_url}") |
| 137 | except Exception as e: | 166 | except Exception as e: |
| 138 | logger.error(f"Error playing audio: {e}") | 167 | logger.error(f"Error playing audio: {e}") |
| 139 | if voice_client.is_connected(): | 168 | if voice_client.is_connected(): |
| 169 | logger.info("Audio playback failed - disconnecting") | ||
| 140 | await voice_client.disconnect() | 170 | await voice_client.disconnect() |
| 141 | if channel.guild.system_channel: | 171 | if channel.guild.system_channel: |
| 142 | await channel.guild.system_channel.send( | 172 | await channel.guild.system_channel.send( |
| @@ -176,8 +206,19 @@ class Cron(commands.Cog): | |||
| 176 | else [discord.OptionChoice(name="Brak wyników", value="")] | 206 | else [discord.OptionChoice(name="Brak wyników", value="")] |
| 177 | ) | 207 | ) |
| 178 | 208 | ||
| 179 | @commands.slash_command( | 209 | def _get_baseurl(self): |
| 180 | name="cron_dodaj_audio", description="Dodaj nowe zadanie cron audio" | 210 | """Get base URL from settings""" |
| 211 | try: | ||
| 212 | from cogs.utils.settings import get_baseurl | ||
| 213 | |||
| 214 | return get_baseurl() | ||
| 215 | except Exception: | ||
| 216 | return "" | ||
| 217 | |||
| 218 | trigger_group = discord.SlashCommandGroup("cron", "komendy cron") | ||
| 219 | |||
| 220 | @trigger_group.command( | ||
| 221 | name="dodaj_audio", description="Dodaj nowe zadanie cron audio" | ||
| 181 | ) | 222 | ) |
| 182 | async def cron_add_audio( | 223 | async def cron_add_audio( |
| 183 | self, | 224 | self, |
| @@ -227,8 +268,8 @@ class Cron(commands.Cog): | |||
| 227 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" | 268 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" |
| 228 | ) | 269 | ) |
| 229 | 270 | ||
| 230 | @commands.slash_command( | 271 | @trigger_group.command( |
| 231 | name="cron_dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" | 272 | name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" |
| 232 | ) | 273 | ) |
| 233 | async def cron_add_message( | 274 | async def cron_add_message( |
| 234 | self, | 275 | self, |
| @@ -272,17 +313,8 @@ class Cron(commands.Cog): | |||
| 272 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" | 313 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" |
| 273 | ) | 314 | ) |
| 274 | 315 | ||
| 275 | def _get_baseurl(self): | 316 | @trigger_group.command( |
| 276 | """Get base URL from settings""" | 317 | name="pokaż", description="Pokaż wszystkie zadania cron" |
| 277 | try: | ||
| 278 | from cogs.utils.settings import get_baseurl | ||
| 279 | |||
| 280 | return get_baseurl() | ||
| 281 | except Exception: | ||
| 282 | return "" | ||
| 283 | |||
| 284 | @commands.slash_command( | ||
| 285 | name="cron_pokaż", description="Pokaż wszystkie zadania cron" | ||
| 286 | ) | 318 | ) |
| 287 | async def cron_list(self, ctx): | 319 | async def cron_list(self, ctx): |
| 288 | """List all cron jobs""" | 320 | """List all cron jobs""" |
| @@ -313,7 +345,7 @@ class Cron(commands.Cog): | |||
| 313 | 345 | ||
| 314 | await ctx.respond(embed=embed) | 346 | await ctx.respond(embed=embed) |
| 315 | 347 | ||
| 316 | @commands.slash_command(name="cron_usuń", description="Usuń zadanie cron z ID") | 348 | @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID") |
| 317 | async def cron_remove( | 349 | async def cron_remove( |
| 318 | self, | 350 | self, |
| 319 | ctx, | 351 | ctx, |
| @@ -331,7 +363,7 @@ class Cron(commands.Cog): | |||
| 331 | 363 | ||
| 332 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | 364 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") |
| 333 | 365 | ||
| 334 | @commands.slash_command(name="cron_pauza", description="Zapauzuj zadanie cron z ID") | 366 | @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID") |
| 335 | async def cron_pause( | 367 | async def cron_pause( |
| 336 | self, | 368 | self, |
| 337 | ctx, | 369 | ctx, |
| @@ -349,7 +381,7 @@ class Cron(commands.Cog): | |||
| 349 | 381 | ||
| 350 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | 382 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") |
| 351 | 383 | ||
| 352 | @commands.slash_command(name="cron_wznów", description="Wznów zadanie cron z ID") | 384 | @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID") |
| 353 | async def cron_resume( | 385 | async def cron_resume( |
| 354 | self, | 386 | self, |
| 355 | ctx, | 387 | ctx, |
diff --git a/cogs/downloader.py b/cogs/downloader.py index 36bd723..900bafb 100644 --- a/cogs/downloader.py +++ b/cogs/downloader.py | |||
| @@ -12,6 +12,7 @@ import re | |||
| 12 | import yt_dlp | 12 | import yt_dlp |
| 13 | from discord.ext import commands | 13 | from discord.ext import commands |
| 14 | import aiofiles | 14 | import aiofiles |
| 15 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 15 | 16 | ||
| 16 | logger = logging.getLogger(__name__) | 17 | logger = logging.getLogger(__name__) |
| 17 | 18 | ||
| @@ -61,6 +62,10 @@ class Downloader(commands.Cog): | |||
| 61 | file_data = response["content"] | 62 | file_data = response["content"] |
| 62 | await ctx.respond(file=discord.File(file_data)) | 63 | await ctx.respond(file=discord.File(file_data)) |
| 63 | else: | 64 | else: |
| 65 | error_msg = response["content"] | ||
| 66 | if 'details' in response: | ||
| 67 | error_msg += f"\n{response['details']}" | ||
| 68 | await ctx.respond(f"Błąd: {error_msg}", ephemeral=True) | ||
| 64 | logger.error(f"Error: {response['content']}") | 69 | logger.error(f"Error: {response['content']}") |
| 65 | if 'details' in response: | 70 | if 'details' in response: |
| 66 | logger.error(f"Details: {response['details']}") | 71 | logger.error(f"Details: {response['details']}") |
| @@ -68,10 +73,11 @@ class Downloader(commands.Cog): | |||
| 68 | @staticmethod | 73 | @staticmethod |
| 69 | async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): | 74 | async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): |
| 70 | try: | 75 | try: |
| 76 | url = clean_url_for_ytdlp(url) | ||
| 71 | logger.info(f"Downloading {url} as {fformat}") | 77 | logger.info(f"Downloading {url} as {fformat}") |
| 72 | 78 | ||
| 73 | with tempfile.TemporaryDirectory(delete=False) as temp_dir: | 79 | with tempfile.TemporaryDirectory(delete=False) as temp_dir: |
| 74 | ydl_opts = Downloader._get_ydl_options(fformat, temp_dir) | 80 | ydl_opts = Downloader._get_ydl_options(filename_user, fformat, temp_dir) |
| 75 | 81 | ||
| 76 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: | 82 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: |
| 77 | ydl.download([url]) | 83 | ydl.download([url]) |
| @@ -87,15 +93,23 @@ class Downloader(commands.Cog): | |||
| 87 | return Downloader._create_error_response("Download failed", str(e), None) | 93 | return Downloader._create_error_response("Download failed", str(e), None) |
| 88 | 94 | ||
| 89 | @staticmethod | 95 | @staticmethod |
| 90 | def _get_ydl_options(fformat: str, temp_dir: str) -> dict: | 96 | def _sanitize_filename(name: str) -> str: |
| 97 | invalid = '<>:"/\\|?*' | ||
| 98 | for c in invalid: | ||
| 99 | name = name.replace(c, '') | ||
| 100 | return name.strip() | ||
| 101 | |||
| 102 | @staticmethod | ||
| 103 | def _get_ydl_options(filename: str = None, fformat: str = None, temp_dir: str = None) -> dict: | ||
| 104 | if filename: | ||
| 105 | filename = Downloader._sanitize_filename(filename)[:100] | ||
| 91 | ydl_opts = { | 106 | ydl_opts = { |
| 92 | 'outtmpl': os.path.join(temp_dir, '%(title)s.%(ext)s'), | 107 | 'outtmpl': os.path.join(temp_dir, filename if filename is not None else '%(title).100s.%(ext)s'), |
| 93 | 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', | 108 | 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', |
| 94 | 'merge_output_format': 'mp4', | 109 | 'merge_output_format': 'mp4', |
| 95 | 'postprocessors': [], | 110 | 'postprocessors': [], |
| 96 | 'remote-components': 'ejs:github', | 111 | 'remote-components': 'ejs:github', |
| 97 | 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}}, | 112 | 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}}, |
| 98 | 'o': '"%(title)s.%(ext)s"' | ||
| 99 | } | 113 | } |
| 100 | 114 | ||
| 101 | if fformat in ['mp3', 'wav', 'm4a']: | 115 | if fformat in ['mp3', 'wav', 'm4a']: |
| @@ -120,10 +134,15 @@ class Downloader(commands.Cog): | |||
| 120 | 134 | ||
| 121 | actual_file = os.path.join(temp_dir, files[0]) | 135 | actual_file = os.path.join(temp_dir, files[0]) |
| 122 | extension = actual_file.split('.')[-1] | 136 | extension = actual_file.split('.')[-1] |
| 137 | extension = Downloader._sanitize_filename(extension) | ||
| 123 | 138 | ||
| 124 | if not filename_user: | 139 | if not filename_user: |
| 125 | filename = os.path.basename(actual_file) | 140 | filename = os.path.basename(actual_file) |
| 141 | filename = Downloader._sanitize_filename(filename) | ||
| 142 | if len(filename) > 100: | ||
| 143 | filename = filename[:100] | ||
| 126 | else: | 144 | else: |
| 145 | filename_user = Downloader._sanitize_filename(filename_user)[:100] | ||
| 127 | filename = filename_user + '.' + extension | 146 | filename = filename_user + '.' + extension |
| 128 | actual_file = os.path.join(temp_dir, filename) | 147 | actual_file = os.path.join(temp_dir, filename) |
| 129 | 148 | ||
| @@ -144,12 +163,18 @@ class Downloader(commands.Cog): | |||
| 144 | files = {'f': (filename, content, 'application/octet-stream')} | 163 | files = {'f': (filename, content, 'application/octet-stream')} |
| 145 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | 164 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} |
| 146 | params = {'j': '1', 'ck': 'b2'} | 165 | params = {'j': '1', 'ck': 'b2'} |
| 147 | base_url = "https://catbox.2137697.xyz/sperma/" | 166 | base_url = os.getenv("COPYPARTY_URL_UPLOAD") |
| 148 | 167 | ||
| 149 | response = requests.post(base_url, files=files, headers=headers, params=params) | 168 | response = requests.post(base_url, files=files, headers=headers, params=params) |
| 150 | 169 | ||
| 151 | if response.status_code in [200, 201]: | 170 | if response.status_code in [200, 201]: |
| 152 | return Downloader._handle_upload_success(response, filename, temp_dir) | 171 | return Downloader._handle_upload_success(response, filename, temp_dir) |
| 172 | elif response.status_code == 413: | ||
| 173 | return Downloader._create_error_response( | ||
| 174 | "File too large for upload", | ||
| 175 | "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)", | ||
| 176 | temp_dir | ||
| 177 | ) | ||
| 153 | else: | 178 | else: |
| 154 | return Downloader._create_error_response( | 179 | return Downloader._create_error_response( |
| 155 | f"Server returned {response.status_code}", | 180 | f"Server returned {response.status_code}", |
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py index 28c6118..1313d01 100644 --- a/cogs/music/audio_playback.py +++ b/cogs/music/audio_playback.py | |||
| @@ -137,8 +137,16 @@ class AudioPlaybackManager: | |||
| 137 | return absolute_source, display_name, is_url | 137 | return absolute_source, display_name, is_url |
| 138 | 138 | ||
| 139 | @staticmethod | 139 | @staticmethod |
| 140 | async def execute_playback(ctx, source, display_name, is_url): | 140 | async def execute_playback(ctx, source, display_name, is_url, interrupt=True): |
| 141 | """Execute the actual audio playback.""" | 141 | """Execute the actual audio playback. |
| 142 | |||
| 143 | Args: | ||
| 144 | ctx: Discord context | ||
| 145 | source: Audio source path or URL | ||
| 146 | display_name: Name to display | ||
| 147 | is_url: Whether source is a URL | ||
| 148 | interrupt: If True, stop any currently playing audio. If False, skip if audio is playing. | ||
| 149 | """ | ||
| 142 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) | 150 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) |
| 143 | if not target_channel: | 151 | if not target_channel: |
| 144 | return False | 152 | return False |
| @@ -169,9 +177,12 @@ class AudioPlaybackManager: | |||
| 169 | logger.info(f"Moving to voice channel: {target_channel.name}") | 177 | logger.info(f"Moving to voice channel: {target_channel.name}") |
| 170 | await vc.move_to(target_channel) | 178 | await vc.move_to(target_channel) |
| 171 | 179 | ||
| 172 | # Stop any currently playing audio | 180 | # Stop any currently playing audio (if interrupt is True) |
| 173 | if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] | 181 | if interrupt and (vc.is_playing() or vc.is_paused()): # type: ignore[union-attr] |
| 174 | vc.stop() # type: ignore[union-attr] | 182 | vc.stop() # type: ignore[union-attr] |
| 183 | elif not interrupt and vc.is_playing(): # type: ignore[union-attr] | ||
| 184 | logger.info("Audio already playing and interrupt=False, skipping playback") | ||
| 185 | return False | ||
| 175 | 186 | ||
| 176 | # Prepare audio source | 187 | # Prepare audio source |
| 177 | ( | 188 | ( |
| @@ -181,13 +192,14 @@ class AudioPlaybackManager: | |||
| 181 | source, is_url, display_name | 192 | source, is_url, display_name |
| 182 | ) | 193 | ) |
| 183 | 194 | ||
| 184 | # Track if playback failed | 195 | # Track playback state |
| 185 | playback_failed = {"error": None} | 196 | playback_state = {"error": None, "finished": False} |
| 186 | 197 | ||
| 187 | def after_playing(error): | 198 | def after_playing(error): |
| 188 | if error: | 199 | if error: |
| 189 | playback_failed["error"] = str(error) | 200 | playback_state["error"] = str(error) |
| 190 | logger.error(f"Playback error: {error}") | 201 | logger.error(f"Playback error: {error}") |
| 202 | playback_state["finished"] = True | ||
| 191 | 203 | ||
| 192 | # Play the audio | 204 | # Play the audio |
| 193 | vc.play(audio_source, after=after_playing) | 205 | vc.play(audio_source, after=after_playing) |
| @@ -197,14 +209,19 @@ class AudioPlaybackManager: | |||
| 197 | # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds | 209 | # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds |
| 198 | await asyncio.sleep(5) | 210 | await asyncio.sleep(5) |
| 199 | 211 | ||
| 200 | # Check if playback actually started | 212 | # Check if playback failed to start (not just finished quickly) |
| 201 | if playback_failed["error"]: | 213 | if playback_state["error"]: |
| 202 | logger.error(f"Playback failed: {playback_failed['error']}") | 214 | logger.error(f"Playback failed: {playback_state['error']}") |
| 203 | await ctx.followup.send( | 215 | await ctx.followup.send( |
| 204 | "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True | 216 | "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True |
| 205 | ) | 217 | ) |
| 206 | return False | 218 | return False |
| 207 | 219 | ||
| 220 | # If playback finished within 5s, it was a short file - not an error | ||
| 221 | if playback_state["finished"]: | ||
| 222 | logger.info(f"Playback completed quickly: {final_display_name}") | ||
| 223 | return True | ||
| 224 | |||
| 208 | if not vc.is_playing(): | 225 | if not vc.is_playing(): |
| 209 | logger.error(f"Stream failed - not playing after 5s: {source}") | 226 | logger.error(f"Stream failed - not playing after 5s: {source}") |
| 210 | await ctx.followup.send( | 227 | await ctx.followup.send( |
| @@ -232,13 +249,14 @@ class AudioPlaybackManager: | |||
| 232 | return prepared_source, display_name, is_url | 249 | return prepared_source, display_name, is_url |
| 233 | 250 | ||
| 234 | @staticmethod | 251 | @staticmethod |
| 235 | async def play(ctx, source, delete_after_play=False): | 252 | async def play(ctx, source, delete_after_play=False, interrupt=True): |
| 236 | """Play audio from a local file or URL. | 253 | """Play audio from a local file or URL. |
| 237 | 254 | ||
| 238 | Args: | 255 | Args: |
| 239 | ctx: Discord context | 256 | ctx: Discord context |
| 240 | source: Either a local file path or a URL to stream from | 257 | source: Either a local file path or a URL to stream from |
| 241 | delete_after_play: If True, delete the file after playing (only for local files) | 258 | delete_after_play: If True, delete the file after playing (only for local files) |
| 259 | interrupt: If True, stop any currently playing audio. If False, skip if audio is playing. | ||
| 242 | """ | 260 | """ |
| 243 | # Validate and prepare source | 261 | # Validate and prepare source |
| 244 | ( | 262 | ( |
| @@ -254,7 +272,7 @@ class AudioPlaybackManager: | |||
| 254 | 272 | ||
| 255 | # Execute playback | 273 | # Execute playback |
| 256 | success = await AudioPlaybackManager.execute_playback( | 274 | success = await AudioPlaybackManager.execute_playback( |
| 257 | ctx, prepared_source, display_name, is_url | 275 | ctx, prepared_source, display_name, is_url, interrupt |
| 258 | ) | 276 | ) |
| 259 | 277 | ||
| 260 | # Cleanup if needed | 278 | # Cleanup if needed |
diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py index b9b814c..91bd406 100644 --- a/cogs/music/media_processing.py +++ b/cogs/music/media_processing.py | |||
| @@ -9,6 +9,7 @@ import os | |||
| 9 | 9 | ||
| 10 | import yt_dlp | 10 | import yt_dlp |
| 11 | from youtubesearchpython import VideosSearch | 11 | from youtubesearchpython import VideosSearch |
| 12 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 12 | 13 | ||
| 13 | logger = logging.getLogger(__name__) | 14 | logger = logging.getLogger(__name__) |
| 14 | 15 | ||
| @@ -36,6 +37,7 @@ class MediaDownloader: | |||
| 36 | } | 37 | } |
| 37 | 38 | ||
| 38 | try: | 39 | try: |
| 40 | url = clean_url_for_ytdlp(url) | ||
| 39 | with yt_dlp.YoutubeDL(ytdl_opts) as ydl: | 41 | with yt_dlp.YoutubeDL(ytdl_opts) as ydl: |
| 40 | info_dict = ydl.extract_info(url, download=True) | 42 | info_dict = ydl.extract_info(url, download=True) |
| 41 | # Get the base filename without extension | 43 | # Get the base filename without extension |
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index 5373436..bdb021d 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -18,6 +18,8 @@ from discord.ext import commands | |||
| 18 | 18 | ||
| 19 | from cogs.music import audio_playback, media_processing | 19 | from cogs.music import audio_playback, media_processing |
| 20 | from cogs.utils.file_search import UniversalSearcher | 20 | from cogs.utils.file_search import UniversalSearcher |
| 21 | from cogs.utils.settings import get_uploadurl | ||
| 22 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 21 | 23 | ||
| 22 | logger = logging.getLogger(__name__) | 24 | logger = logging.getLogger(__name__) |
| 23 | 25 | ||
| @@ -192,6 +194,7 @@ class PlayCommands(commands.Cog): | |||
| 192 | } | 194 | } |
| 193 | 195 | ||
| 194 | loop = asyncio.get_event_loop() | 196 | loop = asyncio.get_event_loop() |
| 197 | url = clean_url_for_ytdlp(url) | ||
| 195 | with YoutubeDL(ytdl_opts) as ydl: | 198 | with YoutubeDL(ytdl_opts) as ydl: |
| 196 | info_dict = await loop.run_in_executor( | 199 | info_dict = await loop.run_in_executor( |
| 197 | None, lambda: ydl.extract_info(url, download=True) | 200 | None, lambda: ydl.extract_info(url, download=True) |
| @@ -229,14 +232,13 @@ class PlayCommands(commands.Cog): | |||
| 229 | 232 | ||
| 230 | try: | 233 | try: |
| 231 | logger.info(f"Starting async upload: {file_path}") | 234 | logger.info(f"Starting async upload: {file_path}") |
| 232 | baseurl = os.getenv("COPYPARTY_URL_UPLOAD") | 235 | baseurl = get_uploadurl() |
| 233 | password = os.getenv("COPYPARTY_PASSWORD", "") | 236 | password = os.getenv("COPYPARTY_PASSWORD", "") |
| 234 | 237 | ||
| 235 | logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") | 238 | logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") |
| 236 | 239 | ||
| 237 | if not baseurl: | 240 | if baseurl == "http://localhost:3939": |
| 238 | logger.error("COPYPARTY_URL_UPLOAD not set!") | 241 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") |
| 239 | return | ||
| 240 | 242 | ||
| 241 | with open(file_path, "rb") as f: | 243 | with open(file_path, "rb") as f: |
| 242 | content = f.read() | 244 | content = f.read() |
diff --git a/cogs/trigger.py b/cogs/trigger.py index 02e5573..ce80d43 100644 --- a/cogs/trigger.py +++ b/cogs/trigger.py | |||
| @@ -7,13 +7,13 @@ import os | |||
| 7 | import discord | 7 | import discord |
| 8 | from discord.ext import commands | 8 | from discord.ext import commands |
| 9 | import random | 9 | import random |
| 10 | import sqlite3 | ||
| 11 | import logging | 10 | import logging |
| 12 | 11 | ||
| 13 | logger = logging.getLogger(__name__) | 12 | from cogs.utils.database import get_connection, init_database |
| 14 | 13 | ||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | 15 | ||
| 16 | DB_PATH = 'data/triggers.db' | 16 | init_database() |
| 17 | 17 | ||
| 18 | _SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" | 18 | _SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" |
| 19 | _SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" | 19 | _SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" |
| @@ -22,88 +22,12 @@ _TRIGGER_NOT_FOUND_MSG = "Trigger not found." | |||
| 22 | _TRIGGER_NAME_OPTION = "the name of the trigger" | 22 | _TRIGGER_NAME_OPTION = "the name of the trigger" |
| 23 | 23 | ||
| 24 | 24 | ||
| 25 | def get_connection(): | ||
| 26 | return sqlite3.connect(DB_PATH) | ||
| 27 | |||
| 28 | |||
| 29 | class Trigger(commands.Cog): | 25 | class Trigger(commands.Cog): |
| 30 | def __init__(self, bot): | 26 | def __init__(self, bot): |
| 31 | self.bot = bot | 27 | self.bot = bot |
| 32 | self.db_check() | ||
| 33 | 28 | ||
| 34 | trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") | 29 | trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") |
| 35 | 30 | ||
| 36 | @staticmethod | ||
| 37 | def db_check(): | ||
| 38 | if not os.path.exists('data'): | ||
| 39 | os.makedirs('data') | ||
| 40 | |||
| 41 | db_exists = os.path.exists(DB_PATH) | ||
| 42 | if not db_exists: | ||
| 43 | logger.warning("Triggers database not found. Creating new database.") | ||
| 44 | |||
| 45 | try: | ||
| 46 | conn = sqlite3.connect(DB_PATH) | ||
| 47 | c = conn.cursor() | ||
| 48 | |||
| 49 | # Enable foreign key constraints | ||
| 50 | c.execute('PRAGMA foreign_keys = ON') | ||
| 51 | |||
| 52 | # Create triggers table | ||
| 53 | c.execute(''' | ||
| 54 | CREATE TABLE IF NOT EXISTS triggers ( | ||
| 55 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 56 | name TEXT NOT NULL UNIQUE, | ||
| 57 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 58 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 59 | )''') | ||
| 60 | |||
| 61 | # Create trigger_keywords table with foreign key constraint | ||
| 62 | c.execute(''' | ||
| 63 | CREATE TABLE IF NOT EXISTS trigger_keywords ( | ||
| 64 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 65 | trigger_id INTEGER NOT NULL, | ||
| 66 | keyword TEXT NOT NULL, | ||
| 67 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 68 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, | ||
| 69 | UNIQUE(trigger_id, keyword) | ||
| 70 | )''') | ||
| 71 | |||
| 72 | # Create trigger_responses table with foreign key constraint | ||
| 73 | c.execute(''' | ||
| 74 | CREATE TABLE IF NOT EXISTS trigger_responses ( | ||
| 75 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 76 | trigger_id INTEGER NOT NULL, | ||
| 77 | response_text TEXT NOT NULL, | ||
| 78 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 79 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE | ||
| 80 | )''') | ||
| 81 | |||
| 82 | # Create indexes for better query performance | ||
| 83 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)') | ||
| 84 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)') | ||
| 85 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)') | ||
| 86 | |||
| 87 | # Run integrity check | ||
| 88 | integrity_check = c.execute('PRAGMA integrity_check').fetchone() | ||
| 89 | if integrity_check and integrity_check[0] == 'ok': | ||
| 90 | logger.info("Triggers database integrity check passed") | ||
| 91 | else: | ||
| 92 | logger.warning(f"Triggers database integrity check: {integrity_check}") | ||
| 93 | |||
| 94 | conn.commit() | ||
| 95 | |||
| 96 | except sqlite3.Error as e: | ||
| 97 | logger.error(f"Triggers database error: {e}") | ||
| 98 | raise | ||
| 99 | |||
| 100 | finally: | ||
| 101 | if 'conn' in locals(): | ||
| 102 | conn.close() | ||
| 103 | |||
| 104 | if not db_exists: | ||
| 105 | logger.info("Triggers database initialized successfully") | ||
| 106 | |||
| 107 | @commands.Cog.listener() | 31 | @commands.Cog.listener() |
| 108 | async def on_message(self, message): | 32 | async def on_message(self, message): |
| 109 | if message.author == self.bot.user: | 33 | if message.author == self.bot.user: |
| @@ -130,7 +54,7 @@ class Trigger(commands.Cog): | |||
| 130 | 54 | ||
| 131 | @staticmethod | 55 | @staticmethod |
| 132 | def get_type(name): | 56 | def get_type(name): |
| 133 | with sqlite3.connect(DB_PATH) as conn: | 57 | with get_connection() as conn: |
| 134 | c = conn.cursor() | 58 | c = conn.cursor() |
| 135 | c.execute("SELECT name FROM triggers") | 59 | c.execute("SELECT name FROM triggers") |
| 136 | return [row[0] for row in c.fetchall()] | 60 | return [row[0] for row in c.fetchall()] |
diff --git a/cogs/uploader.py b/cogs/uploader.py new file mode 100644 index 0000000..df6908e --- /dev/null +++ b/cogs/uploader.py | |||
| @@ -0,0 +1,155 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import aiofiles | ||
| 8 | import requests | ||
| 9 | import urllib.parse | ||
| 10 | from typing import Optional | ||
| 11 | import discord | ||
| 12 | from discord.ext import commands | ||
| 13 | from discord import AutocompleteContext | ||
| 14 | from cogs.utils import file_search as search_utils | ||
| 15 | from cogs.utils.settings import get_uploadurl | ||
| 16 | import logging | ||
| 17 | |||
| 18 | logger = logging.getLogger(__name__) | ||
| 19 | |||
| 20 | |||
| 21 | class Uploader(commands.Cog): | ||
| 22 | def __init__(self, bot): | ||
| 23 | from cogs.utils.temp import get_temp_dir | ||
| 24 | self.bot = bot | ||
| 25 | self.temp_dir = get_temp_dir() | ||
| 26 | self._cached_urls = {} | ||
| 27 | self._uploadurl = get_uploadurl() | ||
| 28 | |||
| 29 | async def _file_autocomplete(self, ctx: AutocompleteContext): | ||
| 30 | query = ctx.value.lower() if ctx.value else "" | ||
| 31 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 32 | |||
| 33 | user_id = ctx.interaction.user.id | ||
| 34 | self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 35 | |||
| 36 | choices = [] | ||
| 37 | for url in results[:25]: | ||
| 38 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 39 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 40 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 41 | return choices | ||
| 42 | |||
| 43 | async def _download_from_url(self, url: str) -> Optional[str]: | ||
| 44 | """Download file from copyparty URL and return local path""" | ||
| 45 | try: | ||
| 46 | response = requests.get(url) | ||
| 47 | if response.status_code != 200: | ||
| 48 | return None | ||
| 49 | |||
| 50 | filename = url.split('/')[-1].split('?')[0] | ||
| 51 | file_path = os.path.join(self.temp_dir, filename) | ||
| 52 | |||
| 53 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 54 | await f.write(response.content) | ||
| 55 | return file_path | ||
| 56 | except Exception as e: | ||
| 57 | logger.error(f"Error downloading from {url}: {e}") | ||
| 58 | return None | ||
| 59 | |||
| 60 | async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: | ||
| 61 | """Upload file to copyparty and return download URL""" | ||
| 62 | try: | ||
| 63 | async with aiofiles.open(file_path, 'rb') as f: | ||
| 64 | file_content = await f.read() | ||
| 65 | |||
| 66 | files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} | ||
| 67 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | ||
| 68 | params = {'j': '1', 'ck': 'b2'} | ||
| 69 | |||
| 70 | response = requests.post(self._uploadurl, files=files, headers=headers, params=params) | ||
| 71 | |||
| 72 | if response.status_code in [200, 201]: | ||
| 73 | result = response.json() | ||
| 74 | return result.get('fileurl') | ||
| 75 | except Exception as e: | ||
| 76 | logger.error(f"Upload error: {e}") | ||
| 77 | return None | ||
| 78 | |||
| 79 | async def _upload_file(self, file_path: str) -> Optional[str]: | ||
| 80 | """Upload a file and return the URL""" | ||
| 81 | if self._uploadurl == "http://localhost:3939": | ||
| 82 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") | ||
| 83 | return None | ||
| 84 | |||
| 85 | return await self._upload_to_copyparty(file_path) | ||
| 86 | |||
| 87 | @commands.message_command( | ||
| 88 | name="upload", | ||
| 89 | description="wyślij załączniki na copyparty", | ||
| 90 | ) | ||
| 91 | async def message_upload(self, ctx, message: discord.Message): | ||
| 92 | if not message.attachments: | ||
| 93 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) | ||
| 94 | return | ||
| 95 | |||
| 96 | await ctx.defer() | ||
| 97 | |||
| 98 | uploaded_urls = [] | ||
| 99 | failed_count = 0 | ||
| 100 | |||
| 101 | for attachment in message.attachments: | ||
| 102 | file_path = os.path.join(self.temp_dir, attachment.filename) | ||
| 103 | await attachment.save(file_path) | ||
| 104 | |||
| 105 | url = await self._upload_file(file_path) | ||
| 106 | if url: | ||
| 107 | uploaded_urls.append(url) | ||
| 108 | else: | ||
| 109 | failed_count += 1 | ||
| 110 | |||
| 111 | if os.path.exists(file_path): | ||
| 112 | os.remove(file_path) | ||
| 113 | |||
| 114 | if uploaded_urls: | ||
| 115 | response = f"Wysłano {len(uploaded_urls)} plików:\n" + "\n".join(uploaded_urls) | ||
| 116 | if failed_count > 0: | ||
| 117 | response += f"\n\nNie wysłano {failed_count} plików" | ||
| 118 | await ctx.respond(response) | ||
| 119 | else: | ||
| 120 | await ctx.respond("Nie udało się wysłać żadnych plików", ephemeral=True) | ||
| 121 | |||
| 122 | @commands.slash_command( | ||
| 123 | name="upload", | ||
| 124 | description="wyślij plik na copyparty", | ||
| 125 | ) | ||
| 126 | @discord.option( | ||
| 127 | name="zalacznik", | ||
| 128 | description="plik z discorda", | ||
| 129 | required=True, | ||
| 130 | type=discord.Attachment | ||
| 131 | ) | ||
| 132 | async def upload(self, ctx, zalacznik: discord.Attachment = None): | ||
| 133 | await ctx.defer() | ||
| 134 | |||
| 135 | file_path = None | ||
| 136 | file_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 137 | await zalacznik.save(file_path) | ||
| 138 | |||
| 139 | if not file_path or not os.path.exists(file_path): | ||
| 140 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 141 | return | ||
| 142 | |||
| 143 | try: | ||
| 144 | dl_url = await self._upload_file(file_path) | ||
| 145 | if dl_url: | ||
| 146 | await ctx.respond(dl_url) | ||
| 147 | else: | ||
| 148 | await ctx.respond("Nie udało się wysłać pliku", ephemeral=True) | ||
| 149 | finally: | ||
| 150 | if file_path and os.path.exists(file_path): | ||
| 151 | os.remove(file_path) | ||
| 152 | |||
| 153 | |||
| 154 | def setup(bot): | ||
| 155 | bot.add_cog(Uploader(bot)) | ||
diff --git a/cogs/utils/database.py b/cogs/utils/database.py new file mode 100644 index 0000000..ea7d356 --- /dev/null +++ b/cogs/utils/database.py | |||
| @@ -0,0 +1,126 @@ | |||
| 1 | import os | ||
| 2 | import sqlite3 | ||
| 3 | import logging | ||
| 4 | from contextlib import contextmanager | ||
| 5 | |||
| 6 | logger = logging.getLogger(__name__) | ||
| 7 | |||
| 8 | DB_PATH = "data/senator.db" | ||
| 9 | |||
| 10 | _initialized = False | ||
| 11 | |||
| 12 | |||
| 13 | def get_connection(): | ||
| 14 | conn = sqlite3.connect(get_db_path()) | ||
| 15 | return _ConnectionContextManager(conn) | ||
| 16 | |||
| 17 | |||
| 18 | def get_db_path(): | ||
| 19 | return os.getenv("SENATOR_DB_PATH", DB_PATH) | ||
| 20 | |||
| 21 | |||
| 22 | def set_db_path(path): | ||
| 23 | global DB_PATH | ||
| 24 | DB_PATH = path | ||
| 25 | |||
| 26 | |||
| 27 | class _ConnectionContextManager: | ||
| 28 | """Context manager for sqlite3 connections that auto-closes.""" | ||
| 29 | def __init__(self, conn): | ||
| 30 | self.conn = conn | ||
| 31 | |||
| 32 | def __enter__(self): | ||
| 33 | return self.conn | ||
| 34 | |||
| 35 | def __exit__(self, exc_type, exc_val, exc_tb): | ||
| 36 | self.conn.close() | ||
| 37 | return False | ||
| 38 | |||
| 39 | |||
| 40 | def init_database(): | ||
| 41 | global _initialized | ||
| 42 | if _initialized: | ||
| 43 | return | ||
| 44 | |||
| 45 | _initialized = True | ||
| 46 | |||
| 47 | db_path = get_db_path() | ||
| 48 | db_dir = os.path.dirname(db_path) | ||
| 49 | if db_dir and not os.path.exists(db_dir): | ||
| 50 | os.makedirs(db_dir) | ||
| 51 | |||
| 52 | conn = sqlite3.connect(db_path) | ||
| 53 | try: | ||
| 54 | c = conn.cursor() | ||
| 55 | |||
| 56 | c.execute("PRAGMA foreign_keys = ON") | ||
| 57 | |||
| 58 | c.execute(""" | ||
| 59 | CREATE TABLE IF NOT EXISTS settings ( | ||
| 60 | key TEXT PRIMARY KEY, | ||
| 61 | value TEXT | ||
| 62 | )""") | ||
| 63 | |||
| 64 | c.execute(""" | ||
| 65 | CREATE TABLE IF NOT EXISTS cron_jobs ( | ||
| 66 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 67 | cron_expression TEXT NOT NULL, | ||
| 68 | type TEXT NOT NULL, | ||
| 69 | channel_id INTEGER NOT NULL, | ||
| 70 | active INTEGER DEFAULT 1, | ||
| 71 | audio_filename TEXT, | ||
| 72 | message TEXT, | ||
| 73 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 74 | )""") | ||
| 75 | |||
| 76 | c.execute(""" | ||
| 77 | CREATE TABLE IF NOT EXISTS voice_activity ( | ||
| 78 | user_id TEXT PRIMARY KEY, | ||
| 79 | join_time REAL, | ||
| 80 | total_active REAL DEFAULT 0, | ||
| 81 | total_afk REAL DEFAULT 0, | ||
| 82 | is_afk INTEGER DEFAULT 0, | ||
| 83 | afk_start REAL, | ||
| 84 | active_start REAL, | ||
| 85 | channel_name TEXT, | ||
| 86 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 87 | )""") | ||
| 88 | |||
| 89 | c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)") | ||
| 90 | c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)") | ||
| 91 | |||
| 92 | c.execute(""" | ||
| 93 | CREATE TABLE IF NOT EXISTS triggers ( | ||
| 94 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 95 | name TEXT NOT NULL UNIQUE, | ||
| 96 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 97 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||
| 98 | )""") | ||
| 99 | |||
| 100 | c.execute(""" | ||
| 101 | CREATE TABLE IF NOT EXISTS trigger_keywords ( | ||
| 102 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 103 | trigger_id INTEGER NOT NULL, | ||
| 104 | keyword TEXT NOT NULL, | ||
| 105 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 106 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, | ||
| 107 | UNIQUE(trigger_id, keyword) | ||
| 108 | )""") | ||
| 109 | |||
| 110 | c.execute(""" | ||
| 111 | CREATE TABLE IF NOT EXISTS trigger_responses ( | ||
| 112 | id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 113 | trigger_id INTEGER NOT NULL, | ||
| 114 | response_text TEXT NOT NULL, | ||
| 115 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| 116 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE | ||
| 117 | )""") | ||
| 118 | |||
| 119 | c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)") | ||
| 120 | c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)") | ||
| 121 | c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)") | ||
| 122 | |||
| 123 | conn.commit() | ||
| 124 | logger.info("Database initialized successfully") | ||
| 125 | finally: | ||
| 126 | conn.close() | ||
diff --git a/cogs/utils/file_search.py b/cogs/utils/file_search.py index 8111216..788f50c 100644 --- a/cogs/utils/file_search.py +++ b/cogs/utils/file_search.py | |||
| @@ -34,10 +34,22 @@ class UniversalSearcher: | |||
| 34 | query = query.strip() | 34 | query = query.strip() |
| 35 | logger.info(f"Starting copyparty search for query: '{query}'") | 35 | logger.info(f"Starting copyparty search for query: '{query}'") |
| 36 | 36 | ||
| 37 | # Build the query with optional extension filtering | 37 | tokens = query.split() |
| 38 | name_query = f'name like "*{query}*"' | 38 | if not tokens: |
| 39 | return [] | ||
| 40 | |||
| 41 | token_queries = [] | ||
| 42 | for token in tokens: | ||
| 43 | path_query = f'path like "*{token}*"' | ||
| 44 | name_query = f'name like "*{token}*"' | ||
| 45 | token_queries.append(f"({path_query} or {name_query})") | ||
| 46 | |||
| 47 | if len(token_queries) == 1: | ||
| 48 | name_query = token_queries[0] | ||
| 49 | else: | ||
| 50 | name_query = " and ".join(token_queries) | ||
| 51 | |||
| 39 | if file_extensions: | 52 | if file_extensions: |
| 40 | # Create extension filters using copyparty's .ext$ syntax | ||
| 41 | ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] | 53 | ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] |
| 42 | ext_query = " or ".join(ext_filters) | 54 | ext_query = " or ".join(ext_filters) |
| 43 | query = f"{name_query} and ({ext_query})" | 55 | query = f"{name_query} and ({ext_query})" |
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py index d66ce7f..b1a585c 100644 --- a/cogs/utils/settings.py +++ b/cogs/utils/settings.py | |||
| @@ -3,16 +3,18 @@ | |||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io |
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import json | ||
| 7 | import logging | 6 | import logging |
| 8 | import aiofiles | 7 | import sqlite3 |
| 9 | import os | 8 | import os |
| 10 | from dataclasses import dataclass, field | 9 | from dataclasses import dataclass, field |
| 11 | 10 | ||
| 11 | from cogs.utils.database import get_connection, init_database | ||
| 12 | |||
| 12 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
| 13 | 14 | ||
| 14 | settings_file = "data/settings.json" | 15 | DEFAULT_BASEURL = "http://localhost:3939" |
| 15 | DEFAULT_BASEURL = "http://localhost:8080" | 16 | |
| 17 | init_database() | ||
| 16 | 18 | ||
| 17 | 19 | ||
| 18 | @dataclass | 20 | @dataclass |
| @@ -28,24 +30,21 @@ class Settings: | |||
| 28 | } | 30 | } |
| 29 | 31 | ||
| 30 | try: | 32 | try: |
| 31 | if os.path.exists(settings_file): | 33 | with get_connection() as conn: |
| 32 | async with aiofiles.open(settings_file, "r") as f: | 34 | c = conn.cursor() |
| 33 | content = await f.read() | 35 | c.execute("SELECT key, value FROM settings") |
| 34 | if content.strip(): | 36 | rows = c.fetchall() |
| 35 | self._data = json.loads(content) | 37 | |
| 36 | self._sync_from_data() | 38 | if rows: |
| 37 | logger.info("Settings loaded into memory") | 39 | self._data = {row[0]: row[1] for row in rows} |
| 38 | else: | 40 | self._sync_from_data() |
| 39 | self._data = default_settings.copy() | 41 | logger.info("Settings loaded from database") |
| 40 | self._sync_from_data() | ||
| 41 | await self.save() | ||
| 42 | logger.warning("Empty settings.json, reset to defaults") | ||
| 43 | else: | 42 | else: |
| 44 | self._data = default_settings.copy() | 43 | self._data = default_settings.copy() |
| 45 | self._sync_from_data() | 44 | self._sync_from_data() |
| 46 | await self.save() | 45 | await self.save() |
| 47 | logger.info("Created default settings.json") | 46 | logger.info("Created default settings in database") |
| 48 | except json.JSONDecodeError as e: | 47 | except Exception as e: |
| 49 | logger.error(f"Error loading settings: {e}") | 48 | logger.error(f"Error loading settings: {e}") |
| 50 | self._data = default_settings.copy() | 49 | self._data = default_settings.copy() |
| 51 | self._sync_from_data() | 50 | self._sync_from_data() |
| @@ -53,17 +52,27 @@ class Settings: | |||
| 53 | return self | 52 | return self |
| 54 | 53 | ||
| 55 | def _sync_from_data(self): | 54 | def _sync_from_data(self): |
| 56 | self.volume = self._data.get("volume", 1.0) | 55 | try: |
| 56 | self.volume = float(self._data.get("volume", 1.0)) | ||
| 57 | except (ValueError, TypeError): | ||
| 58 | self.volume = 1.0 | ||
| 57 | self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) | 59 | self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) |
| 58 | 60 | ||
| 59 | def _sync_to_data(self): | 61 | def _sync_to_data(self): |
| 60 | self._data["volume"] = self.volume | 62 | self._data["volume"] = str(self.volume) |
| 61 | self._data["baseurl"] = self.baseurl | 63 | self._data["baseurl"] = self.baseurl |
| 62 | 64 | ||
| 63 | async def save(self): | 65 | async def save(self): |
| 64 | self._sync_to_data() | 66 | self._sync_to_data() |
| 65 | async with aiofiles.open(settings_file, "w") as f: | 67 | with get_connection() as conn: |
| 66 | await f.write(json.dumps(self._data, indent=2)) | 68 | c = conn.cursor() |
| 69 | c.execute("PRAGMA foreign_keys = ON") | ||
| 70 | for key, value in self._data.items(): | ||
| 71 | c.execute( | ||
| 72 | "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", | ||
| 73 | (key, value), | ||
| 74 | ) | ||
| 75 | conn.commit() | ||
| 67 | 76 | ||
| 68 | 77 | ||
| 69 | _settings_instance: Settings | None = None | 78 | _settings_instance: Settings | None = None |
| @@ -90,4 +99,16 @@ def get_volume() -> float: | |||
| 90 | 99 | ||
| 91 | 100 | ||
| 92 | def get_baseurl() -> str: | 101 | def get_baseurl() -> str: |
| 93 | return _settings_instance.baseurl.rstrip("/") if _settings_instance else DEFAULT_BASEURL | 102 | url = os.getenv("COPYPARTY_URL") |
| 103 | if not url: | ||
| 104 | logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939") | ||
| 105 | return DEFAULT_BASEURL | ||
| 106 | return url.rstrip("/") | ||
| 107 | |||
| 108 | |||
| 109 | def get_uploadurl() -> str: | ||
| 110 | url = os.getenv("COPYPARTY_URL_UPLOAD") | ||
| 111 | if not url: | ||
| 112 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939") | ||
| 113 | return DEFAULT_BASEURL | ||
| 114 | return url.rstrip("/") | ||
diff --git a/cogs/utils/url_helper.py b/cogs/utils/url_helper.py new file mode 100644 index 0000000..eac06f1 --- /dev/null +++ b/cogs/utils/url_helper.py | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | |||
| 7 | def clean_url_for_ytdlp(url: str) -> str: | ||
| 8 | """Clean URL by removing everything after the first '&'. | ||
| 9 | |||
| 10 | This prevents yt-dlp from interpreting additional query parameters | ||
| 11 | (like &list=...) as playlist URLs. | ||
| 12 | |||
| 13 | Example: | ||
| 14 | Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM& | ||
| 15 | Output: https://www.youtube.com/watch?v=lPGipwoJiOM | ||
| 16 | """ | ||
| 17 | # Find the first '&' and truncate | ||
| 18 | first_ampersand = url.find('&') | ||
| 19 | if first_ampersand != -1: | ||
| 20 | return url[:first_ampersand] | ||
| 21 | return url | ||
diff --git a/cogs/vclogs.py b/cogs/vclogs.py index fb0a2e0..e5ca202 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py | |||
| @@ -4,7 +4,6 @@ | |||
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import datetime | 6 | import datetime |
| 7 | import json | ||
| 8 | import logging | 7 | import logging |
| 9 | import os | 8 | import os |
| 10 | import asyncio | 9 | import asyncio |
| @@ -13,24 +12,66 @@ from discord.ext import commands | |||
| 13 | from gtts import gTTS | 12 | from gtts import gTTS |
| 14 | from cogs.voice import get_voice_manager | 13 | from cogs.voice import get_voice_manager |
| 15 | from cogs.utils.settings import get_volume, save_settings | 14 | from cogs.utils.settings import get_volume, save_settings |
| 15 | from cogs.utils.database import get_connection, init_database | ||
| 16 | 16 | ||
| 17 | import tempfile | 17 | import tempfile |
| 18 | 18 | ||
| 19 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| 20 | 20 | ||
| 21 | ACTIVITY_FILE = os.path.join(tempfile.gettempdir(), 'voice_activity.json') | 21 | init_database() |
| 22 | 22 | ||
| 23 | 23 | ||
| 24 | def load_activity_data(): | 24 | def load_activity_data(): |
| 25 | if os.path.exists(ACTIVITY_FILE): | 25 | try: |
| 26 | with open(ACTIVITY_FILE, 'r') as f: | 26 | with get_connection() as conn: |
| 27 | return json.load(f) | 27 | c = conn.cursor() |
| 28 | return {} | 28 | c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") |
| 29 | rows = c.fetchall() | ||
| 30 | |||
| 31 | return { | ||
| 32 | row[0]: { | ||
| 33 | "join_time": row[1], | ||
| 34 | "total_active": row[2], | ||
| 35 | "total_afk": row[3], | ||
| 36 | "is_afk": bool(row[4]), | ||
| 37 | "afk_start": row[5], | ||
| 38 | "active_start": row[6], | ||
| 39 | "channel_name": row[7], | ||
| 40 | } | ||
| 41 | for row in rows | ||
| 42 | } | ||
| 43 | except Exception as e: | ||
| 44 | logger.error(f"Error loading voice activity: {e}") | ||
| 45 | return {} | ||
| 29 | 46 | ||
| 30 | 47 | ||
| 31 | def save_activity_data(data): | 48 | def save_activity_data(data): |
| 32 | with open(ACTIVITY_FILE, 'w') as f: | 49 | try: |
| 33 | json.dump(data, f, indent=4) | 50 | with get_connection() as conn: |
| 51 | c = conn.cursor() | ||
| 52 | c.execute("PRAGMA foreign_keys = ON") | ||
| 53 | |||
| 54 | c.execute("DELETE FROM voice_activity") | ||
| 55 | |||
| 56 | for user_id, user_data in data.items(): | ||
| 57 | c.execute( | ||
| 58 | """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) | ||
| 59 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", | ||
| 60 | ( | ||
| 61 | user_id, | ||
| 62 | user_data.get("join_time"), | ||
| 63 | user_data.get("total_active", 0), | ||
| 64 | user_data.get("total_afk", 0), | ||
| 65 | int(user_data.get("is_afk", False)), | ||
| 66 | user_data.get("afk_start"), | ||
| 67 | user_data.get("active_start"), | ||
| 68 | user_data.get("channel_name"), | ||
| 69 | ), | ||
| 70 | ) | ||
| 71 | conn.commit() | ||
| 72 | logger.info(f"Saved voice activity for {len(data)} users") | ||
| 73 | except Exception as e: | ||
| 74 | logger.error(f"Error saving voice activity: {e}") | ||
| 34 | 75 | ||
| 35 | 76 | ||
| 36 | def format_duration(seconds): | 77 | def format_duration(seconds): |
| @@ -104,9 +145,8 @@ class VcLogs(commands.Cog): | |||
| 104 | 145 | ||
| 105 | if voice_client: | 146 | if voice_client: |
| 106 | if is_playing: | 147 | if is_playing: |
| 107 | logger.info("voice already playing, stopping current audio") | 148 | logger.info("voice already playing, skipping TTS") |
| 108 | voice_client.stop() | 149 | return |
| 109 | await asyncio.sleep(0.1) | ||
| 110 | 150 | ||
| 111 | logger.info("preparing to play audio") | 151 | logger.info("preparing to play audio") |
| 112 | audio_source = discord.FFmpegPCMAudio(tts_file) | 152 | audio_source = discord.FFmpegPCMAudio(tts_file) |
| @@ -169,7 +209,7 @@ class VcLogs(commands.Cog): | |||
| 169 | } | 209 | } |
| 170 | save_activity_data(activity_data) | 210 | save_activity_data(activity_data) |
| 171 | 211 | ||
| 172 | message = f"[{current_time}] {member.name} dołączył na {after.channel.name}" | 212 | message = f"[{current_time}] {member.name} dołączył na {after.channel.name} <t:{int(current_ts)}:R>" |
| 173 | tts_text = f"{member.display_name} sie zespawnił" | 213 | tts_text = f"{member.display_name} sie zespawnił" |
| 174 | tts_channel = after.channel | 214 | tts_channel = after.channel |
| 175 | await log_channel.send(message) | 215 | await log_channel.send(message) |
| @@ -190,7 +230,7 @@ class VcLogs(commands.Cog): | |||
| 190 | afk_str = format_duration(data['total_afk']) | 230 | afk_str = format_duration(data['total_afk']) |
| 191 | 231 | ||
| 192 | message = ( | 232 | message = ( |
| 193 | f"[{current_time}] {member.name} rozłączył się z {before.channel.name}\n" | 233 | f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>\n" |
| 194 | f"Aktywny: {active_str} | AFK: {afk_str}" | 234 | f"Aktywny: {active_str} | AFK: {afk_str}" |
| 195 | ) | 235 | ) |
| 196 | tts_text = f"{member.display_name} jest chujem" | 236 | tts_text = f"{member.display_name} jest chujem" |
| @@ -201,7 +241,7 @@ class VcLogs(commands.Cog): | |||
| 201 | del activity_data[user_id] | 241 | del activity_data[user_id] |
| 202 | save_activity_data(activity_data) | 242 | save_activity_data(activity_data) |
| 203 | else: | 243 | else: |
| 204 | message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name}" | 244 | message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>" |
| 205 | tts_text = f"{member.display_name} jest chujem" | 245 | tts_text = f"{member.display_name} jest chujem" |
| 206 | tts_channel = before.channel | 246 | tts_channel = before.channel |
| 207 | await log_channel.send(message) | 247 | await log_channel.send(message) |
| @@ -225,7 +265,7 @@ class VcLogs(commands.Cog): | |||
| 225 | else: | 265 | else: |
| 226 | activity = "odgłuchnął" | 266 | activity = "odgłuchnął" |
| 227 | 267 | ||
| 228 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name}\n" | 268 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n" |
| 229 | f"AFK: {afk_str}" ) | 269 | f"AFK: {afk_str}" ) |
| 230 | await log_channel.send(message) | 270 | await log_channel.send(message) |
| 231 | logger.info(f"User returned from AFK: {message}") | 271 | logger.info(f"User returned from AFK: {message}") |
| @@ -245,7 +285,7 @@ class VcLogs(commands.Cog): | |||
| 245 | activity = "ogłuchnął" | 285 | activity = "ogłuchnął" |
| 246 | 286 | ||
| 247 | active_str = format_duration(active_since_start) | 287 | active_str = format_duration(active_since_start) |
| 248 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name}\n" | 288 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n" |
| 249 | f"Aktywny: {active_str}" ) | 289 | f"Aktywny: {active_str}" ) |
| 250 | await log_channel.send(message) | 290 | await log_channel.send(message) |
| 251 | logger.info(f"User went AFK: {message}") | 291 | logger.info(f"User went AFK: {message}") |
diff --git a/cogs/voice.py b/cogs/voice.py index 319bb16..046c2a8 100644 --- a/cogs/voice.py +++ b/cogs/voice.py | |||
| @@ -45,6 +45,7 @@ class VoiceManager: | |||
| 45 | return voice_client | 45 | return voice_client |
| 46 | except Exception as e: | 46 | except Exception as e: |
| 47 | logger.error(f"Failed to move voice client: {e}") | 47 | logger.error(f"Failed to move voice client: {e}") |
| 48 | logger.info("Disconnecting after failed move attempt") | ||
| 48 | try: | 49 | try: |
| 49 | await voice_client.disconnect() | 50 | await voice_client.disconnect() |
| 50 | except Exception: | 51 | except Exception: |
| @@ -55,6 +56,25 @@ class VoiceManager: | |||
| 55 | voice_client = await channel.connect(timeout=30.0, reconnect=False) | 56 | voice_client = await channel.connect(timeout=30.0, reconnect=False) |
| 56 | logger.info(f"Connected to voice channel: {channel}") | 57 | logger.info(f"Connected to voice channel: {channel}") |
| 57 | return voice_client | 58 | return voice_client |
| 59 | except discord.ClientException as e: | ||
| 60 | if "Already connected" in str(e): | ||
| 61 | logger.warning(f"Stale voice state detected, forcing disconnect and reconnect: {e}") | ||
| 62 | voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 63 | if voice_client: | ||
| 64 | try: | ||
| 65 | await voice_client.disconnect() | ||
| 66 | except Exception: | ||
| 67 | pass | ||
| 68 | await asyncio.sleep(0.3) | ||
| 69 | try: | ||
| 70 | voice_client = await channel.connect(timeout=30.0, reconnect=False) | ||
| 71 | logger.info(f"Reconnected to voice channel: {channel}") | ||
| 72 | return voice_client | ||
| 73 | except Exception as e2: | ||
| 74 | logger.error(f"Failed to reconnect after stale cleanup: {e2}") | ||
| 75 | return None | ||
| 76 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 77 | return None | ||
| 58 | except Exception as e: | 78 | except Exception as e: |
| 59 | logger.error(f"Failed to connect to voice channel: {e}") | 79 | logger.error(f"Failed to connect to voice channel: {e}") |
| 60 | return None | 80 | return None |
| @@ -113,6 +133,7 @@ class VoiceManager: | |||
| 113 | voice_client = await self.get_voice_client(guild) | 133 | voice_client = await self.get_voice_client(guild) |
| 114 | if voice_client: | 134 | if voice_client: |
| 115 | voice_client.stop() | 135 | voice_client.stop() |
| 136 | logger.info("stop() called - disconnecting from voice") | ||
| 116 | await voice_client.disconnect() | 137 | await voice_client.disconnect() |
| 117 | logger.info(f"Stopped and disconnected from guild {guild}") | 138 | logger.info(f"Stopped and disconnected from guild {guild}") |
| 118 | 139 | ||
| @@ -174,6 +195,7 @@ class Voice(commands.Cog): | |||
| 174 | 195 | ||
| 175 | if voice_client and not voice_client.is_connected(): | 196 | if voice_client and not voice_client.is_connected(): |
| 176 | try: | 197 | try: |
| 198 | logger.info("Voice client exists but not connected - disconnecting stale client") | ||
| 177 | await voice_client.disconnect() | 199 | await voice_client.disconnect() |
| 178 | except Exception: | 200 | except Exception: |
| 179 | pass | 201 | pass |
diff --git a/cogs/web.py b/cogs/web.py index b072e99..fd22f9f 100644 --- a/cogs/web.py +++ b/cogs/web.py | |||
| @@ -11,6 +11,7 @@ from bs4 import BeautifulSoup | |||
| 11 | import urllib.parse | 11 | import urllib.parse |
| 12 | import yt_dlp | 12 | import yt_dlp |
| 13 | import logging | 13 | import logging |
| 14 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 14 | 15 | ||
| 15 | logger = logging.getLogger(__name__) | 16 | logger = logging.getLogger(__name__) |
| 16 | 17 | ||
| @@ -24,6 +25,7 @@ class Web(commands.Cog): | |||
| 24 | if "youtube.com/watch" not in url: | 25 | if "youtube.com/watch" not in url: |
| 25 | return None | 26 | return None |
| 26 | 27 | ||
| 28 | url = clean_url_for_ytdlp(url) | ||
| 27 | logger.info("youtube link") | 29 | logger.info("youtube link") |
| 28 | try: | 30 | try: |
| 29 | ydl_opts = {"quiet": True, "nowarnings": True} | 31 | ydl_opts = {"quiet": True, "nowarnings": True} |
| @@ -34,18 +36,8 @@ class Web(commands.Cog): | |||
| 34 | pass | 36 | pass |
| 35 | return None | 37 | return None |
| 36 | 38 | ||
| 37 | def _create_button(self, index, link): | ||
| 38 | """Create a button for sending search result.""" | ||
| 39 | button = discord.ui.Button(label=f"Send Result {index}") | ||
| 40 | |||
| 41 | async def button_callback(interaction: discord.Interaction): | ||
| 42 | await interaction.response.send_message(link.get("href")) | ||
| 43 | |||
| 44 | button.callback = button_callback | ||
| 45 | return button | ||
| 46 | |||
| 47 | def _process_search_results(self, links, query): | 39 | def _process_search_results(self, links, query): |
| 48 | """Process search results and create embed with buttons.""" | 40 | """Process search results and create embed.""" |
| 49 | embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) | 41 | embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) |
| 50 | view = discord.ui.View(disable_on_timeout=True) | 42 | view = discord.ui.View(disable_on_timeout=True) |
| 51 | 43 | ||
| @@ -56,8 +48,6 @@ class Web(commands.Cog): | |||
| 56 | title = youtube_title | 48 | title = youtube_title |
| 57 | 49 | ||
| 58 | embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) | 50 | embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) |
| 59 | button = self._create_button(i, link) | ||
| 60 | view.add_item(button) | ||
| 61 | 51 | ||
| 62 | return embed, view | 52 | return embed, view |
| 63 | 53 | ||
| @@ -70,7 +60,7 @@ class Web(commands.Cog): | |||
| 70 | ctx: discord.ApplicationContext, | 60 | ctx: discord.ApplicationContext, |
| 71 | query: str | 61 | query: str |
| 72 | ): | 62 | ): |
| 73 | await ctx.defer(ephemeral=True) | 63 | await ctx.defer() |
| 74 | headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"} | 64 | headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"} |
| 75 | encoded_query = urllib.parse.quote_plus(query) | 65 | encoded_query = urllib.parse.quote_plus(query) |
| 76 | url = f"https://duckduckgo.com/html/?q={encoded_query}" | 66 | url = f"https://duckduckgo.com/html/?q={encoded_query}" |
| @@ -92,7 +82,7 @@ class Web(commands.Cog): | |||
| 92 | return | 82 | return |
| 93 | 83 | ||
| 94 | embed, view = self._process_search_results(links, query) | 84 | embed, view = self._process_search_results(links, query) |
| 95 | await ctx.respond(embed=embed, view=view, ephemeral=True) | 85 | await ctx.respond(embed=embed, view=view) |
| 96 | logger.info("Search results sent") | 86 | logger.info("Search results sent") |
| 97 | 87 | ||
| 98 | 88 | ||
