diff options
Diffstat (limited to '')
| -rw-r--r-- | cogs/ai.py | 78 | ||||
| -rw-r--r-- | cogs/assistant.py | 130 | ||||
| -rw-r--r-- | cogs/compression.py | 306 | ||||
| -rw-r--r-- | cogs/conversion.py | 245 | ||||
| -rw-r--r-- | cogs/cron.py | 403 | ||||
| -rw-r--r-- | cogs/downloader.py | 234 | ||||
| -rw-r--r-- | cogs/math_commands.py | 83 | ||||
| -rw-r--r-- | cogs/music/join.py | 29 | ||||
| -rw-r--r-- | cogs/music/loop.py | 40 | ||||
| -rw-r--r-- | cogs/music/media_processing.py | 135 | ||||
| -rw-r--r-- | cogs/music/play_commands.py | 320 | ||||
| -rw-r--r-- | cogs/music/stop.py | 29 | ||||
| -rw-r--r-- | cogs/qr.py | 154 | ||||
| -rw-r--r-- | cogs/say.py | 28 | ||||
| -rw-r--r-- | cogs/search.py | 73 | ||||
| -rw-r--r-- | cogs/trigger.py | 260 | ||||
| -rw-r--r-- | cogs/tts.py | 111 | ||||
| -rw-r--r-- | cogs/uploader.py | 156 | ||||
| -rw-r--r-- | cogs/utils/database.py | 126 | ||||
| -rw-r--r-- | cogs/utils/file_search.py | 167 | ||||
| -rw-r--r-- | cogs/utils/settings.py | 114 | ||||
| -rw-r--r-- | cogs/utils/temp.py | 18 | ||||
| -rw-r--r-- | cogs/utils/url_helper.py | 21 | ||||
| -rw-r--r-- | cogs/vclogs.py | 316 | ||||
| -rw-r--r-- | cogs/voice.py | 444 | ||||
| -rw-r--r-- | cogs/web.py | 90 |
26 files changed, 0 insertions, 4110 deletions
diff --git a/cogs/ai.py b/cogs/ai.py deleted file mode 100644 index ef2ba7c..0000000 --- a/cogs/ai.py +++ /dev/null | |||
| @@ -1,78 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import discord | ||
| 8 | from discord.ext import commands | ||
| 9 | import logging | ||
| 10 | import httpx | ||
| 11 | from gtts import gTTS | ||
| 12 | import io | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | class AI(commands.Cog): | ||
| 17 | def __init__(self, bot): | ||
| 18 | self.bot = bot | ||
| 19 | |||
| 20 | @commands.slash_command( | ||
| 21 | name="senator", | ||
| 22 | description="SEENATOR", | ||
| 23 | ) | ||
| 24 | async def senator( | ||
| 25 | self, | ||
| 26 | ctx: discord.ApplicationContext, | ||
| 27 | text: str = discord.Option(str, "message", required=True), | ||
| 28 | tts: str = discord.Option(bool, "face?", default=False) | ||
| 29 | ): | ||
| 30 | await ctx.defer() | ||
| 31 | |||
| 32 | |||
| 33 | url = "https://openrouter.ai/api/v1/chat/completions" | ||
| 34 | headers = { | ||
| 35 | "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", | ||
| 36 | "Content-Type": "application/json", | ||
| 37 | } | ||
| 38 | payload = { | ||
| 39 | "model": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", | ||
| 40 | "messages": [ | ||
| 41 | { | ||
| 42 | "role": "system", | ||
| 43 | "content": "You are Senator Steaven Sperma Armstrong from Metal Gear Rising Vengeance. \ | ||
| 44 | You are racist and you hate other minorities. Respond in plain text, no markdown tricks. \ | ||
| 45 | If the message is in Polish, respond in Polish. Respond in 100 words." | ||
| 46 | }, | ||
| 47 | { | ||
| 48 | "role": "user", | ||
| 49 | "content": text | ||
| 50 | } | ||
| 51 | ], | ||
| 52 | |||
| 53 | } | ||
| 54 | async with httpx.AsyncClient() as client: | ||
| 55 | response = await client.post(url=url, headers=headers, json=payload) | ||
| 56 | response_data = response.json() | ||
| 57 | logger.info(response_data) | ||
| 58 | if not response_data["choices"]: | ||
| 59 | await ctx.send_followup("bot siem zesroł") | ||
| 60 | else: | ||
| 61 | response_text = response_data["choices"][0]["message"]["content"] | ||
| 62 | if tts: | ||
| 63 | tts_obj = gTTS(text=response_text, lang='pl') | ||
| 64 | tts_buffer = io.BytesIO() | ||
| 65 | tts_obj.write_to_fp(tts_buffer) | ||
| 66 | tts_buffer.seek(0) | ||
| 67 | from cogs.voice import get_voice_manager | ||
| 68 | vm = get_voice_manager(self.bot) | ||
| 69 | channel = ctx.author.voice.channel | ||
| 70 | await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False) | ||
| 71 | |||
| 72 | else: | ||
| 73 | await ctx.send_followup(response_text) | ||
| 74 | |||
| 75 | |||
| 76 | |||
| 77 | def setup(bot): | ||
| 78 | bot.add_cog(AI(bot)) | ||
diff --git a/cogs/assistant.py b/cogs/assistant.py deleted file mode 100644 index fed7825..0000000 --- a/cogs/assistant.py +++ /dev/null | |||
| @@ -1,130 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import discord | ||
| 7 | from discord.ext import commands | ||
| 8 | import httpx | ||
| 9 | import logging | ||
| 10 | import os | ||
| 11 | |||
| 12 | logger = logging.getLogger(__name__) | ||
| 13 | |||
| 14 | class Jarvis(commands.Cog): | ||
| 15 | def __init__(self, bot): | ||
| 16 | self.bot = bot | ||
| 17 | self.keyword = os.getenv("ASSISTANT_KEYWORD", "jarvis").lower() | ||
| 18 | |||
| 19 | @commands.Cog.listener() | ||
| 20 | async def on_message(self, message): | ||
| 21 | if message.author == self.bot.user: | ||
| 22 | return | ||
| 23 | |||
| 24 | logger.info(message.content) | ||
| 25 | |||
| 26 | if message.mentions: | ||
| 27 | if message.mentions[0].id == self.bot.user.id: | ||
| 28 | await self.assistant(message) | ||
| 29 | |||
| 30 | elif self.keyword in message.content.lower(): | ||
| 31 | await self.assistant(message) | ||
| 32 | |||
| 33 | @staticmethod | ||
| 34 | async def assistant(message): | ||
| 35 | headers = { | ||
| 36 | "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", | ||
| 37 | "Content-Type": "application/json" | ||
| 38 | } | ||
| 39 | |||
| 40 | chain = await Jarvis._build_message_chain(message) | ||
| 41 | messages = Jarvis._build_messages(message, chain) | ||
| 42 | payload = Jarvis._build_payload(messages) | ||
| 43 | |||
| 44 | response_data = await Jarvis._safe_api_call(payload, headers) | ||
| 45 | if response_data is None: | ||
| 46 | await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.") | ||
| 47 | return | ||
| 48 | |||
| 49 | response_message = Jarvis._parse_response(response_data) | ||
| 50 | |||
| 51 | channel = message.channel | ||
| 52 | await message.reply(response_message) | ||
| 53 | |||
| 54 | @staticmethod | ||
| 55 | async def _build_message_chain(message): | ||
| 56 | chain = [] | ||
| 57 | current = message | ||
| 58 | max_depth = 5 | ||
| 59 | |||
| 60 | while current and len(chain) < max_depth: | ||
| 61 | chain.append(current) | ||
| 62 | if not current.reference: | ||
| 63 | break | ||
| 64 | try: | ||
| 65 | if current.reference.resolved: | ||
| 66 | current = current.reference.resolved | ||
| 67 | else: | ||
| 68 | current = await current.channel.fetch_message(current.reference.message_id) | ||
| 69 | except discord.NotFound: | ||
| 70 | break | ||
| 71 | |||
| 72 | return list(reversed(chain)) | ||
| 73 | |||
| 74 | @staticmethod | ||
| 75 | def _build_messages(message, chain=None): | ||
| 76 | messages = [{ | ||
| 77 | "role": "system", | ||
| 78 | "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis." | ||
| 79 | "Twoim zadaniem jest asystowanie użytkownika" | ||
| 80 | "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" | ||
| 81 | "nie pytaj sie użytkownika czy chce abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" | ||
| 82 | "Nie pozostawiaj wiadomości pustej oraz nie dawaj swojej wiadomości w pole myślenia" | ||
| 83 | }] | ||
| 84 | |||
| 85 | if chain is None: | ||
| 86 | chain = [message] | ||
| 87 | |||
| 88 | for msg in chain: | ||
| 89 | messages.append({ | ||
| 90 | "role": "user", | ||
| 91 | "content": f"USER: {msg.author.display_name}\nMESSAGE: {msg.content}" | ||
| 92 | }) | ||
| 93 | |||
| 94 | return messages | ||
| 95 | |||
| 96 | @staticmethod | ||
| 97 | def _build_payload(messages): | ||
| 98 | return { | ||
| 99 | "model": os.getenv("OPENROUTER_MODEL"), | ||
| 100 | "messages": messages, | ||
| 101 | "plugins": [{"id": "response-healing"}] | ||
| 102 | } | ||
| 103 | |||
| 104 | @staticmethod | ||
| 105 | async def _make_api_request(payload, headers): | ||
| 106 | async with httpx.AsyncClient(timeout=30.0) as client: | ||
| 107 | url = "https://openrouter.ai/api/v1/chat/completions" | ||
| 108 | response = await client.post(url=url, headers=headers, json=payload) | ||
| 109 | response_data = response.json() | ||
| 110 | logger.info(response_data) | ||
| 111 | return response_data | ||
| 112 | |||
| 113 | @staticmethod | ||
| 114 | async def _safe_api_call(payload, headers): | ||
| 115 | try: | ||
| 116 | return await Jarvis._make_api_request(payload, headers) | ||
| 117 | except httpx.HTTPError as e: | ||
| 118 | logger.error(f"API request failed: {e}") | ||
| 119 | return None | ||
| 120 | |||
| 121 | @staticmethod | ||
| 122 | def _parse_response(response_data): | ||
| 123 | content = response_data['choices'][0]['message']['content'] | ||
| 124 | |||
| 125 | logger.info(content) | ||
| 126 | |||
| 127 | return content | ||
| 128 | |||
| 129 | def setup(bot): | ||
| 130 | bot.add_cog(Jarvis(bot)) | ||
diff --git a/cogs/compression.py b/cogs/compression.py deleted file mode 100644 index 71348dc..0000000 --- a/cogs/compression.py +++ /dev/null | |||
| @@ -1,306 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import mimetypes | ||
| 7 | import os | ||
| 8 | import asyncio | ||
| 9 | import aiofiles | ||
| 10 | import requests | ||
| 11 | import urllib.parse | ||
| 12 | from typing import List, Optional | ||
| 13 | from os import PathLike | ||
| 14 | import discord | ||
| 15 | from discord.ext import commands | ||
| 16 | from discord import AutocompleteContext | ||
| 17 | from cogs.utils import file_search as search_utils | ||
| 18 | import shlex | ||
| 19 | import logging | ||
| 20 | |||
| 21 | logger = logging.getLogger(__name__) | ||
| 22 | |||
| 23 | def filetype(filename): | ||
| 24 | mime_type, _ = mimetypes.guess_type(filename) | ||
| 25 | if mime_type: | ||
| 26 | if mime_type.startswith('video/'): | ||
| 27 | return "Video" | ||
| 28 | elif mime_type.startswith('audio/'): | ||
| 29 | return "Audio" | ||
| 30 | elif mime_type.startswith('image/'): | ||
| 31 | return "Image" | ||
| 32 | return "Unknown" | ||
| 33 | |||
| 34 | class Compression(commands.Cog): | ||
| 35 | def __init__(self, bot): | ||
| 36 | from cogs.utils.temp import get_temp_dir | ||
| 37 | from cogs.utils.settings import get_baseurl | ||
| 38 | self.bot = bot | ||
| 39 | self.temp_dir = get_temp_dir() | ||
| 40 | self._cached_urls = {} | ||
| 41 | self._baseurl = get_baseurl() | ||
| 42 | |||
| 43 | async def _file_autocomplete(self, ctx: AutocompleteContext): | ||
| 44 | query = ctx.value.lower() if ctx.value else "" | ||
| 45 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 46 | |||
| 47 | user_id = ctx.interaction.user.id | ||
| 48 | self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 49 | |||
| 50 | choices = [] | ||
| 51 | for url in results[:25]: | ||
| 52 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 53 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 54 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 55 | return choices | ||
| 56 | |||
| 57 | async def _download_from_url(self, url: str) -> Optional[str]: | ||
| 58 | """Download file from copyparty URL and return local path""" | ||
| 59 | try: | ||
| 60 | response = requests.get(url) | ||
| 61 | if response.status_code != 200: | ||
| 62 | return None | ||
| 63 | |||
| 64 | filename = url.split('/')[-1].split('?')[0] | ||
| 65 | filename = urllib.parse.unquote(filename) | ||
| 66 | file_path = os.path.join(self.temp_dir, filename) | ||
| 67 | |||
| 68 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 69 | await f.write(response.content) | ||
| 70 | return file_path | ||
| 71 | except Exception as e: | ||
| 72 | logger.error(f"Error downloading from {url}: {e}") | ||
| 73 | return None | ||
| 74 | |||
| 75 | async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: | ||
| 76 | """Upload file to copyparty and return download URL""" | ||
| 77 | try: | ||
| 78 | async with aiofiles.open(file_path, 'rb') as f: | ||
| 79 | file_content = await f.read() | ||
| 80 | |||
| 81 | files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} | ||
| 82 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | ||
| 83 | params = {'j': '1', 'ck': 'b2'} | ||
| 84 | |||
| 85 | response = requests.post(self._baseurl, files=files, headers=headers, params=params) | ||
| 86 | |||
| 87 | if response.status_code in [200, 201]: | ||
| 88 | result = response.json() | ||
| 89 | return result.get('fileurl') | ||
| 90 | except Exception as e: | ||
| 91 | logger.error(f"Upload error: {e}") | ||
| 92 | return None | ||
| 93 | |||
| 94 | def _process_video(self, file_path: str, output_path: str) -> str: | ||
| 95 | """Process video compression""" | ||
| 96 | command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:v libx264 -crf 51 -b:v 100 -vf 'fps=10,scale=trunc(iw*0.5/2)*2:trunc(ih*0.5/2)*2' -preset ultrafast -c:a libopus -b:a 500 {shlex.quote(output_path)}" | ||
| 97 | return command | ||
| 98 | |||
| 99 | def _process_audio(self, file_path: str, output_path: str) -> str: | ||
| 100 | """Process audio compression""" | ||
| 101 | if output_path.lower().endswith('.mp3'): | ||
| 102 | output_path = os.path.splitext(output_path)[0] + os.path.splitext(output_path)[1].replace('.mp3', '') | ||
| 103 | if not output_path.lower().endswith('.ogg'): | ||
| 104 | output_path = os.path.splitext(output_path)[0] + '.ogg' | ||
| 105 | command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:a libopus -b:a 500 -ar 12000 -ac 1 {shlex.quote(output_path)}" | ||
| 106 | return command | ||
| 107 | |||
| 108 | def _process_image(self, file_path: str, output_path: str) -> tuple[str, str]: | ||
| 109 | """Process image compression""" | ||
| 110 | ext = os.path.splitext(file_path)[1].lower() | ||
| 111 | if ext in [".jpg", ".jpeg"]: | ||
| 112 | command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" | ||
| 113 | elif ext == ".gif": | ||
| 114 | command = f"magick {shlex.quote(file_path)} -coalesce -resize 30% -colors 32 -strip -layers Optimize {shlex.quote(output_path)}" | ||
| 115 | else: | ||
| 116 | output_path = os.path.splitext(output_path)[0] + ".jpg" | ||
| 117 | command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" | ||
| 118 | return command, output_path | ||
| 119 | |||
| 120 | async def _execute_compression(self, command: str) -> bool: | ||
| 121 | """Execute compression command""" | ||
| 122 | proc = await asyncio.create_subprocess_exec( | ||
| 123 | *shlex.split(command), | ||
| 124 | stdout=asyncio.subprocess.PIPE, | ||
| 125 | stderr=asyncio.subprocess.PIPE | ||
| 126 | ) | ||
| 127 | await proc.wait() | ||
| 128 | return proc.returncode == 0 | ||
| 129 | |||
| 130 | async def _compress_attachment(self, attachment: discord.Attachment) -> Optional[discord.File]: | ||
| 131 | """Compress a single attachment and return the compressed file""" | ||
| 132 | try: | ||
| 133 | file_path: str = os.path.join(self.temp_dir, attachment.filename) | ||
| 134 | output_path = os.path.join(self.temp_dir, f"better_{attachment.filename}") | ||
| 135 | |||
| 136 | # Save the original file | ||
| 137 | await attachment.save(file_path) | ||
| 138 | |||
| 139 | # Process the file | ||
| 140 | file_type = filetype(file_path) | ||
| 141 | logger.info(f"Compression started for: {file_type}") | ||
| 142 | |||
| 143 | command = self._get_compression_command(file_type, file_path, output_path) | ||
| 144 | if command is None: | ||
| 145 | logger.info(f"Unsupported file type: {file_type}") | ||
| 146 | os.remove(file_path) | ||
| 147 | return None | ||
| 148 | |||
| 149 | # Update output_path if it was modified | ||
| 150 | if file_type == "Image": | ||
| 151 | command, output_path = command | ||
| 152 | elif file_type == "Audio": | ||
| 153 | # output_path is already modified in _process_audio | ||
| 154 | pass | ||
| 155 | |||
| 156 | # Run the compression | ||
| 157 | success = await self._execute_compression(command) | ||
| 158 | |||
| 159 | if success and os.path.exists(output_path): | ||
| 160 | logger.info(f"Successfully compressed: {attachment.filename}") | ||
| 161 | return discord.File(output_path, filename=f"compressed_{attachment.filename}") | ||
| 162 | return None | ||
| 163 | |||
| 164 | except Exception as e: | ||
| 165 | logger.error(f"Error processing {attachment.filename}: {str(e)}") | ||
| 166 | return None | ||
| 167 | |||
| 168 | finally: | ||
| 169 | # Clean up temporary files | ||
| 170 | if 'file_path' in locals() and os.path.exists(file_path): | ||
| 171 | os.remove(file_path) | ||
| 172 | |||
| 173 | def _get_compression_command(self, file_type: str, file_path: str, output_path: str): | ||
| 174 | """Get the appropriate compression command based on file type""" | ||
| 175 | if file_type == "Video": | ||
| 176 | return self._process_video(file_path, output_path) | ||
| 177 | elif file_type == "Audio": | ||
| 178 | return self._process_audio(file_path, output_path) | ||
| 179 | elif file_type == "Image": | ||
| 180 | return self._process_image(file_path, output_path) | ||
| 181 | else: | ||
| 182 | return None | ||
| 183 | |||
| 184 | async def _compress_attachments(self, attachments: List[discord.Attachment]) -> List[discord.File]: | ||
| 185 | """Compress multiple attachments and return list of compressed files""" | ||
| 186 | compressed_files = [] | ||
| 187 | for attachment in attachments: | ||
| 188 | compressed_file = await self._compress_attachment(attachment) | ||
| 189 | if compressed_file: | ||
| 190 | compressed_files.append(compressed_file) | ||
| 191 | return compressed_files | ||
| 192 | |||
| 193 | @commands.message_command( | ||
| 194 | name="kompresja", | ||
| 195 | description="skompresuj wideło albo dźwienk :)", | ||
| 196 | ) | ||
| 197 | async def message_compression(self, ctx, message: discord.Message): | ||
| 198 | if not message.attachments: | ||
| 199 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) | ||
| 200 | return | ||
| 201 | |||
| 202 | await ctx.defer() | ||
| 203 | compressed_files = await self._compress_attachments(message.attachments) | ||
| 204 | |||
| 205 | # Send all compressed files in one message | ||
| 206 | if compressed_files: | ||
| 207 | await ctx.respond( | ||
| 208 | f"Skompresowano {len(compressed_files)} z {len(message.attachments)} plików:", | ||
| 209 | files=compressed_files | ||
| 210 | ) | ||
| 211 | |||
| 212 | # Clean up compressed files | ||
| 213 | for file in compressed_files: | ||
| 214 | if os.path.exists(file.fp.name): # type: ignore[attr-defined] | ||
| 215 | os.remove(file.fp.name) # type: ignore[attr-defined] | ||
| 216 | else: | ||
| 217 | await ctx.respond("Nie udało się skompresować żadnych plików.", ephemeral=True) | ||
| 218 | |||
| 219 | @commands.slash_command( | ||
| 220 | name="kompresja", | ||
| 221 | description="skompresuj wideło albo dźwienk :)", | ||
| 222 | ) | ||
| 223 | @discord.option( | ||
| 224 | name="plik", | ||
| 225 | description="plik z copyparty", | ||
| 226 | required=False, | ||
| 227 | autocomplete=_file_autocomplete | ||
| 228 | ) | ||
| 229 | @discord.option( | ||
| 230 | name="zalacznik", | ||
| 231 | description="plik z discorda", | ||
| 232 | required=False, | ||
| 233 | type=discord.Attachment | ||
| 234 | ) | ||
| 235 | @discord.option( | ||
| 236 | name="serwer?", | ||
| 237 | description="wysłać plik na serwer?", | ||
| 238 | required=False, | ||
| 239 | default=False, | ||
| 240 | type=bool | ||
| 241 | ) | ||
| 242 | async def compression(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server = False): | ||
| 243 | await ctx.defer() | ||
| 244 | |||
| 245 | file_path = None | ||
| 246 | original_filename = None | ||
| 247 | |||
| 248 | if plik: | ||
| 249 | url = self._cached_urls.get(ctx.author.id, {}).get(plik) | ||
| 250 | if not url: | ||
| 251 | await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) | ||
| 252 | return | ||
| 253 | file_path = await self._download_from_url(url) | ||
| 254 | original_filename = plik | ||
| 255 | elif zalacznik: | ||
| 256 | file_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 257 | original_filename = zalacznik.filename | ||
| 258 | await zalacznik.save(file_path) | ||
| 259 | else: | ||
| 260 | await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) | ||
| 261 | return | ||
| 262 | |||
| 263 | if not file_path or not os.path.exists(file_path): | ||
| 264 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 265 | return | ||
| 266 | |||
| 267 | try: | ||
| 268 | file_type = filetype(file_path) | ||
| 269 | output_path = os.path.join(self.temp_dir, f"better_{original_filename}") | ||
| 270 | |||
| 271 | command = self._get_compression_command(file_type, file_path, output_path) | ||
| 272 | if command is None: | ||
| 273 | await ctx.respond("Nieobsługiwany typ pliku", ephemeral=True) | ||
| 274 | return | ||
| 275 | |||
| 276 | if file_type == "Image": | ||
| 277 | command, output_path = command | ||
| 278 | elif file_type == "Audio": | ||
| 279 | # Audio output path remains unchanged - already processed in _process_audio | ||
| 280 | pass | ||
| 281 | |||
| 282 | success = await self._execute_compression(command) | ||
| 283 | |||
| 284 | if not success or not os.path.exists(output_path): | ||
| 285 | await ctx.respond("Kompresja nie powiodła się", ephemeral=True) | ||
| 286 | return | ||
| 287 | |||
| 288 | file_size = os.path.getsize(output_path) | ||
| 289 | if file_size > 1024 * 1024 * 10 or server: | ||
| 290 | dl_url = await self._upload_to_copyparty(output_path) | ||
| 291 | if dl_url: | ||
| 292 | await ctx.respond(f"Skompresowano\n{dl_url}") | ||
| 293 | else: | ||
| 294 | await ctx.respond("Nie udało wysłać na serwer", ephemeral=True) | ||
| 295 | else: | ||
| 296 | await ctx.respond(file=discord.File(output_path, filename=f"compressed_{original_filename}")) | ||
| 297 | |||
| 298 | finally: | ||
| 299 | if file_path and os.path.exists(file_path): | ||
| 300 | os.remove(file_path) | ||
| 301 | if 'output_path' in locals() and os.path.exists(output_path): | ||
| 302 | os.remove(output_path) | ||
| 303 | |||
| 304 | |||
| 305 | def setup(bot): | ||
| 306 | bot.add_cog(Compression(bot)) | ||
diff --git a/cogs/conversion.py b/cogs/conversion.py deleted file mode 100644 index f9e4c84..0000000 --- a/cogs/conversion.py +++ /dev/null | |||
| @@ -1,245 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import discord | ||
| 7 | import requests | ||
| 8 | from discord.ext import commands | ||
| 9 | import logging | ||
| 10 | import os | ||
| 11 | import asyncio | ||
| 12 | import aiofiles | ||
| 13 | import mimetypes | ||
| 14 | import subprocess | ||
| 15 | import urllib.parse | ||
| 16 | import shlex | ||
| 17 | from typing import Optional | ||
| 18 | from discord import AutocompleteContext | ||
| 19 | from cogs.utils import file_search as search_utils | ||
| 20 | |||
| 21 | logger = logging.getLogger(__name__) | ||
| 22 | |||
| 23 | def filetype(filename): | ||
| 24 | mime_type, _ = mimetypes.guess_type(filename) | ||
| 25 | if mime_type: | ||
| 26 | if mime_type.startswith('video/'): | ||
| 27 | return "Video" | ||
| 28 | elif mime_type.startswith('audio/'): | ||
| 29 | return "Audio" | ||
| 30 | elif mime_type.startswith('image/'): | ||
| 31 | return "Image" | ||
| 32 | return "Unknown" | ||
| 33 | |||
| 34 | class Conversion(commands.Cog): | ||
| 35 | def __init__(self, bot): | ||
| 36 | self.bot = bot | ||
| 37 | from cogs.utils.temp import get_temp_dir | ||
| 38 | from cogs.utils.settings import get_baseurl, get_uploadurl | ||
| 39 | self.temp_dir = get_temp_dir() | ||
| 40 | self._cached_urls = {} | ||
| 41 | self._baseurl = get_baseurl() | ||
| 42 | self._uploadurl = get_uploadurl() | ||
| 43 | |||
| 44 | async def _file_autocomplete(self, ctx: AutocompleteContext): | ||
| 45 | query = ctx.value.lower() if ctx.value else "" | ||
| 46 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 47 | |||
| 48 | user_id = ctx.interaction.user.id | ||
| 49 | self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 50 | |||
| 51 | choices = [] | ||
| 52 | for url in results[:25]: | ||
| 53 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 54 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 55 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 56 | return choices | ||
| 57 | |||
| 58 | async def _download_from_url(self, url: str) -> Optional[str]: | ||
| 59 | """Download file from copyparty URL and return local path""" | ||
| 60 | try: | ||
| 61 | response = requests.get(url) | ||
| 62 | if response.status_code != 200: | ||
| 63 | return None | ||
| 64 | |||
| 65 | filename = url.split('/')[-1].split('?')[0] | ||
| 66 | filename = urllib.parse.unquote(filename) | ||
| 67 | file_path = os.path.join(self.temp_dir, filename) | ||
| 68 | |||
| 69 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 70 | await f.write(response.content) | ||
| 71 | return file_path | ||
| 72 | except Exception as e: | ||
| 73 | logger.error(f"Error downloading from {url}: {e}") | ||
| 74 | return None | ||
| 75 | |||
| 76 | async def _setup_file_paths(self, plik, output_format): | ||
| 77 | """Setup input and output file paths""" | ||
| 78 | from cogs.utils.temp import get_temp_dir | ||
| 79 | |||
| 80 | temp_dir = get_temp_dir() | ||
| 81 | file_path = os.path.join(temp_dir, plik.filename) | ||
| 82 | filename_noext = os.path.splitext(plik.filename)[0] | ||
| 83 | await plik.save(file_path) | ||
| 84 | |||
| 85 | input_path = file_path | ||
| 86 | output_path = os.path.join(temp_dir, f"{filename_noext}.{output_format}") | ||
| 87 | return input_path, output_path | ||
| 88 | |||
| 89 | def _build_conversion_command(self, file_type, input_path, output_path, args): | ||
| 90 | """Build the appropriate conversion command based on file type""" | ||
| 91 | if file_type in ["Video", "Audio"]: | ||
| 92 | return f"ffmpeg -i {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" | ||
| 93 | elif file_type == "Image": | ||
| 94 | return f"magick {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" | ||
| 95 | else: | ||
| 96 | return None | ||
| 97 | |||
| 98 | async def _upload_to_copyparty(self, output_path): | ||
| 99 | """Upload file to copyparty and return download URL""" | ||
| 100 | try: | ||
| 101 | async with aiofiles.open(output_path, 'rb') as f: | ||
| 102 | file_content = await f.read() | ||
| 103 | |||
| 104 | files = { | ||
| 105 | 'f': (os.path.basename(output_path), file_content, 'application/octet-stream') | ||
| 106 | } | ||
| 107 | headers = { | ||
| 108 | 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}' | ||
| 109 | } | ||
| 110 | params = { | ||
| 111 | 'j': '1', | ||
| 112 | 'ck': 'b2' | ||
| 113 | } | ||
| 114 | |||
| 115 | response = requests.post(self._uploadurl, files=files, headers=headers, params=params) | ||
| 116 | |||
| 117 | if response.status_code in [200, 201]: | ||
| 118 | result = response.json() | ||
| 119 | if 'fileurl' in result: | ||
| 120 | return result['fileurl'] | ||
| 121 | else: | ||
| 122 | raise ValueError(f"Server response missing fileurl: {result}") | ||
| 123 | else: | ||
| 124 | error_msg = response.text if response.text else 'Unknown error' | ||
| 125 | raise requests.RequestException(f"HTTP {response.status_code}: {error_msg}") | ||
| 126 | |||
| 127 | except Exception as e: | ||
| 128 | logger.error(f"copyparty upload error: {e}", exc_info=True) | ||
| 129 | raise | ||
| 130 | |||
| 131 | async def _handle_file_output(self, ctx, output_path): | ||
| 132 | """Handle file output based on size""" | ||
| 133 | if os.path.getsize(output_path) > 1024*1024*10: | ||
| 134 | try: | ||
| 135 | dl_url = await self._upload_to_copyparty(output_path) | ||
| 136 | await ctx.followup.send(f"wysyłon\n{dl_url}\n") | ||
| 137 | except Exception as upload_error: | ||
| 138 | await ctx.followup.send(f"copyparty mnie nienawidzi :cccc\n {str(upload_error)}") | ||
| 139 | else: | ||
| 140 | await ctx.respond(file=discord.File(output_path)) | ||
| 141 | |||
| 142 | def _cleanup_files(self, *file_paths): | ||
| 143 | """Clean up temporary files""" | ||
| 144 | for path in file_paths: | ||
| 145 | if os.path.exists(path): | ||
| 146 | os.remove(path) | ||
| 147 | |||
| 148 | @commands.slash_command(name="konwersja", description="konwertuj plik na inny format") | ||
| 149 | @discord.option( | ||
| 150 | name="plik", | ||
| 151 | description="plik z copyparty", | ||
| 152 | required=False, | ||
| 153 | autocomplete=_file_autocomplete | ||
| 154 | ) | ||
| 155 | @discord.option( | ||
| 156 | name="zalacznik", | ||
| 157 | description="plik z discorda", | ||
| 158 | required=False, | ||
| 159 | type=discord.Attachment | ||
| 160 | ) | ||
| 161 | @discord.option( | ||
| 162 | name="serwer?", | ||
| 163 | description="wysłać plik na serwer?", | ||
| 164 | required=False, | ||
| 165 | type=bool, | ||
| 166 | default=False | ||
| 167 | ) | ||
| 168 | @discord.option( | ||
| 169 | name="format", | ||
| 170 | description="format konwersji", | ||
| 171 | required=True, | ||
| 172 | type=str | ||
| 173 | ) | ||
| 174 | async def convert(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server: bool = False, output_format: str = None): | ||
| 175 | await ctx.defer() | ||
| 176 | |||
| 177 | input_path = None | ||
| 178 | original_filename = None | ||
| 179 | |||
| 180 | try: | ||
| 181 | if plik: | ||
| 182 | url = self._cached_urls.get(ctx.author.id, {}).get(plik) | ||
| 183 | if not url: | ||
| 184 | await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) | ||
| 185 | return | ||
| 186 | input_path = await self._download_from_url(url) | ||
| 187 | original_filename = plik | ||
| 188 | elif zalacznik: | ||
| 189 | input_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 190 | original_filename = zalacznik.filename | ||
| 191 | await zalacznik.save(input_path) | ||
| 192 | else: | ||
| 193 | await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) | ||
| 194 | return | ||
| 195 | |||
| 196 | if not input_path or not os.path.exists(input_path): | ||
| 197 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 198 | return | ||
| 199 | |||
| 200 | output_path = os.path.join(self.temp_dir, f"{os.path.splitext(original_filename)[0]}.{output_format}") | ||
| 201 | |||
| 202 | file_type = filetype(input_path) | ||
| 203 | logger.info(f"Conversion started for: {file_type}") | ||
| 204 | |||
| 205 | command = self._build_conversion_command(file_type, input_path, output_path, "") | ||
| 206 | logger.info(f"Running: {command}") | ||
| 207 | if not command: | ||
| 208 | await ctx.respond("Plik jest nieobsługiwany", ephemeral=True) | ||
| 209 | return | ||
| 210 | |||
| 211 | proc = await asyncio.create_subprocess_exec( | ||
| 212 | *shlex.split(command), | ||
| 213 | stdout=asyncio.subprocess.PIPE, | ||
| 214 | stderr=asyncio.subprocess.PIPE | ||
| 215 | ) | ||
| 216 | stdout, stderr = await proc.communicate() | ||
| 217 | |||
| 218 | if proc.returncode != 0: | ||
| 219 | logger.error(f"FFmpeg error: {stderr.decode() if stderr else 'unknown'}") | ||
| 220 | await ctx.respond(f"Błąd konwersji: {stderr.decode() if stderr else 'unknown'}", ephemeral=True) | ||
| 221 | return | ||
| 222 | |||
| 223 | if os.path.getsize(output_path) > 1024 * 1024 * 10 or server: | ||
| 224 | dl_url = await self._upload_to_copyparty(output_path) | ||
| 225 | if dl_url: | ||
| 226 | await ctx.respond(f"Skonwertowano\n{dl_url}") | ||
| 227 | else: | ||
| 228 | await ctx.respond("Nie udało się wysłać na serwer", ephemeral=True) | ||
| 229 | else: | ||
| 230 | await ctx.respond(file=discord.File(output_path, filename=f"{original_filename}")) | ||
| 231 | |||
| 232 | logger.info(f"Conversion completed for: {file_type}") | ||
| 233 | |||
| 234 | except subprocess.CalledProcessError as e: | ||
| 235 | await ctx.respond(f"Conversion failed: {str(e)}", ephemeral=True) | ||
| 236 | except Exception as e: | ||
| 237 | await ctx.respond(f"An error occurred: {str(e)}", ephemeral=True) | ||
| 238 | finally: | ||
| 239 | if input_path and os.path.exists(input_path): | ||
| 240 | os.remove(input_path) | ||
| 241 | if 'output_path' in locals() and os.path.exists(output_path): | ||
| 242 | os.remove(output_path) | ||
| 243 | |||
| 244 | def setup(bot): | ||
| 245 | bot.add_cog(Conversion(bot)) | ||
diff --git a/cogs/cron.py b/cogs/cron.py deleted file mode 100644 index ee88085..0000000 --- a/cogs/cron.py +++ /dev/null | |||
| @@ -1,403 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import asyncio | ||
| 7 | import logging | ||
| 8 | from datetime import datetime | ||
| 9 | from venv import logger | ||
| 10 | |||
| 11 | import discord | ||
| 12 | from croniter import croniter | ||
| 13 | from discord.ext import commands | ||
| 14 | |||
| 15 | from cogs.utils import file_search as search_utils | ||
| 16 | from cogs.voice import get_voice_manager | ||
| 17 | from cogs.utils.database import get_connection, init_database | ||
| 18 | |||
| 19 | logger = logging.getLogger(__name__) | ||
| 20 | |||
| 21 | init_database() | ||
| 22 | |||
| 23 | |||
| 24 | class Cron(commands.Cog): | ||
| 25 | def __init__(self, bot): | ||
| 26 | self.bot = bot | ||
| 27 | self.scheduled_tasks = [] | ||
| 28 | self.load_tasks() | ||
| 29 | self.task = bot.loop.create_task(self.schedule_checker()) | ||
| 30 | |||
| 31 | def load_tasks(self): | ||
| 32 | try: | ||
| 33 | with get_connection() as conn: | ||
| 34 | c = conn.cursor() | ||
| 35 | c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") | ||
| 36 | rows = c.fetchall() | ||
| 37 | |||
| 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") | ||
| 51 | except Exception as e: | ||
| 52 | logger.error(f"Error loading cron jobs: {e}") | ||
| 53 | self.scheduled_tasks = [] | ||
| 54 | |||
| 55 | def save_tasks(self): | ||
| 56 | try: | ||
| 57 | with get_connection() as conn: | ||
| 58 | c = conn.cursor() | ||
| 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() | ||
| 78 | except Exception as e: | ||
| 79 | logger.error(f"Error saving cron jobs: {e}") | ||
| 80 | |||
| 81 | async def schedule_checker(self): | ||
| 82 | """Background task to check and execute scheduled jobs""" | ||
| 83 | await self.bot.wait_until_ready() | ||
| 84 | while not self.bot.is_closed(): | ||
| 85 | try: | ||
| 86 | now = datetime.now() | ||
| 87 | for task in self.scheduled_tasks: | ||
| 88 | if not task.get("active", True): | ||
| 89 | continue | ||
| 90 | if self.should_run_task(task, now): | ||
| 91 | await self.execute_task(task) | ||
| 92 | await asyncio.sleep(30) | ||
| 93 | except Exception as e: | ||
| 94 | logger.error(f"Error in schedule checker: {e}") | ||
| 95 | await asyncio.sleep(30) | ||
| 96 | |||
| 97 | def should_run_task(self, task, now): | ||
| 98 | """Check if a task should run now based on its cron expression""" | ||
| 99 | try: | ||
| 100 | cron_expr = task["cron_expression"] | ||
| 101 | cron = croniter(cron_expr, now) | ||
| 102 | prev_time = cron.get_prev(datetime) | ||
| 103 | time_diff = abs((now - prev_time).total_seconds()) | ||
| 104 | result = time_diff <= 30 | ||
| 105 | logger.debug( | ||
| 106 | f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}" | ||
| 107 | ) | ||
| 108 | return result | ||
| 109 | except Exception as e: | ||
| 110 | logger.error( | ||
| 111 | f"Error checking cron expression {task.get('cron_expression')}: {e}" | ||
| 112 | ) | ||
| 113 | return False | ||
| 114 | |||
| 115 | async def execute_task(self, task): | ||
| 116 | """Execute a scheduled task""" | ||
| 117 | try: | ||
| 118 | task_type = task["type"] | ||
| 119 | channel_id = task["channel_id"] | ||
| 120 | |||
| 121 | channel = self.bot.get_channel(channel_id) | ||
| 122 | if not channel: | ||
| 123 | logger.warning(f"Channel {channel_id} not found for task ID: {task.get('id')}") | ||
| 124 | return | ||
| 125 | |||
| 126 | logger.info(f"Found channel {channel} (type: {type(channel).__name__})") | ||
| 127 | |||
| 128 | perms = channel.permissions_for(channel.guild.me) | ||
| 129 | logger.debug( | ||
| 130 | f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}" | ||
| 131 | ) | ||
| 132 | |||
| 133 | if task_type in ("zagraj_audio", "audio"): | ||
| 134 | await self.play_audio_task(task, channel) | ||
| 135 | elif task_type in ("wyślij_wiadomość", "send_message", "wiadomość"): | ||
| 136 | await self.send_message_task(task, channel) | ||
| 137 | |||
| 138 | logger.info(f"Executed task {task['id']} of type {task_type}") | ||
| 139 | except Exception as e: | ||
| 140 | logger.error(f"Error executing task {task.get('id')}: {e}") | ||
| 141 | |||
| 142 | async def play_audio_task(self, task, channel): | ||
| 143 | """Play audio task implementation using voice manager""" | ||
| 144 | audio_url = task["audio_filename"] | ||
| 145 | |||
| 146 | if isinstance(channel, discord.VoiceChannel): | ||
| 147 | voice_mgr = get_voice_manager(self.bot) | ||
| 148 | |||
| 149 | try: | ||
| 150 | audio_source = discord.FFmpegPCMAudio(audio_url) | ||
| 151 | success = await voice_mgr.play_source(channel, audio_source) | ||
| 152 | |||
| 153 | if success: | ||
| 154 | logger.info(f"Finished playing audio: {audio_url}") | ||
| 155 | else: | ||
| 156 | raise Exception("Playback failed") | ||
| 157 | |||
| 158 | await voice_mgr.disconnect(channel.guild) | ||
| 159 | except Exception as e: | ||
| 160 | logger.error(f"Error playing audio: {e}") | ||
| 161 | try: | ||
| 162 | await voice_mgr.disconnect(channel.guild) | ||
| 163 | except Exception: | ||
| 164 | pass | ||
| 165 | if channel.guild.system_channel: | ||
| 166 | await channel.guild.system_channel.send( | ||
| 167 | f"Błąd podczas odgrywania audio: {audio_url}" | ||
| 168 | ) | ||
| 169 | else: | ||
| 170 | if hasattr(channel, "send"): | ||
| 171 | await channel.send("Odtwarzanie audio wymaga kanału głosowego") | ||
| 172 | |||
| 173 | async def send_message_task(self, task, channel): | ||
| 174 | """Send message task implementation""" | ||
| 175 | message = task["message"] | ||
| 176 | logger.info(f"Message to send: '{message}' | Channel: {channel.id}") | ||
| 177 | await channel.send(message) | ||
| 178 | logger.debug("Message sent successfully") | ||
| 179 | |||
| 180 | async def audio_autocomplete(self, ctx: discord.AutocompleteContext): | ||
| 181 | """Autocomplete for audio files from file_search""" | ||
| 182 | query = ctx.value.lower() if ctx.value else "" | ||
| 183 | if not query: | ||
| 184 | return "" | ||
| 185 | |||
| 186 | audio_extensions = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "mp4", "mkv"] | ||
| 187 | results = await search_utils.UniversalSearcher.search_copyparty( | ||
| 188 | query, max_results=25, file_extensions=audio_extensions | ||
| 189 | ) | ||
| 190 | |||
| 191 | choices = [] | ||
| 192 | for url in results[:25]: | ||
| 193 | filename = url.split("/")[-1] | ||
| 194 | display_name = filename[:98] + "…" if len(filename) > 100 else filename | ||
| 195 | choices.append(discord.OptionChoice(name=display_name, value=url)) | ||
| 196 | |||
| 197 | return ( | ||
| 198 | choices | ||
| 199 | if choices | ||
| 200 | else [discord.OptionChoice(name="Brak wyników", value="")] | ||
| 201 | ) | ||
| 202 | |||
| 203 | def _get_baseurl(self): | ||
| 204 | """Get base URL from settings""" | ||
| 205 | try: | ||
| 206 | from cogs.utils.settings import get_baseurl | ||
| 207 | |||
| 208 | return get_baseurl() | ||
| 209 | except Exception: | ||
| 210 | return "" | ||
| 211 | |||
| 212 | trigger_group = discord.SlashCommandGroup("cron", "komendy cron") | ||
| 213 | |||
| 214 | @trigger_group.command( | ||
| 215 | name="dodaj_audio", description="Dodaj nowe zadanie cron audio" | ||
| 216 | ) | ||
| 217 | async def cron_add_audio( | ||
| 218 | self, | ||
| 219 | ctx, | ||
| 220 | cron_expression: str = discord.Option( | ||
| 221 | str, | ||
| 222 | "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", | ||
| 223 | required=True, | ||
| 224 | ), | ||
| 225 | channel: discord.VoiceChannel = discord.Option( | ||
| 226 | discord.VoiceChannel, | ||
| 227 | "Kanał głosowy na którym będzie wykonywana akcja", | ||
| 228 | required=True, | ||
| 229 | ), | ||
| 230 | audio_filename: str = discord.Option( | ||
| 231 | str, | ||
| 232 | "Plik audio z serwera copyparty", | ||
| 233 | required=True, | ||
| 234 | autocomplete=audio_autocomplete, | ||
| 235 | ), | ||
| 236 | ): | ||
| 237 | try: | ||
| 238 | croniter(cron_expression) | ||
| 239 | except Exception: | ||
| 240 | await ctx.respond( | ||
| 241 | "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", | ||
| 242 | ephemeral=True, | ||
| 243 | ) | ||
| 244 | return | ||
| 245 | |||
| 246 | task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 | ||
| 247 | |||
| 248 | task = { | ||
| 249 | "id": task_id, | ||
| 250 | "cron_expression": cron_expression, | ||
| 251 | "type": "audio", | ||
| 252 | "channel_id": channel.id, | ||
| 253 | "active": True, | ||
| 254 | } | ||
| 255 | |||
| 256 | task["audio_filename"] = audio_filename | ||
| 257 | |||
| 258 | self.scheduled_tasks.append(task) | ||
| 259 | self.save_tasks() | ||
| 260 | |||
| 261 | await ctx.respond( | ||
| 262 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" | ||
| 263 | ) | ||
| 264 | |||
| 265 | @trigger_group.command( | ||
| 266 | name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" | ||
| 267 | ) | ||
| 268 | async def cron_add_message( | ||
| 269 | self, | ||
| 270 | ctx, | ||
| 271 | cron_expression: str = discord.Option( | ||
| 272 | str, | ||
| 273 | "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", | ||
| 274 | required=True, | ||
| 275 | ), | ||
| 276 | channel: discord.TextChannel = discord.Option( | ||
| 277 | discord.TextChannel, "Kanał na którym będzie wykonana akcja", required=True | ||
| 278 | ), | ||
| 279 | message: str = discord.Option(str, "Wiadomość do wysłania", required=True), | ||
| 280 | ): | ||
| 281 | """Add a new cron job""" | ||
| 282 | |||
| 283 | try: | ||
| 284 | croniter(cron_expression) | ||
| 285 | except Exception: | ||
| 286 | await ctx.respond( | ||
| 287 | "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", | ||
| 288 | ephemeral=True, | ||
| 289 | ) | ||
| 290 | return | ||
| 291 | |||
| 292 | task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 | ||
| 293 | |||
| 294 | task = { | ||
| 295 | "id": task_id, | ||
| 296 | "cron_expression": cron_expression, | ||
| 297 | "type": "wiadomość", | ||
| 298 | "channel_id": channel.id, | ||
| 299 | "active": True, | ||
| 300 | "message": message, | ||
| 301 | } | ||
| 302 | |||
| 303 | self.scheduled_tasks.append(task) | ||
| 304 | self.save_tasks() | ||
| 305 | |||
| 306 | await ctx.respond( | ||
| 307 | f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" | ||
| 308 | ) | ||
| 309 | |||
| 310 | @trigger_group.command( | ||
| 311 | name="pokaż", description="Pokaż wszystkie zadania cron" | ||
| 312 | ) | ||
| 313 | async def cron_list(self, ctx): | ||
| 314 | """List all cron jobs""" | ||
| 315 | if not self.scheduled_tasks: | ||
| 316 | await ctx.respond("Brak zadań cron") | ||
| 317 | return | ||
| 318 | |||
| 319 | embed = discord.Embed(title="Zaplanowane prace cron", color=0x00FF00) | ||
| 320 | |||
| 321 | for task in self.scheduled_tasks: | ||
| 322 | channel = self.bot.get_channel(task["channel_id"]) | ||
| 323 | channel_name = f"#{channel}" | ||
| 324 | status = "AKTYWNA" if task.get("active", True) else "PAUZA" | ||
| 325 | |||
| 326 | if task["type"] in ("zagraj_audio", "audio"): | ||
| 327 | action_desc = f"Zagraj audio: `{task.get('audio_filename', 'N/A')}`" | ||
| 328 | else: | ||
| 329 | msg = task.get("message", "") | ||
| 330 | action_desc = ( | ||
| 331 | f"Wyślij wiadomość: `{msg[:50]}{'...' if len(msg) > 50 else ''}`" | ||
| 332 | ) | ||
| 333 | |||
| 334 | embed.add_field( | ||
| 335 | name=f"Zadanie #{task['id']} ({status})", | ||
| 336 | value=f"**Czas:** `{task['cron_expression']}`\n**Akcja:** {action_desc}\n**Kanał:** {channel_name}", | ||
| 337 | inline=False, | ||
| 338 | ) | ||
| 339 | |||
| 340 | await ctx.respond(embed=embed) | ||
| 341 | |||
| 342 | @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID") | ||
| 343 | async def cron_remove( | ||
| 344 | self, | ||
| 345 | ctx, | ||
| 346 | job_id: int = discord.Option( | ||
| 347 | int, "ID zadania cron do usunięcia", required=True | ||
| 348 | ), | ||
| 349 | ): | ||
| 350 | """Remove a cron job by ID""" | ||
| 351 | for i, task in enumerate(self.scheduled_tasks): | ||
| 352 | if task["id"] == job_id: | ||
| 353 | self.scheduled_tasks.pop(i) | ||
| 354 | self.save_tasks() | ||
| 355 | await ctx.respond(f"Usunięto zadanie cron #{job_id}") | ||
| 356 | return | ||
| 357 | |||
| 358 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | ||
| 359 | |||
| 360 | @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID") | ||
| 361 | async def cron_pause( | ||
| 362 | self, | ||
| 363 | ctx, | ||
| 364 | job_id: int = discord.Option( | ||
| 365 | int, "ID zadania cron do zatrzymania", required=True | ||
| 366 | ), | ||
| 367 | ): | ||
| 368 | """Pause a cron job by ID""" | ||
| 369 | for task in self.scheduled_tasks: | ||
| 370 | if task["id"] == job_id: | ||
| 371 | task["active"] = False | ||
| 372 | self.save_tasks() | ||
| 373 | await ctx.respond(f"Zapauzowano zadanie cron #{job_id}") | ||
| 374 | return | ||
| 375 | |||
| 376 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | ||
| 377 | |||
| 378 | @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID") | ||
| 379 | async def cron_resume( | ||
| 380 | self, | ||
| 381 | ctx, | ||
| 382 | job_id: int = discord.Option( | ||
| 383 | int, "ID zadania cron do wznowienia", required=True | ||
| 384 | ), | ||
| 385 | ): | ||
| 386 | """Resume a paused cron job by ID""" | ||
| 387 | for task in self.scheduled_tasks: | ||
| 388 | if task["id"] == job_id: | ||
| 389 | task["active"] = True | ||
| 390 | self.save_tasks() | ||
| 391 | await ctx.respond(f"Wznowiono zadanie cron z ID #{job_id}") | ||
| 392 | return | ||
| 393 | |||
| 394 | await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") | ||
| 395 | |||
| 396 | def cog_unload(self): | ||
| 397 | """Clean up when cog is unloaded""" | ||
| 398 | if hasattr(self, "task"): | ||
| 399 | self.task.cancel() | ||
| 400 | |||
| 401 | |||
| 402 | def setup(bot): | ||
| 403 | bot.add_cog(Cron(bot)) | ||
diff --git a/cogs/downloader.py b/cogs/downloader.py deleted file mode 100644 index 900bafb..0000000 --- a/cogs/downloader.py +++ /dev/null | |||
| @@ -1,234 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import tempfile | ||
| 8 | import logging | ||
| 9 | import requests | ||
| 10 | import discord | ||
| 11 | import re | ||
| 12 | import yt_dlp | ||
| 13 | from discord.ext import commands | ||
| 14 | import aiofiles | ||
| 15 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 16 | |||
| 17 | logger = logging.getLogger(__name__) | ||
| 18 | |||
| 19 | |||
| 20 | class Downloader(commands.Cog): | ||
| 21 | def __init__(self, bot): | ||
| 22 | self.bot = bot | ||
| 23 | |||
| 24 | URL_PATTERN = re.compile( | ||
| 25 | r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*' | ||
| 26 | ) | ||
| 27 | |||
| 28 | @commands.message_command( | ||
| 29 | name="download", | ||
| 30 | description="Download and convert video/audio from internet" | ||
| 31 | ) | ||
| 32 | async def message_download(self, ctx, message: discord.Message): | ||
| 33 | # check if message has a valid url | ||
| 34 | url = [m.group(0) for m in self.URL_PATTERN.finditer(message.content)] | ||
| 35 | if url: | ||
| 36 | for url in url: | ||
| 37 | await self.download(ctx=ctx, url=url, fformat="mp4") | ||
| 38 | else: | ||
| 39 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) | ||
| 40 | |||
| 41 | @commands.slash_command( | ||
| 42 | name="download", | ||
| 43 | description="Download and convert video/audio from internet" | ||
| 44 | ) | ||
| 45 | async def download( | ||
| 46 | self, | ||
| 47 | ctx: discord.ApplicationContext, | ||
| 48 | url: str = discord.Option(str, description="Link media", required=True), | ||
| 49 | catbox: bool = discord.Option(bool, description="wysłać na serwer??", required=False, default=False), | ||
| 50 | fformat: str = discord.Option(str, description="jaki format? (mp4, mp3, wav, etc.)", required=False, default="mp4"), | ||
| 51 | filename_user: str = discord.Option(str, description="jaka nazwa pliku?", required=False) | ||
| 52 | ): | ||
| 53 | """Download and convert media from a URL to the specified format""" | ||
| 54 | await ctx.defer() | ||
| 55 | # Call the function and get the response | ||
| 56 | response = await self.download_standalone(url=url, catbox=catbox, fformat=fformat, filename_user=filename_user) | ||
| 57 | if isinstance(response, dict): | ||
| 58 | if response["status"] == "success": | ||
| 59 | if response["type"] == "url": | ||
| 60 | await ctx.respond(f"pobioron i wysyłon: {response['content']}") | ||
| 61 | elif response["type"] == "file": | ||
| 62 | file_data = response["content"] | ||
| 63 | await ctx.respond(file=discord.File(file_data)) | ||
| 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) | ||
| 69 | logger.error(f"Error: {response['content']}") | ||
| 70 | if 'details' in response: | ||
| 71 | logger.error(f"Details: {response['details']}") | ||
| 72 | |||
| 73 | @staticmethod | ||
| 74 | async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): | ||
| 75 | try: | ||
| 76 | url = clean_url_for_ytdlp(url) | ||
| 77 | logger.info(f"Downloading {url} as {fformat}") | ||
| 78 | |||
| 79 | with tempfile.TemporaryDirectory(delete=False) as temp_dir: | ||
| 80 | ydl_opts = Downloader._get_ydl_options(filename_user, fformat, temp_dir) | ||
| 81 | |||
| 82 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: | ||
| 83 | ydl.download([url]) | ||
| 84 | |||
| 85 | actual_file, filename = Downloader._find_and_prepare_file(temp_dir, filename_user) | ||
| 86 | |||
| 87 | if Downloader._should_upload_to_server(actual_file, catbox): | ||
| 88 | return await Downloader._upload_to_server(actual_file, filename, temp_dir) | ||
| 89 | else: | ||
| 90 | return Downloader._return_file_response(actual_file, filename, temp_dir) | ||
| 91 | |||
| 92 | except Exception as e: | ||
| 93 | return Downloader._create_error_response("Download failed", str(e), None) | ||
| 94 | |||
| 95 | @staticmethod | ||
| 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] | ||
| 106 | ydl_opts = { | ||
| 107 | 'outtmpl': os.path.join(temp_dir, filename if filename is not None else '%(title).100s.%(ext)s'), | ||
| 108 | 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', | ||
| 109 | 'merge_output_format': 'mp4', | ||
| 110 | 'postprocessors': [], | ||
| 111 | 'remote-components': 'ejs:github', | ||
| 112 | 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}}, | ||
| 113 | } | ||
| 114 | |||
| 115 | if fformat in ['mp3', 'wav', 'm4a']: | ||
| 116 | ydl_opts['postprocessors'].append({ | ||
| 117 | 'key': 'FFmpegExtractAudio', | ||
| 118 | 'preferredcodec': fformat, | ||
| 119 | 'preferredquality': '192', | ||
| 120 | }) | ||
| 121 | elif fformat != 'mp4': | ||
| 122 | ydl_opts['postprocessors'].append({ | ||
| 123 | 'key': 'FFmpegVideoConvertor', | ||
| 124 | 'preferedformat': fformat, | ||
| 125 | }) | ||
| 126 | |||
| 127 | return ydl_opts | ||
| 128 | |||
| 129 | @staticmethod | ||
| 130 | def _find_and_prepare_file(temp_dir: str, filename_user: str) -> tuple: | ||
| 131 | files = [f for f in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, f))] | ||
| 132 | if not files: | ||
| 133 | raise FileNotFoundError("No file was downloaded") | ||
| 134 | |||
| 135 | actual_file = os.path.join(temp_dir, files[0]) | ||
| 136 | extension = actual_file.split('.')[-1] | ||
| 137 | extension = Downloader._sanitize_filename(extension) | ||
| 138 | |||
| 139 | if not filename_user: | ||
| 140 | filename = os.path.basename(actual_file) | ||
| 141 | filename = Downloader._sanitize_filename(filename) | ||
| 142 | if len(filename) > 100: | ||
| 143 | filename = filename[:100] | ||
| 144 | else: | ||
| 145 | filename_user = Downloader._sanitize_filename(filename_user)[:100] | ||
| 146 | filename = filename_user + '.' + extension | ||
| 147 | actual_file = os.path.join(temp_dir, filename) | ||
| 148 | |||
| 149 | if not os.path.exists(actual_file): | ||
| 150 | raise FileNotFoundError(f"Downloaded file not found: {actual_file}") | ||
| 151 | |||
| 152 | return actual_file, filename | ||
| 153 | |||
| 154 | @staticmethod | ||
| 155 | def _should_upload_to_server(actual_file: str, catbox: bool) -> bool: | ||
| 156 | return os.path.getsize(actual_file) > 1024*1024*10 or catbox | ||
| 157 | |||
| 158 | @staticmethod | ||
| 159 | async def _upload_to_server(actual_file: str, filename: str, temp_dir: str) -> dict: | ||
| 160 | try: | ||
| 161 | async with aiofiles.open(actual_file, 'rb') as f: | ||
| 162 | content = await f.read() | ||
| 163 | files = {'f': (filename, content, 'application/octet-stream')} | ||
| 164 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | ||
| 165 | params = {'j': '1', 'ck': 'b2'} | ||
| 166 | base_url = os.getenv("COPYPARTY_URL_UPLOAD") | ||
| 167 | |||
| 168 | response = requests.post(base_url, files=files, headers=headers, params=params) | ||
| 169 | |||
| 170 | if response.status_code in [200, 201]: | ||
| 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 | ) | ||
| 178 | else: | ||
| 179 | return Downloader._create_error_response( | ||
| 180 | f"Server returned {response.status_code}", | ||
| 181 | response.text or 'Unknown error', | ||
| 182 | temp_dir | ||
| 183 | ) | ||
| 184 | except Exception as upload_error: | ||
| 185 | return Downloader._create_error_response("Upload failed", str(upload_error), temp_dir) | ||
| 186 | |||
| 187 | @staticmethod | ||
| 188 | def _handle_upload_success(response: requests.Response, filename: str, temp_dir: str) -> dict: | ||
| 189 | try: | ||
| 190 | result = response.json() | ||
| 191 | if 'fileurl' in result: | ||
| 192 | return { | ||
| 193 | "status": "success", | ||
| 194 | "type": "url", | ||
| 195 | "content": result['fileurl'], | ||
| 196 | "filename": filename, | ||
| 197 | "temp_dir": temp_dir | ||
| 198 | } | ||
| 199 | else: | ||
| 200 | return Downloader._create_error_response( | ||
| 201 | "Invalid response from file server", | ||
| 202 | str(result), | ||
| 203 | temp_dir | ||
| 204 | ) | ||
| 205 | except ValueError: | ||
| 206 | return Downloader._create_error_response( | ||
| 207 | "Failed to parse server response", | ||
| 208 | response.text[:200], | ||
| 209 | temp_dir | ||
| 210 | ) | ||
| 211 | |||
| 212 | @staticmethod | ||
| 213 | def _return_file_response(actual_file: str, filename: str, temp_dir: str) -> dict: | ||
| 214 | return { | ||
| 215 | "status": "success", | ||
| 216 | "type": "file", | ||
| 217 | "content": actual_file, | ||
| 218 | "filename": filename, | ||
| 219 | "temp_dir": temp_dir | ||
| 220 | } | ||
| 221 | |||
| 222 | @staticmethod | ||
| 223 | def _create_error_response(content: str, details: str, temp_dir: str | None) -> dict: | ||
| 224 | return { | ||
| 225 | "status": "error", | ||
| 226 | "type": "error", | ||
| 227 | "content": content, | ||
| 228 | "details": details, | ||
| 229 | "temp_dir": temp_dir | ||
| 230 | } | ||
| 231 | |||
| 232 | |||
| 233 | def setup(bot): | ||
| 234 | bot.add_cog(Downloader(bot)) | ||
diff --git a/cogs/math_commands.py b/cogs/math_commands.py deleted file mode 100644 index e30c92b..0000000 --- a/cogs/math_commands.py +++ /dev/null | |||
| @@ -1,83 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | import asyncio | ||
| 8 | import re | ||
| 9 | |||
| 10 | from discord import ApplicationContext | ||
| 11 | from discord.ext import commands | ||
| 12 | |||
| 13 | logger = logging.getLogger(__name__) | ||
| 14 | |||
| 15 | MATH_SANITIZE_PATTERN = re.compile(r'^[\d\s\+\-\*\/\^\%\(\)\.\,\=\\<\>\!\|\&\~\?\:\'\"\[\]\{\}a-zA-Z]+$') | ||
| 16 | |||
| 17 | |||
| 18 | def sanitize_math_input(equation: str) -> str | None: | ||
| 19 | if not equation or len(equation) > 1000: | ||
| 20 | return None | ||
| 21 | equation = equation.strip() | ||
| 22 | if not equation: | ||
| 23 | return None | ||
| 24 | if not MATH_SANITIZE_PATTERN.match(equation): | ||
| 25 | return None | ||
| 26 | for char in equation: | ||
| 27 | if ord(char) < 32 or ord(char) > 126: | ||
| 28 | return None | ||
| 29 | if equation.count('(') != equation.count(')'): | ||
| 30 | return None | ||
| 31 | if equation.count('[') != equation.count(']'): | ||
| 32 | return None | ||
| 33 | if equation.count('{') != equation.count('}'): | ||
| 34 | return None | ||
| 35 | dangerous_patterns = [ | ||
| 36 | r'eval', r'exec', r'__import__', r'os\.', r'sys\.', | ||
| 37 | r'subprocess', r'open(', r'file', r'shutil', | ||
| 38 | r'lambda:', r'compile', r'dir(', r'vars(', | ||
| 39 | r'\|\|', r'&&', r';', r'`', r'\$', r'\\n', r'\\r', | ||
| 40 | r'\x', r'\0', r'\a', r'\b', r'\f', r'\v', | ||
| 41 | ] | ||
| 42 | for pattern in dangerous_patterns: | ||
| 43 | if re.search(pattern, equation, re.IGNORECASE): | ||
| 44 | return None | ||
| 45 | return equation | ||
| 46 | |||
| 47 | |||
| 48 | class Math(commands.Cog): | ||
| 49 | def __init__(self, bot): | ||
| 50 | self.bot = bot | ||
| 51 | |||
| 52 | @commands.slash_command( | ||
| 53 | name="math", | ||
| 54 | description="wrug szyskih matfisuf" | ||
| 55 | ) | ||
| 56 | async def math(self, ctx: ApplicationContext, equation: str): | ||
| 57 | sanitized = sanitize_math_input(equation) | ||
| 58 | if sanitized is None: | ||
| 59 | await ctx.respond("Invalid equation. Only basic math expressions allowed.", ephemeral=True) | ||
| 60 | logger.warning(f"Math command rejected invalid input: {equation}") | ||
| 61 | return | ||
| 62 | try: | ||
| 63 | math_proc = await asyncio.create_subprocess_exec( | ||
| 64 | "qalc", "-e", sanitized, | ||
| 65 | stdout=asyncio.subprocess.PIPE, | ||
| 66 | stderr=asyncio.subprocess.PIPE | ||
| 67 | ) | ||
| 68 | stdout, stderr = await math_proc.communicate() | ||
| 69 | if math_proc.returncode != 0: | ||
| 70 | error_output = stderr.decode('utf-8', errors='replace').strip() | ||
| 71 | await ctx.respond(f"Calculation error: {error_output}", ephemeral=True) | ||
| 72 | logger.error(f"qalc error: {error_output}") | ||
| 73 | return | ||
| 74 | result = stdout.decode('utf-8', errors='replace').strip() | ||
| 75 | await ctx.respond(f"{sanitized} = {result}") | ||
| 76 | logger.info(f"Math command executed: {sanitized}") | ||
| 77 | except Exception as e: | ||
| 78 | await ctx.respond(f"Error: {str(e)}", ephemeral=True) | ||
| 79 | logger.error(f"Error in math command: {e}", exc_info=True) | ||
| 80 | |||
| 81 | |||
| 82 | def setup(bot): | ||
| 83 | bot.add_cog(Math(bot)) | ||
diff --git a/cogs/music/join.py b/cogs/music/join.py deleted file mode 100644 index d68ca79..0000000 --- a/cogs/music/join.py +++ /dev/null | |||
| @@ -1,29 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | |||
| 8 | from discord.ext import commands | ||
| 9 | from cogs.voice import get_voice_manager | ||
| 10 | |||
| 11 | logger = logging.getLogger(__name__) | ||
| 12 | |||
| 13 | |||
| 14 | class Join(commands.Cog): | ||
| 15 | def __init__(self, bot): | ||
| 16 | self.bot = bot | ||
| 17 | |||
| 18 | @commands.slash_command(name="join", description="dołącza do kanalu") | ||
| 19 | async def join(self, ctx): | ||
| 20 | voice_mgr = get_voice_manager(self.bot) | ||
| 21 | channel = ctx.author.voice.channel | ||
| 22 | await voice_mgr.connect(channel) | ||
| 23 | await ctx.respond(content="\u200b", ephemeral=True) | ||
| 24 | await ctx.interaction.delete_original_response() | ||
| 25 | logger.info("Bot connected to voice channel") | ||
| 26 | |||
| 27 | |||
| 28 | def setup(bot): | ||
| 29 | bot.add_cog(Join(bot)) | ||
diff --git a/cogs/music/loop.py b/cogs/music/loop.py deleted file mode 100644 index 6e2c838..0000000 --- a/cogs/music/loop.py +++ /dev/null | |||
| @@ -1,40 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | |||
| 8 | from discord.ext import commands | ||
| 9 | |||
| 10 | logger = logging.getLogger(__name__) | ||
| 11 | |||
| 12 | |||
| 13 | class Loop(commands.Cog): | ||
| 14 | def __init__(self, bot): | ||
| 15 | self.bot = bot | ||
| 16 | self.looping = {} | ||
| 17 | |||
| 18 | def is_looping(self, guild_id): | ||
| 19 | return self.looping.get(guild_id, False) | ||
| 20 | |||
| 21 | def set_looping(self, guild_id, value): | ||
| 22 | self.looping[guild_id] = value | ||
| 23 | |||
| 24 | @commands.slash_command( | ||
| 25 | name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji" | ||
| 26 | ) | ||
| 27 | async def loop(self, ctx): | ||
| 28 | guild_id = ctx.guild.id | ||
| 29 | looping = not self.is_looping(guild_id) | ||
| 30 | self.set_looping(guild_id, looping) | ||
| 31 | await ctx.respond( | ||
| 32 | f"looping jest teraz {'włączony' if looping else 'wyłączony'}." | ||
| 33 | ) | ||
| 34 | logger.info( | ||
| 35 | f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}" | ||
| 36 | ) | ||
| 37 | |||
| 38 | |||
| 39 | def setup(bot): | ||
| 40 | bot.add_cog(Loop(bot)) | ||
diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py deleted file mode 100644 index 91bd406..0000000 --- a/cogs/music/media_processing.py +++ /dev/null | |||
| @@ -1,135 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import asyncio | ||
| 7 | import logging | ||
| 8 | import os | ||
| 9 | |||
| 10 | import yt_dlp | ||
| 11 | from youtubesearchpython import VideosSearch | ||
| 12 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | |||
| 17 | class MediaDownloader: | ||
| 18 | """Handles downloading media from various sources.""" | ||
| 19 | |||
| 20 | @staticmethod | ||
| 21 | async def download(ctx, url): | ||
| 22 | """Download audio from YouTube URL.""" | ||
| 23 | from cogs.utils.temp import get_temp_dir | ||
| 24 | |||
| 25 | temp_dir = get_temp_dir("senator_sperma_music") | ||
| 26 | |||
| 27 | ytdl_opts = { | ||
| 28 | "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), | ||
| 29 | "postprocessors": [ | ||
| 30 | { | ||
| 31 | "key": "FFmpegExtractAudio", | ||
| 32 | "preferredcodec": "mp3", | ||
| 33 | "preferredquality": "192", | ||
| 34 | } | ||
| 35 | ], | ||
| 36 | "force_ipv4": True, | ||
| 37 | } | ||
| 38 | |||
| 39 | try: | ||
| 40 | url = clean_url_for_ytdlp(url) | ||
| 41 | with yt_dlp.YoutubeDL(ytdl_opts) as ydl: | ||
| 42 | info_dict = ydl.extract_info(url, download=True) | ||
| 43 | # Get the base filename without extension | ||
| 44 | base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] | ||
| 45 | # Add .mp3 extension since we're extracting audio | ||
| 46 | filename = f"{base_filename}.mp3" | ||
| 47 | logger.info(f"Downloaded file: {filename}") | ||
| 48 | return filename | ||
| 49 | except Exception as e: | ||
| 50 | logger.error(f"Error downloading video: {e}") | ||
| 51 | await ctx.respond("eror pobirajync wideło", ephemeral=True) | ||
| 52 | |||
| 53 | @staticmethod | ||
| 54 | async def get_url(ctx, query): | ||
| 55 | """Search YouTube and get URL for a query.""" | ||
| 56 | search = VideosSearch(query, limit=1) | ||
| 57 | if search.result()["result"]: | ||
| 58 | title = search.result()["result"][0]["title"] | ||
| 59 | url = search.result()["result"][0]["link"] | ||
| 60 | logger.info(f"Found video: {title}") | ||
| 61 | return url, title | ||
| 62 | else: | ||
| 63 | logger.warning("No results found for the query.") | ||
| 64 | await ctx.respond("nic ni ma", ephemeral=True) | ||
| 65 | return None | ||
| 66 | |||
| 67 | |||
| 68 | class AudioCompressor: | ||
| 69 | """Handles audio compression using FFmpeg.""" | ||
| 70 | |||
| 71 | @staticmethod | ||
| 72 | async def compress(ctx, filename): | ||
| 73 | """Compress audio file to opus format.""" | ||
| 74 | from cogs.utils.temp import get_temp_dir | ||
| 75 | |||
| 76 | if not filename: | ||
| 77 | await ctx.respond("dzie wideło", ephemeral=True) | ||
| 78 | return None | ||
| 79 | |||
| 80 | temp_dir = get_temp_dir("senator_sperma_compressed") | ||
| 81 | |||
| 82 | # Create a temporary output filename in the temp directory | ||
| 83 | base_name = os.path.basename(filename) | ||
| 84 | output_filename = os.path.join( | ||
| 85 | temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus" | ||
| 86 | ) | ||
| 87 | |||
| 88 | # Ensure the output directory exists | ||
| 89 | os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True) | ||
| 90 | |||
| 91 | # Use proper quoting for filenames with spaces | ||
| 92 | ffmpeg_command = [ | ||
| 93 | "ffmpeg", | ||
| 94 | "-y", # Overwrite output file if it exists | ||
| 95 | "-i", | ||
| 96 | filename, | ||
| 97 | "-ac", | ||
| 98 | "1", # Mono audio | ||
| 99 | "-b:a", | ||
| 100 | "500", # 500k bitrate | ||
| 101 | "-acodec", | ||
| 102 | "libopus", | ||
| 103 | output_filename, | ||
| 104 | ] | ||
| 105 | |||
| 106 | try: | ||
| 107 | logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") | ||
| 108 | process = await asyncio.create_subprocess_exec( | ||
| 109 | *ffmpeg_command, | ||
| 110 | stdout=asyncio.subprocess.PIPE, | ||
| 111 | stderr=asyncio.subprocess.PIPE, | ||
| 112 | ) | ||
| 113 | |||
| 114 | _, stderr = await process.communicate() | ||
| 115 | |||
| 116 | if process.returncode != 0: | ||
| 117 | error_msg = stderr.decode() | ||
| 118 | logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") | ||
| 119 | await ctx.respond("eror kompresujync pilk", ephemeral=True) | ||
| 120 | return None | ||
| 121 | |||
| 122 | if not os.path.exists(output_filename): | ||
| 123 | logger.error(f"Output file was not created: {output_filename}") | ||
| 124 | await ctx.respond( | ||
| 125 | "eror kompresujync pilk - output file not created", ephemeral=True | ||
| 126 | ) | ||
| 127 | return None | ||
| 128 | |||
| 129 | logger.info(f"Successfully compressed file: {output_filename}") | ||
| 130 | return output_filename | ||
| 131 | |||
| 132 | except Exception as e: | ||
| 133 | logger.error(f"Error compressing file: {str(e)}", exc_info=True) | ||
| 134 | await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True) | ||
| 135 | return None | ||
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py deleted file mode 100644 index c6a55b0..0000000 --- a/cogs/music/play_commands.py +++ /dev/null | |||
| @@ -1,320 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | import os | ||
| 8 | import urllib.parse | ||
| 9 | import asyncio | ||
| 10 | from os import PathLike | ||
| 11 | from typing import Awaitable, Callable, List, Tuple | ||
| 12 | import re | ||
| 13 | import requests | ||
| 14 | from yt_dlp import YoutubeDL | ||
| 15 | |||
| 16 | import discord | ||
| 17 | from discord.commands import OptionChoice | ||
| 18 | from discord.ext import commands | ||
| 19 | |||
| 20 | from cogs.music import media_processing | ||
| 21 | from cogs.utils.file_search import UniversalSearcher | ||
| 22 | from cogs.utils.settings import get_uploadurl | ||
| 23 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 24 | from cogs.voice import get_voice_manager | ||
| 25 | |||
| 26 | logger = logging.getLogger(__name__) | ||
| 27 | |||
| 28 | EXTERNAL_URL_PATTERN = re.compile( | ||
| 29 | r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" | ||
| 30 | ) | ||
| 31 | |||
| 32 | |||
| 33 | class PlayCommands(commands.Cog): | ||
| 34 | """Discord slash commands for music playback.""" | ||
| 35 | |||
| 36 | def __init__(self, bot): | ||
| 37 | from cogs.utils.temp import get_temp_dir | ||
| 38 | |||
| 39 | self.bot = bot | ||
| 40 | self.temp_dir = get_temp_dir() | ||
| 41 | self.path_mapping = {} # Maps display names to full paths | ||
| 42 | |||
| 43 | @staticmethod | ||
| 44 | async def _create_search_embed( | ||
| 45 | candidates: List[str], | ||
| 46 | ) -> Tuple[discord.Embed, discord.ui.View]: | ||
| 47 | """Create an embed and view for search results.""" | ||
| 48 | embed = discord.Embed( | ||
| 49 | title="Wyniki wyszukiwania", | ||
| 50 | description="Wybierz plik do odtworzenia:", | ||
| 51 | color=discord.Color.blue(), | ||
| 52 | ) | ||
| 53 | |||
| 54 | view = discord.ui.View(disable_on_timeout=True) | ||
| 55 | |||
| 56 | for i, candidate in enumerate(candidates, start=1): | ||
| 57 | filename = candidate.split("/")[-1].split("?")[0] | ||
| 58 | display_name = os.path.splitext(filename)[0] | ||
| 59 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) | ||
| 60 | |||
| 61 | return embed, view | ||
| 62 | |||
| 63 | async def _handle_search_results( | ||
| 64 | self, | ||
| 65 | ctx: discord.ApplicationContext, | ||
| 66 | candidates: List[str], | ||
| 67 | play_callback: Callable[[discord.Interaction, str], Awaitable[None]], | ||
| 68 | ) -> None: | ||
| 69 | """Handle the display of search results and button interactions.""" | ||
| 70 | if not candidates: | ||
| 71 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) | ||
| 72 | return | ||
| 73 | |||
| 74 | embed, view = await self._create_search_embed(candidates) | ||
| 75 | |||
| 76 | # Add buttons for each candidate | ||
| 77 | for i, candidate in enumerate(candidates, start=1): | ||
| 78 | button = discord.ui.Button(label=f"Play {i}") | ||
| 79 | |||
| 80 | async def button_callback( | ||
| 81 | interaction: discord.Interaction, candidate_path=candidate | ||
| 82 | ): | ||
| 83 | await interaction.response.defer(ephemeral=True) | ||
| 84 | await ctx.interaction.delete_original_response() | ||
| 85 | try: | ||
| 86 | await play_callback(interaction, candidate_path) | ||
| 87 | except Exception as e: | ||
| 88 | logger.error(f"Error in play callback: {str(e)}", exc_info=True) | ||
| 89 | await interaction.followup.send( | ||
| 90 | f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True | ||
| 91 | ) | ||
| 92 | |||
| 93 | button.callback = button_callback | ||
| 94 | view.add_item(button) | ||
| 95 | |||
| 96 | await ctx.respond(embed=embed, view=view, ephemeral=True) | ||
| 97 | |||
| 98 | async def _search_autocomplete( | ||
| 99 | self, ctx: discord.AutocompleteContext | ||
| 100 | ) -> List[OptionChoice]: | ||
| 101 | """Autocomplete handler for search queries.""" | ||
| 102 | if ctx.value.lower().startswith(("http://", "https://")): | ||
| 103 | return [] | ||
| 104 | |||
| 105 | query = ctx.value.lower() | ||
| 106 | try: | ||
| 107 | results = await UniversalSearcher.search_copyparty_autocomplete_audio( | ||
| 108 | query, max_results=25 | ||
| 109 | ) | ||
| 110 | choices = [] | ||
| 111 | self.path_mapping = {} # Reset the mapping for each new query | ||
| 112 | |||
| 113 | for url in results: | ||
| 114 | filename = url.split("/")[-1] | ||
| 115 | filename = filename.split("?")[0] # Remove query params if any | ||
| 116 | filename = urllib.parse.unquote(filename) # Decode URL encoding | ||
| 117 | display = os.path.splitext(filename)[0] # Remove extension | ||
| 118 | |||
| 119 | display = display.strip() | ||
| 120 | |||
| 121 | if len(display) > 100: | ||
| 122 | display = display[:99] + "…" | ||
| 123 | |||
| 124 | self.path_mapping[display] = url | ||
| 125 | choices.append(OptionChoice(display, display)) | ||
| 126 | |||
| 127 | if len(choices) >= 25: | ||
| 128 | break | ||
| 129 | |||
| 130 | return choices | ||
| 131 | |||
| 132 | except Exception as e: | ||
| 133 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) | ||
| 134 | return [] | ||
| 135 | |||
| 136 | async def _handle_compressed_file_play( | ||
| 137 | self, ctx: discord.ApplicationContext, url: str | ||
| 138 | ) -> None: | ||
| 139 | """Download from copyparty, compress, then play locally.""" | ||
| 140 | import aiohttp | ||
| 141 | |||
| 142 | local_path = None | ||
| 143 | compressed_path = None | ||
| 144 | try: | ||
| 145 | async with aiohttp.ClientSession() as session: | ||
| 146 | async with session.get(url) as response: | ||
| 147 | if response.status != 200: | ||
| 148 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) | ||
| 149 | return | ||
| 150 | |||
| 151 | filename = url.split("/")[-1].split("?")[0] | ||
| 152 | filename = urllib.parse.unquote(filename) | ||
| 153 | local_path = os.path.join(self.temp_dir, filename) | ||
| 154 | os.makedirs(self.temp_dir, exist_ok=True) | ||
| 155 | |||
| 156 | loop = asyncio.get_event_loop() | ||
| 157 | with open(local_path, "wb") as f: | ||
| 158 | async for chunk in response.content.iter_chunked(8192): | ||
| 159 | await loop.run_in_executor(None, f.write, chunk) | ||
| 160 | |||
| 161 | compressed_path = await media_processing.AudioCompressor.compress( | ||
| 162 | ctx, local_path | ||
| 163 | ) | ||
| 164 | if not compressed_path: | ||
| 165 | return | ||
| 166 | |||
| 167 | await get_voice_manager(self.bot).play( | ||
| 168 | ctx, compressed_path, delete_after_play=True | ||
| 169 | ) | ||
| 170 | except Exception as e: | ||
| 171 | logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) | ||
| 172 | await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) | ||
| 173 | finally: | ||
| 174 | if local_path and os.path.exists(local_path): | ||
| 175 | try: | ||
| 176 | os.remove(local_path) | ||
| 177 | except Exception: | ||
| 178 | pass | ||
| 179 | |||
| 180 | async def _handle_external_url_play( | ||
| 181 | self, ctx: discord.ApplicationContext, url: str | ||
| 182 | ) -> None: | ||
| 183 | """Download from external URL, upload to copyparty, then play.""" | ||
| 184 | local_path = None | ||
| 185 | try: | ||
| 186 | ytdl_opts = { | ||
| 187 | "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"), | ||
| 188 | "postprocessors": [ | ||
| 189 | { | ||
| 190 | "key": "FFmpegExtractAudio", | ||
| 191 | "preferredcodec": "mp3", | ||
| 192 | "preferredquality": "192", | ||
| 193 | } | ||
| 194 | ], | ||
| 195 | "force_ipv4": True, | ||
| 196 | } | ||
| 197 | |||
| 198 | loop = asyncio.get_event_loop() | ||
| 199 | url = clean_url_for_ytdlp(url) | ||
| 200 | with YoutubeDL(ytdl_opts) as ydl: | ||
| 201 | info_dict = await loop.run_in_executor( | ||
| 202 | None, lambda: ydl.extract_info(url, download=True) | ||
| 203 | ) | ||
| 204 | title = info_dict.get("title", "nieznany") | ||
| 205 | base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] | ||
| 206 | local_path = f"{base_filename}.mp3" | ||
| 207 | |||
| 208 | if not os.path.exists(local_path): | ||
| 209 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) | ||
| 210 | return | ||
| 211 | |||
| 212 | upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) | ||
| 213 | |||
| 214 | await get_voice_manager(self.bot).play( | ||
| 215 | ctx, local_path, delete_after_play=True | ||
| 216 | ) | ||
| 217 | |||
| 218 | try: | ||
| 219 | await asyncio.wait_for(upload_task, timeout=30.0) | ||
| 220 | except asyncio.TimeoutError: | ||
| 221 | logger.warning("Upload timed out") | ||
| 222 | except Exception as e: | ||
| 223 | logger.error(f"Error downloading external URL: {str(e)}", exc_info=True) | ||
| 224 | if local_path and os.path.exists(local_path): | ||
| 225 | try: | ||
| 226 | os.remove(local_path) | ||
| 227 | except Exception: | ||
| 228 | pass | ||
| 229 | await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True) | ||
| 230 | |||
| 231 | async def _upload_to_copyparty_async(self, file_path: str) -> None: | ||
| 232 | """Upload file to copyparty asynchronously (fire and forget).""" | ||
| 233 | try: | ||
| 234 | logger.info(f"Starting async upload: {file_path}") | ||
| 235 | baseurl = get_uploadurl() | ||
| 236 | password = os.getenv("COPYPARTY_PASSWORD", "") | ||
| 237 | |||
| 238 | logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") | ||
| 239 | |||
| 240 | if baseurl == "http://localhost:3939": | ||
| 241 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") | ||
| 242 | |||
| 243 | with open(file_path, "rb") as f: | ||
| 244 | content = f.read() | ||
| 245 | |||
| 246 | files = {"f": (os.path.basename(file_path), content, "application/octet-stream")} | ||
| 247 | headers = {"Cookie": f"cppwd={password}"} | ||
| 248 | params = {"j": "1", "ck": "b2"} | ||
| 249 | |||
| 250 | logger.info(f"Posting to {baseurl}") | ||
| 251 | response = requests.post(baseurl, files=files, headers=headers, params=params) | ||
| 252 | text = response.text | ||
| 253 | logger.info(f"Upload response: {response.status_code} - {text[:200] if text else 'empty'}") | ||
| 254 | |||
| 255 | if response.status_code in [200, 201]: | ||
| 256 | result = response.json() | ||
| 257 | logger.info(f"Uploaded to copyparty: {result.get('fileurl')}") | ||
| 258 | except Exception as e: | ||
| 259 | logger.error(f"Async upload failed: {e}", exc_info=True) | ||
| 260 | |||
| 261 | async def _handle_local_file_play( | ||
| 262 | self, ctx: discord.ApplicationContext, file: str, compressed: bool | ||
| 263 | ) -> None: | ||
| 264 | """Handle file playback from copyparty URL.""" | ||
| 265 | url = self.path_mapping.get(file, file) # Look up URL from display name | ||
| 266 | |||
| 267 | if compressed: | ||
| 268 | await self._handle_compressed_file_play(ctx, url) | ||
| 269 | else: | ||
| 270 | await get_voice_manager(self.bot).play(ctx, url) | ||
| 271 | |||
| 272 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | ||
| 273 | async def play( | ||
| 274 | self, | ||
| 275 | ctx: discord.ApplicationContext, | ||
| 276 | melodia: str = discord.Option( | ||
| 277 | str, | ||
| 278 | "odtwórz plik z serwera bądź strumienia", | ||
| 279 | autocomplete=_search_autocomplete, | ||
| 280 | required=False, | ||
| 281 | ), | ||
| 282 | file: discord.Attachment = discord.Option( | ||
| 283 | discord.Attachment, "plik do zagrania", required=False | ||
| 284 | ), | ||
| 285 | compressed: bool = discord.Option( | ||
| 286 | bool, "Czy skompresować dźwięk?", default=False | ||
| 287 | ), | ||
| 288 | ): | ||
| 289 | """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" | ||
| 290 | await ctx.defer() | ||
| 291 | |||
| 292 | if melodia and file: | ||
| 293 | await ctx.respond( | ||
| 294 | "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True | ||
| 295 | ) | ||
| 296 | return | ||
| 297 | |||
| 298 | if not melodia and not file: | ||
| 299 | await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True) | ||
| 300 | return | ||
| 301 | |||
| 302 | if file: | ||
| 303 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) | ||
| 304 | await file.save(file_path) | ||
| 305 | await get_voice_manager(self.bot).play(ctx, file_path) | ||
| 306 | else: | ||
| 307 | try: | ||
| 308 | if EXTERNAL_URL_PATTERN.match(melodia.lower()): | ||
| 309 | await self._handle_external_url_play(ctx, melodia) | ||
| 310 | else: | ||
| 311 | await self._handle_local_file_play(ctx, melodia, compressed) | ||
| 312 | except Exception as e: | ||
| 313 | logger.error(f"Error playing: {str(e)}", exc_info=True) | ||
| 314 | await ctx.respond( | ||
| 315 | f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True | ||
| 316 | ) | ||
| 317 | |||
| 318 | |||
| 319 | def setup(bot): | ||
| 320 | bot.add_cog(PlayCommands(bot)) | ||
diff --git a/cogs/music/stop.py b/cogs/music/stop.py deleted file mode 100644 index 9c8d5cf..0000000 --- a/cogs/music/stop.py +++ /dev/null | |||
| @@ -1,29 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | from discord.ext import commands | ||
| 7 | |||
| 8 | from cogs.voice import get_voice_manager | ||
| 9 | |||
| 10 | import logging | ||
| 11 | |||
| 12 | logger = logging.getLogger(__name__) | ||
| 13 | |||
| 14 | |||
| 15 | class Stop(commands.Cog): | ||
| 16 | def __init__(self, bot): | ||
| 17 | self.bot = bot | ||
| 18 | |||
| 19 | @commands.slash_command(name="stop", description="stopuj bota") | ||
| 20 | async def stop(self, ctx): | ||
| 21 | await ctx.respond(content="\u200b", ephemeral=True) | ||
| 22 | await ctx.interaction.delete_original_response() | ||
| 23 | voice_mgr = get_voice_manager(self.bot) | ||
| 24 | await voice_mgr.disconnect(ctx.guild) | ||
| 25 | logger.info("Bot disconnected from voice channel") | ||
| 26 | |||
| 27 | |||
| 28 | def setup(bot): | ||
| 29 | bot.add_cog(Stop(bot)) | ||
diff --git a/cogs/qr.py b/cogs/qr.py deleted file mode 100644 index 32c574e..0000000 --- a/cogs/qr.py +++ /dev/null | |||
| @@ -1,154 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import asyncio | ||
| 7 | import logging | ||
| 8 | import os | ||
| 9 | import tempfile | ||
| 10 | |||
| 11 | import aiohttp | ||
| 12 | import discord | ||
| 13 | import qrcode | ||
| 14 | from discord.ext import commands | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | ||
| 17 | |||
| 18 | _pil_image = None | ||
| 19 | _decode_func = None | ||
| 20 | _decode_available = False | ||
| 21 | |||
| 22 | |||
| 23 | def _ensure_qr(): | ||
| 24 | global _pil_image, _decode_func, _decode_available | ||
| 25 | |||
| 26 | if _pil_image is not None: | ||
| 27 | return _decode_available | ||
| 28 | |||
| 29 | try: | ||
| 30 | from PIL import Image | ||
| 31 | from pyzbar.pyzbar import decode as _decode_func | ||
| 32 | _pil_image = Image | ||
| 33 | _decode_available = True | ||
| 34 | except ImportError: | ||
| 35 | _decode_available = False | ||
| 36 | logger.warning("pyzbar or PIL not available, decode command will be disabled") | ||
| 37 | return _decode_available | ||
| 38 | |||
| 39 | |||
| 40 | class QR(commands.Cog): | ||
| 41 | def __init__(self, bot): | ||
| 42 | self.bot = bot | ||
| 43 | |||
| 44 | async def _download_image(self, url): | ||
| 45 | """Download image from URL to temporary file""" | ||
| 46 | async with aiohttp.ClientSession() as session: | ||
| 47 | async with session.get(url) as response: | ||
| 48 | if response.status == 200: | ||
| 49 | content = await response.read() | ||
| 50 | with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f: | ||
| 51 | await asyncio.get_event_loop().run_in_executor(None, f.write, content) | ||
| 52 | return f.name | ||
| 53 | return None | ||
| 54 | |||
| 55 | @commands.slash_command( | ||
| 56 | name="qr_generuj", description="Generuje kod QR z podanego tekstu" | ||
| 57 | ) | ||
| 58 | async def qr( | ||
| 59 | self, | ||
| 60 | ctx, | ||
| 61 | text: str = discord.Option(str, "Tekst do zakodowania", required=True), | ||
| 62 | ): | ||
| 63 | await ctx.defer() | ||
| 64 | |||
| 65 | from cogs.utils.temp import get_temp_dir | ||
| 66 | temp_path = os.path.join( | ||
| 67 | get_temp_dir(), f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png" | ||
| 68 | ) | ||
| 69 | |||
| 70 | try: | ||
| 71 | loop = asyncio.get_event_loop() | ||
| 72 | await loop.run_in_executor(None, self._generate_qr, text, temp_path) | ||
| 73 | await ctx.respond(file=discord.File(temp_path, filename="qr.png")) | ||
| 74 | logger.info(f"Generated QR for text: {text}") | ||
| 75 | except Exception as e: | ||
| 76 | logger.error(f"QR generation error: {e}") | ||
| 77 | await ctx.respond("Error generating QR code", ephemeral=True) | ||
| 78 | finally: | ||
| 79 | try: | ||
| 80 | if os.path.exists(temp_path): | ||
| 81 | os.unlink(temp_path) | ||
| 82 | except Exception as e: | ||
| 83 | logger.error(f"Error cleaning up temp file: {e}") | ||
| 84 | |||
| 85 | def _generate_qr(self, text, output_path): | ||
| 86 | """Generate QR code (runs in executor)""" | ||
| 87 | qr = qrcode.QRCode( | ||
| 88 | version=1, | ||
| 89 | error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore[attr-defined] | ||
| 90 | box_size=10, | ||
| 91 | border=4, | ||
| 92 | ) | ||
| 93 | qr.add_data(text) | ||
| 94 | qr.make(fit=True) | ||
| 95 | img = qr.make_image(fill_color="black", back_color="white") | ||
| 96 | img.save(output_path) | ||
| 97 | |||
| 98 | @commands.slash_command(name="qr_dekoduj", description="Dekoduje kod QR z obrazka") | ||
| 99 | async def decode( | ||
| 100 | self, | ||
| 101 | ctx, | ||
| 102 | image: discord.Attachment = discord.Option( | ||
| 103 | discord.Attachment, "Obrazek z kodem QR", required=True | ||
| 104 | ), | ||
| 105 | ): | ||
| 106 | if not _ensure_qr(): | ||
| 107 | await ctx.respond( | ||
| 108 | "Dekodowanie nie jest dostępne (brak biblioteki pyzbar)", ephemeral=True | ||
| 109 | ) | ||
| 110 | return | ||
| 111 | |||
| 112 | await ctx.defer() | ||
| 113 | |||
| 114 | try: | ||
| 115 | temp_path = await self._download_image(image.url) | ||
| 116 | if not temp_path: | ||
| 117 | await ctx.respond("Nie udało się pobrać obrazka", ephemeral=True) | ||
| 118 | return | ||
| 119 | |||
| 120 | try: | ||
| 121 | loop = asyncio.get_event_loop() | ||
| 122 | decoded = await loop.run_in_executor(None, self._decode_qr, temp_path) | ||
| 123 | |||
| 124 | if decoded: | ||
| 125 | await ctx.respond(f"```\n{decoded}\n```") | ||
| 126 | logger.info(f"Decoded QR: {decoded}") | ||
| 127 | else: | ||
| 128 | await ctx.respond( | ||
| 129 | "Nie znaleziono kodu QR na obrazku", ephemeral=True | ||
| 130 | ) | ||
| 131 | finally: | ||
| 132 | if os.path.exists(temp_path): | ||
| 133 | os.unlink(temp_path) | ||
| 134 | except Exception as e: | ||
| 135 | logger.error(f"QR decode error: {e}") | ||
| 136 | await ctx.respond("Error dekodowania QR", ephemeral=True) | ||
| 137 | |||
| 138 | def _decode_qr(self, image_path): | ||
| 139 | """Decode QR code from image (runs in executor)""" | ||
| 140 | if not _ensure_qr() or _decode_func is None: | ||
| 141 | return None | ||
| 142 | |||
| 143 | try: | ||
| 144 | img = _pil_image.open(image_path) | ||
| 145 | decoded_list = _decode_func(img) | ||
| 146 | if decoded_list: | ||
| 147 | return decoded_list[0].data.decode("utf-8") | ||
| 148 | except Exception as e: | ||
| 149 | logger.error(f"Error decoding QR: {e}") | ||
| 150 | return None | ||
| 151 | |||
| 152 | |||
| 153 | def setup(bot): | ||
| 154 | bot.add_cog(QR(bot)) | ||
diff --git a/cogs/say.py b/cogs/say.py deleted file mode 100644 index 29d95fe..0000000 --- a/cogs/say.py +++ /dev/null | |||
| @@ -1,28 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | from discord.ext import commands | ||
| 7 | import discord | ||
| 8 | import logging | ||
| 9 | |||
| 10 | logger = logging.getLogger(__name__) | ||
| 11 | |||
| 12 | class Say(commands.Cog): | ||
| 13 | def __init__(self, bot): | ||
| 14 | self.bot = bot | ||
| 15 | |||
| 16 | @discord.slash_command(name="say", description="senator, gadaj") | ||
| 17 | async def say( | ||
| 18 | self, | ||
| 19 | ctx: discord.ApplicationContext, | ||
| 20 | text: str = discord.Option(str, "Tekst do przeczytania", required=True) | ||
| 21 | ): | ||
| 22 | await ctx.defer() | ||
| 23 | await ctx.respond(content="\u200b", ephemeral=True) | ||
| 24 | await ctx.interaction.delete_original_response() | ||
| 25 | await ctx.send(content=text) | ||
| 26 | |||
| 27 | def setup(bot): | ||
| 28 | bot.add_cog(Say(bot)) | ||
diff --git a/cogs/search.py b/cogs/search.py deleted file mode 100644 index a124cbf..0000000 --- a/cogs/search.py +++ /dev/null | |||
| @@ -1,73 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import urllib.parse | ||
| 7 | |||
| 8 | import discord | ||
| 9 | from discord.ext import commands | ||
| 10 | import logging | ||
| 11 | from discord import AutocompleteContext | ||
| 12 | from cogs.utils import file_search as search_utils | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | class Search(commands.Cog): | ||
| 17 | def __init__(self, bot): | ||
| 18 | self.bot = bot | ||
| 19 | self._cached_results = {} | ||
| 20 | |||
| 21 | async def file_autocomplete(self, ctx: AutocompleteContext): | ||
| 22 | """Autocomplete function for file search.""" | ||
| 23 | query = ctx.value.lower() if ctx.value else "" | ||
| 24 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 25 | |||
| 26 | user_id = ctx.interaction.user.id | ||
| 27 | self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 28 | |||
| 29 | choices = [] | ||
| 30 | for url in results[:25]: | ||
| 31 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 32 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 33 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 34 | return choices | ||
| 35 | |||
| 36 | @commands.slash_command( | ||
| 37 | name="search", | ||
| 38 | description="wyszukaj pliku" | ||
| 39 | ) | ||
| 40 | @discord.option( | ||
| 41 | name="filename", | ||
| 42 | description="Search for a file to send", | ||
| 43 | autocomplete=file_autocomplete | ||
| 44 | ) | ||
| 45 | async def search(self, ctx, filename: str): | ||
| 46 | """Send a file via URL from autocomplete selection.""" | ||
| 47 | if not filename: | ||
| 48 | await ctx.reply("Please select a file from the autocomplete options.") | ||
| 49 | return | ||
| 50 | |||
| 51 | await ctx.defer() | ||
| 52 | logger.info(f"Search command called by {ctx.author} for file: {filename}") | ||
| 53 | |||
| 54 | cached = self._cached_results.get(ctx.author.id) | ||
| 55 | if not cached: | ||
| 56 | await ctx.send_followup("Search results expired. Please search again.") | ||
| 57 | return | ||
| 58 | |||
| 59 | matching_url = cached.get(filename) | ||
| 60 | if not matching_url: | ||
| 61 | await ctx.send_followup(f"File '{filename}' not found.") | ||
| 62 | return | ||
| 63 | |||
| 64 | try: | ||
| 65 | await ctx.send_followup(matching_url) | ||
| 66 | logger.info(f"Successfully sent URL: {matching_url}") | ||
| 67 | del self._cached_results[ctx.author.id] | ||
| 68 | except Exception as e: | ||
| 69 | logger.error(f"Error sending URL {matching_url}: {str(e)}") | ||
| 70 | await ctx.send_followup(str(e)) | ||
| 71 | |||
| 72 | def setup(bot): | ||
| 73 | bot.add_cog(Search(bot)) | ||
diff --git a/cogs/trigger.py b/cogs/trigger.py deleted file mode 100644 index ce80d43..0000000 --- a/cogs/trigger.py +++ /dev/null | |||
| @@ -1,260 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import discord | ||
| 8 | from discord.ext import commands | ||
| 9 | import random | ||
| 10 | import logging | ||
| 11 | |||
| 12 | from cogs.utils.database import get_connection, init_database | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | init_database() | ||
| 17 | |||
| 18 | _SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" | ||
| 19 | _SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" | ||
| 20 | _SELECT_TRIGGER_ID_SQL = "SELECT id FROM triggers WHERE name=?" | ||
| 21 | _TRIGGER_NOT_FOUND_MSG = "Trigger not found." | ||
| 22 | _TRIGGER_NAME_OPTION = "the name of the trigger" | ||
| 23 | |||
| 24 | |||
| 25 | class Trigger(commands.Cog): | ||
| 26 | def __init__(self, bot): | ||
| 27 | self.bot = bot | ||
| 28 | |||
| 29 | trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") | ||
| 30 | |||
| 31 | @commands.Cog.listener() | ||
| 32 | async def on_message(self, message): | ||
| 33 | if message.author == self.bot.user: | ||
| 34 | return | ||
| 35 | |||
| 36 | await self.trigger(message) | ||
| 37 | |||
| 38 | @staticmethod | ||
| 39 | async def trigger(message): | ||
| 40 | content = message.content.lower() | ||
| 41 | with get_connection() as conn: | ||
| 42 | c = conn.cursor() | ||
| 43 | c.execute("SELECT id, name FROM triggers") | ||
| 44 | for trigger_id, name in c.fetchall(): | ||
| 45 | c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) | ||
| 46 | keywords = [row[0] for row in c.fetchall()] | ||
| 47 | if any(keyword in content for keyword in keywords): | ||
| 48 | c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) | ||
| 49 | responses = [row[0] for row in c.fetchall()] | ||
| 50 | if responses: | ||
| 51 | await message.channel.send(random.choice(responses).replace('\\n', '\n')) | ||
| 52 | logger.info(f"{message.author} triggered {name}") | ||
| 53 | break | ||
| 54 | |||
| 55 | @staticmethod | ||
| 56 | def get_type(name): | ||
| 57 | with get_connection() as conn: | ||
| 58 | c = conn.cursor() | ||
| 59 | c.execute("SELECT name FROM triggers") | ||
| 60 | return [row[0] for row in c.fetchall()] | ||
| 61 | |||
| 62 | @trigger_group.command( | ||
| 63 | description="Create a new trigger", | ||
| 64 | ) | ||
| 65 | async def create( | ||
| 66 | self, | ||
| 67 | ctx: discord.ApplicationContext, | ||
| 68 | name = discord.Option( | ||
| 69 | str, | ||
| 70 | _TRIGGER_NAME_OPTION, | ||
| 71 | autocomplete= discord.utils.basic_autocomplete(get_type), | ||
| 72 | required=True | ||
| 73 | ) | ||
| 74 | ): | ||
| 75 | with get_connection() as conn: | ||
| 76 | c = conn.cursor() | ||
| 77 | c.execute("INSERT OR IGNORE INTO triggers (name) VALUES (?)", (name,)) | ||
| 78 | conn.commit() | ||
| 79 | |||
| 80 | await ctx.respond("trigger added") | ||
| 81 | |||
| 82 | @trigger_group.command( | ||
| 83 | description="Add a trigger or response to the name", | ||
| 84 | ) | ||
| 85 | async def add( | ||
| 86 | self, | ||
| 87 | ctx: discord.ApplicationContext, | ||
| 88 | name = discord.Option( | ||
| 89 | str, | ||
| 90 | _TRIGGER_NAME_OPTION, | ||
| 91 | autocomplete= discord.utils.basic_autocomplete(get_type), | ||
| 92 | required=True | ||
| 93 | ), | ||
| 94 | trigger = discord.Option( | ||
| 95 | str, | ||
| 96 | "the trigger to add to the name", | ||
| 97 | required=False | ||
| 98 | ), | ||
| 99 | response = discord.Option( | ||
| 100 | str, | ||
| 101 | "the response to add to the name", | ||
| 102 | required=False | ||
| 103 | ) | ||
| 104 | ): | ||
| 105 | with get_connection() as conn: | ||
| 106 | c = conn.cursor() | ||
| 107 | c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) | ||
| 108 | row = c.fetchone() | ||
| 109 | if not row: | ||
| 110 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) | ||
| 111 | logger.warning(f"Trigger not found: {name}") | ||
| 112 | return | ||
| 113 | trigger_id = row[0] | ||
| 114 | if trigger: | ||
| 115 | for keyword in trigger.split('\\'): | ||
| 116 | keyword = keyword.strip() | ||
| 117 | if keyword: | ||
| 118 | c.execute("INSERT INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)", (trigger_id, keyword)) | ||
| 119 | if response: | ||
| 120 | for resp in response.split('\\'): | ||
| 121 | resp = resp.strip() | ||
| 122 | if resp: | ||
| 123 | c.execute("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)", (trigger_id, resp)) | ||
| 124 | conn.commit() | ||
| 125 | |||
| 126 | await ctx.respond("trigger updated") | ||
| 127 | logger.info(f"Trigger updated: {name}") | ||
| 128 | |||
| 129 | @trigger_group.command( | ||
| 130 | description="Remove a trigger or response from the name", | ||
| 131 | ) | ||
| 132 | async def remove( | ||
| 133 | self, | ||
| 134 | ctx: discord.ApplicationContext, | ||
| 135 | name = discord.Option( | ||
| 136 | str, | ||
| 137 | _TRIGGER_NAME_OPTION, | ||
| 138 | autocomplete= discord.utils.basic_autocomplete(get_type), | ||
| 139 | required=True | ||
| 140 | ), | ||
| 141 | trigger = discord.Option( | ||
| 142 | str, | ||
| 143 | "the trigger to remove from the name", | ||
| 144 | required=False | ||
| 145 | ), | ||
| 146 | response = discord.Option( | ||
| 147 | str, | ||
| 148 | "the response to remove from the name", | ||
| 149 | required=False | ||
| 150 | ) | ||
| 151 | ): | ||
| 152 | with get_connection() as conn: | ||
| 153 | c = conn.cursor() | ||
| 154 | c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) | ||
| 155 | row = c.fetchone() | ||
| 156 | if not row: | ||
| 157 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) | ||
| 158 | logger.warning(f"Trigger not found: {name}") | ||
| 159 | return | ||
| 160 | trigger_id = row[0] | ||
| 161 | if trigger: | ||
| 162 | c.execute("DELETE FROM trigger_keywords WHERE trigger_id=? AND keyword=?", (trigger_id, trigger)) | ||
| 163 | if response: | ||
| 164 | c.execute("DELETE FROM trigger_responses WHERE trigger_id=? AND response_text=?", (trigger_id, response)) | ||
| 165 | conn.commit() | ||
| 166 | |||
| 167 | await ctx.respond("trigger removed") | ||
| 168 | logger.info(f"Trigger removed: {name}") | ||
| 169 | |||
| 170 | @trigger_group.command( | ||
| 171 | description="Delete the whole trigger", | ||
| 172 | ) | ||
| 173 | async def delete( | ||
| 174 | self, | ||
| 175 | ctx: discord.ApplicationContext, | ||
| 176 | name = discord.Option( | ||
| 177 | str, | ||
| 178 | _TRIGGER_NAME_OPTION, | ||
| 179 | autocomplete= discord.utils.basic_autocomplete(get_type), | ||
| 180 | required=True | ||
| 181 | ) | ||
| 182 | ): | ||
| 183 | with get_connection() as conn: | ||
| 184 | c = conn.cursor() | ||
| 185 | c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) | ||
| 186 | row = c.fetchone() | ||
| 187 | if not row: | ||
| 188 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) | ||
| 189 | return | ||
| 190 | trigger_id = row[0] | ||
| 191 | c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) | ||
| 192 | c.execute("DELETE FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) | ||
| 193 | c.execute("DELETE FROM triggers WHERE id=?", (trigger_id,)) | ||
| 194 | conn.commit() | ||
| 195 | |||
| 196 | await ctx.respond("trigger deleted") | ||
| 197 | logger.info(f"Trigger deleted: {name}") | ||
| 198 | |||
| 199 | |||
| 200 | @trigger_group.command( | ||
| 201 | description="List all triggers", | ||
| 202 | ) | ||
| 203 | async def list( | ||
| 204 | self, | ||
| 205 | ctx: discord.ApplicationContext, | ||
| 206 | name = discord.Option( | ||
| 207 | str, | ||
| 208 | _TRIGGER_NAME_OPTION, | ||
| 209 | autocomplete=discord.utils.basic_autocomplete(get_type), | ||
| 210 | required=True | ||
| 211 | ) | ||
| 212 | ): | ||
| 213 | with get_connection() as conn: | ||
| 214 | c = conn.cursor() | ||
| 215 | if name: | ||
| 216 | c.execute("SELECT id FROM triggers WHERE name=?", (name,)) | ||
| 217 | row = c.fetchone() | ||
| 218 | if not row: | ||
| 219 | await ctx.respond(_TRIGGER_NOT_FOUND_MSG) | ||
| 220 | logger.warning(f"Trigger not found: {name}") | ||
| 221 | return | ||
| 222 | trigger_id = row[0] | ||
| 223 | c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) | ||
| 224 | triggers = [row[0] for row in c.fetchall()] | ||
| 225 | c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) | ||
| 226 | responses = [row[0] for row in c.fetchall()] | ||
| 227 | trigger_list = "\n".join(triggers) | ||
| 228 | response_list = "\n".join(responses) | ||
| 229 | message = f"**Trigger: {name}**\n**Triggers:**\n{trigger_list}\n**Responses:**\n{response_list}" | ||
| 230 | await ctx.respond(message) | ||
| 231 | logger.info(f"Trigger listed: {name}") | ||
| 232 | else: | ||
| 233 | c.execute("SELECT id, name FROM triggers") | ||
| 234 | messages = [] | ||
| 235 | for trigger_id, name in c.fetchall(): | ||
| 236 | c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) | ||
| 237 | triggers = ", ".join([row[0] for row in c.fetchall()]) | ||
| 238 | c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) | ||
| 239 | responses = ", ".join([row[0] for row in c.fetchall()]) | ||
| 240 | messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}") | ||
| 241 | await ctx.respond("\n\n".join(messages)) | ||
| 242 | logger.info("Triggers listed") | ||
| 243 | |||
| 244 | @trigger_group.command( | ||
| 245 | description="how to use trigger group?" | ||
| 246 | ) | ||
| 247 | async def help( | ||
| 248 | self, | ||
| 249 | ctx: discord.ApplicationContext, | ||
| 250 | ): | ||
| 251 | await ctx.respond("Trigger create - creates a group for triggers/responses\n" | ||
| 252 | "Trigger add - adds triggers/responses to a group, \\ is the delimeter\n" | ||
| 253 | "Trigger remove - removes triggers/responses from a group, \\ is the delimeter\n" | ||
| 254 | "Trigger delete - deletes the whole group\n" | ||
| 255 | "Trigger list - lists triggers/responses of a group\n" | ||
| 256 | "Trigger help - display this message\n") | ||
| 257 | |||
| 258 | |||
| 259 | def setup(bot): | ||
| 260 | bot.add_cog(Trigger(bot)) | ||
diff --git a/cogs/tts.py b/cogs/tts.py deleted file mode 100644 index ca81d3a..0000000 --- a/cogs/tts.py +++ /dev/null | |||
| @@ -1,111 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import json | ||
| 8 | import discord | ||
| 9 | import logging | ||
| 10 | import tempfile | ||
| 11 | from discord.ext import commands | ||
| 12 | from gtts import gTTS | ||
| 13 | import asyncio | ||
| 14 | from cogs.utils.settings import get_volume | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | ||
| 17 | |||
| 18 | class TTS(commands.Cog): | ||
| 19 | def __init__(self, bot): | ||
| 20 | self.bot = bot | ||
| 21 | |||
| 22 | async def _handle_file_output(self, ctx, temp_path, text): | ||
| 23 | """Handle TTS file output""" | ||
| 24 | try: | ||
| 25 | await ctx.respond(file=discord.File(temp_path, filename='tts.mp3')) | ||
| 26 | logger.info(f"Sent TTS for text: {text}") | ||
| 27 | except Exception as e: | ||
| 28 | logger.error(f"Error sending file: {e}") | ||
| 29 | await ctx.respond("eror :c", ephemeral=True) | ||
| 30 | |||
| 31 | def _get_voice_manager(self): | ||
| 32 | from cogs.voice import get_voice_manager | ||
| 33 | return get_voice_manager(self.bot) | ||
| 34 | |||
| 35 | async def _get_or_connect_voice_client(self, channel): | ||
| 36 | """Get existing voice client or connect to new channel""" | ||
| 37 | voice_mgr = self._get_voice_manager() | ||
| 38 | return await voice_mgr.join_channel(channel) | ||
| 39 | |||
| 40 | async def _handle_voice_output(self, ctx, temp_path, text, volume): | ||
| 41 | """Handle TTS voice channel output""" | ||
| 42 | await ctx.respond(content="\u200b", ephemeral=True) | ||
| 43 | await ctx.interaction.delete_original_response() | ||
| 44 | |||
| 45 | if not (ctx.author.voice and ctx.author.voice.channel): | ||
| 46 | logger.info("User is not in a voice channel") | ||
| 47 | return | ||
| 48 | |||
| 49 | channel = ctx.author.voice.channel | ||
| 50 | try: | ||
| 51 | voice_mgr = self._get_voice_manager() | ||
| 52 | |||
| 53 | if voice_mgr.get_voice_client and await voice_mgr.get_client(channel.guild): | ||
| 54 | vc = await voice_mgr.get_client(channel.guild) | ||
| 55 | if vc and vc.is_playing(): | ||
| 56 | logger.info("Audio already playing, skipping TTS") | ||
| 57 | return | ||
| 58 | |||
| 59 | audio_source = discord.FFmpegPCMAudio( | ||
| 60 | temp_path, | ||
| 61 | options=f'-filter:a "volume={volume}"' | ||
| 62 | ) | ||
| 63 | logger.info(f"Playing TTS: {text}") | ||
| 64 | await voice_mgr.play_source(channel, audio_source) | ||
| 65 | |||
| 66 | except Exception as e: | ||
| 67 | logger.error(f"Error in voice channel handling: {e}") | ||
| 68 | await ctx.respond("eror :c", ephemeral=True) | ||
| 69 | |||
| 70 | async def _cleanup_temp_file(self, temp_path): | ||
| 71 | """Clean up temporary file""" | ||
| 72 | try: | ||
| 73 | if temp_path and os.path.exists(temp_path): | ||
| 74 | os.unlink(temp_path) | ||
| 75 | except Exception as e: | ||
| 76 | logger.error(f"Error cleaning up temp file {temp_path}: {e}") | ||
| 77 | |||
| 78 | @commands.slash_command(name="tts", description="Generuje TTS dla danego tekstu") | ||
| 79 | async def tts(self, ctx, | ||
| 80 | text: str = discord.Option(str, "Tekst do przeczytania", required=True), | ||
| 81 | output: str = discord.Option(str, "Wybierz sposób odtwarzania", choices=["file", "vc"], default="vc") | ||
| 82 | ): | ||
| 83 | await ctx.defer() | ||
| 84 | |||
| 85 | # Create temporary file in temp folder | ||
| 86 | from cogs.utils.temp import get_temp_dir | ||
| 87 | temp_path = os.path.join(get_temp_dir(), f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") | ||
| 88 | |||
| 89 | try: | ||
| 90 | # Generate and save TTS to temp file | ||
| 91 | tts = gTTS(text=text, lang='pl') | ||
| 92 | loop = asyncio.get_event_loop() | ||
| 93 | await loop.run_in_executor(None, tts.save, temp_path) | ||
| 94 | |||
| 95 | # Load settings | ||
| 96 | volume = get_volume() | ||
| 97 | |||
| 98 | # Handle output based on user choice | ||
| 99 | if output == "file": | ||
| 100 | await self._handle_file_output(ctx, temp_path, text) | ||
| 101 | elif output == "vc": | ||
| 102 | await self._handle_voice_output(ctx, temp_path, text, volume) | ||
| 103 | |||
| 104 | except Exception as e: | ||
| 105 | logger.error(f"TTS generation error: {e}") | ||
| 106 | await ctx.respond("Error generating TTS", ephemeral=True) | ||
| 107 | finally: | ||
| 108 | await self._cleanup_temp_file(temp_path) | ||
| 109 | |||
| 110 | def setup(bot): | ||
| 111 | bot.add_cog(TTS(bot)) | ||
diff --git a/cogs/uploader.py b/cogs/uploader.py deleted file mode 100644 index a5d0e05..0000000 --- a/cogs/uploader.py +++ /dev/null | |||
| @@ -1,156 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import 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 | filename = urllib.parse.unquote(filename) | ||
| 52 | file_path = os.path.join(self.temp_dir, filename) | ||
| 53 | |||
| 54 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 55 | await f.write(response.content) | ||
| 56 | return file_path | ||
| 57 | except Exception as e: | ||
| 58 | logger.error(f"Error downloading from {url}: {e}") | ||
| 59 | return None | ||
| 60 | |||
| 61 | async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: | ||
| 62 | """Upload file to copyparty and return download URL""" | ||
| 63 | try: | ||
| 64 | async with aiofiles.open(file_path, 'rb') as f: | ||
| 65 | file_content = await f.read() | ||
| 66 | |||
| 67 | files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} | ||
| 68 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | ||
| 69 | params = {'j': '1', 'ck': 'b2'} | ||
| 70 | |||
| 71 | response = requests.post(self._uploadurl, files=files, headers=headers, params=params) | ||
| 72 | |||
| 73 | if response.status_code in [200, 201]: | ||
| 74 | result = response.json() | ||
| 75 | return result.get('fileurl') | ||
| 76 | except Exception as e: | ||
| 77 | logger.error(f"Upload error: {e}") | ||
| 78 | return None | ||
| 79 | |||
| 80 | async def _upload_file(self, file_path: str) -> Optional[str]: | ||
| 81 | """Upload a file and return the URL""" | ||
| 82 | if self._uploadurl == "http://localhost:3939": | ||
| 83 | logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") | ||
| 84 | return None | ||
| 85 | |||
| 86 | return await self._upload_to_copyparty(file_path) | ||
| 87 | |||
| 88 | @commands.message_command( | ||
| 89 | name="upload", | ||
| 90 | description="wyślij załączniki na copyparty", | ||
| 91 | ) | ||
| 92 | async def message_upload(self, ctx, message: discord.Message): | ||
| 93 | if not message.attachments: | ||
| 94 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) | ||
| 95 | return | ||
| 96 | |||
| 97 | await ctx.defer() | ||
| 98 | |||
| 99 | uploaded_urls = [] | ||
| 100 | failed_count = 0 | ||
| 101 | |||
| 102 | for attachment in message.attachments: | ||
| 103 | file_path = os.path.join(self.temp_dir, attachment.filename) | ||
| 104 | await attachment.save(file_path) | ||
| 105 | |||
| 106 | url = await self._upload_file(file_path) | ||
| 107 | if url: | ||
| 108 | uploaded_urls.append(url) | ||
| 109 | else: | ||
| 110 | failed_count += 1 | ||
| 111 | |||
| 112 | if os.path.exists(file_path): | ||
| 113 | os.remove(file_path) | ||
| 114 | |||
| 115 | if uploaded_urls: | ||
| 116 | response = f"Wysłano {len(uploaded_urls)} plików:\n" + "\n".join(uploaded_urls) | ||
| 117 | if failed_count > 0: | ||
| 118 | response += f"\n\nNie wysłano {failed_count} plików" | ||
| 119 | await ctx.respond(response) | ||
| 120 | else: | ||
| 121 | await ctx.respond("Nie udało się wysłać żadnych plików", ephemeral=True) | ||
| 122 | |||
| 123 | @commands.slash_command( | ||
| 124 | name="upload", | ||
| 125 | description="wyślij plik na copyparty", | ||
| 126 | ) | ||
| 127 | @discord.option( | ||
| 128 | name="zalacznik", | ||
| 129 | description="plik z discorda", | ||
| 130 | required=True, | ||
| 131 | type=discord.Attachment | ||
| 132 | ) | ||
| 133 | async def upload(self, ctx, zalacznik: discord.Attachment = None): | ||
| 134 | await ctx.defer() | ||
| 135 | |||
| 136 | file_path = None | ||
| 137 | file_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 138 | await zalacznik.save(file_path) | ||
| 139 | |||
| 140 | if not file_path or not os.path.exists(file_path): | ||
| 141 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 142 | return | ||
| 143 | |||
| 144 | try: | ||
| 145 | dl_url = await self._upload_file(file_path) | ||
| 146 | if dl_url: | ||
| 147 | await ctx.respond(dl_url) | ||
| 148 | else: | ||
| 149 | await ctx.respond("Nie udało się wysłać pliku", ephemeral=True) | ||
| 150 | finally: | ||
| 151 | if file_path and os.path.exists(file_path): | ||
| 152 | os.remove(file_path) | ||
| 153 | |||
| 154 | |||
| 155 | def setup(bot): | ||
| 156 | bot.add_cog(Uploader(bot)) | ||
diff --git a/cogs/utils/database.py b/cogs/utils/database.py deleted file mode 100644 index ea7d356..0000000 --- a/cogs/utils/database.py +++ /dev/null | |||
| @@ -1,126 +0,0 @@ | |||
| 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 deleted file mode 100644 index 788f50c..0000000 --- a/cogs/utils/file_search.py +++ /dev/null | |||
| @@ -1,167 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import json | ||
| 7 | import logging | ||
| 8 | import aiohttp | ||
| 9 | import os | ||
| 10 | from typing import List | ||
| 11 | from cogs.utils.settings import get_baseurl | ||
| 12 | |||
| 13 | logger = logging.getLogger(__name__) | ||
| 14 | |||
| 15 | |||
| 16 | class UniversalSearcher: | ||
| 17 | """Handles searching for files via copyparty API.""" | ||
| 18 | |||
| 19 | @staticmethod | ||
| 20 | async def search_copyparty(query: str, max_results: int = 25, file_extensions: List[str] | None = None): | ||
| 21 | """Search for files using copyparty API (jPOST method). | ||
| 22 | |||
| 23 | Args: | ||
| 24 | query: Search query string | ||
| 25 | max_results: Maximum number of results to return | ||
| 26 | file_extensions: Optional list of file extensions to filter by (e.g., ['mp3', 'wav']) | ||
| 27 | |||
| 28 | Returns: | ||
| 29 | List of matching file URLs | ||
| 30 | """ | ||
| 31 | if not query: | ||
| 32 | return [] | ||
| 33 | |||
| 34 | query = query.strip() | ||
| 35 | logger.info(f"Starting copyparty search for query: '{query}'") | ||
| 36 | |||
| 37 | tokens = query.split() | ||
| 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 | |||
| 52 | if file_extensions: | ||
| 53 | ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] | ||
| 54 | ext_query = " or ".join(ext_filters) | ||
| 55 | query = f"{name_query} and ({ext_query})" | ||
| 56 | else: | ||
| 57 | query = name_query | ||
| 58 | |||
| 59 | try: | ||
| 60 | baseurl = get_baseurl() | ||
| 61 | password = os.getenv("COPYPARTY_PASSWORD") | ||
| 62 | |||
| 63 | async with aiohttp.ClientSession() as session: | ||
| 64 | headers = {"Content-Type": "application/json"} | ||
| 65 | if password: | ||
| 66 | headers["PW"] = password | ||
| 67 | async with session.post(f"{baseurl}/?srch", headers=headers, data=json.dumps({"n": max_results, "q": query})) as response: | ||
| 68 | if response.status == 200: | ||
| 69 | data = await response.json() | ||
| 70 | files = UniversalSearcher._extract_files_from_response(data) | ||
| 71 | results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results) | ||
| 72 | |||
| 73 | logger.info(f"Copyparty search complete. Found {len(results)} matches.") | ||
| 74 | return results | ||
| 75 | else: | ||
| 76 | logger.error(f"Copyparty API returned status {response.status}") | ||
| 77 | return [] | ||
| 78 | |||
| 79 | except aiohttp.ClientError as e: | ||
| 80 | logger.error(f"HTTP error during copyparty search: {e}") | ||
| 81 | return [] | ||
| 82 | except Exception as e: | ||
| 83 | logger.error(f"Error during copyparty search: {str(e)}", exc_info=True) | ||
| 84 | return [] | ||
| 85 | |||
| 86 | @staticmethod | ||
| 87 | def _extract_files_from_response(data): | ||
| 88 | """Extract files list from copyparty API response.""" | ||
| 89 | if isinstance(data, dict): | ||
| 90 | if 'hits' in data: | ||
| 91 | return data['hits'] | ||
| 92 | elif 'files' in data: | ||
| 93 | return data['files'] | ||
| 94 | elif 'results' in data: | ||
| 95 | return data['results'] | ||
| 96 | else: | ||
| 97 | return data if isinstance(data, list) else [] | ||
| 98 | elif isinstance(data, list): | ||
| 99 | return data | ||
| 100 | else: | ||
| 101 | return [] | ||
| 102 | |||
| 103 | @staticmethod | ||
| 104 | def _convert_files_to_urls(files, baseurl, max_results): | ||
| 105 | """Convert file info objects to URLs.""" | ||
| 106 | results = [] | ||
| 107 | |||
| 108 | for file_info in files[:max_results]: | ||
| 109 | if isinstance(file_info, dict): | ||
| 110 | file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl) | ||
| 111 | if file_url: | ||
| 112 | results.append(file_url) | ||
| 113 | elif isinstance(file_info, str): | ||
| 114 | if file_info.startswith('http'): | ||
| 115 | results.append(file_info) | ||
| 116 | else: | ||
| 117 | results.append(f"{baseurl}/{file_info.lstrip('/')}") | ||
| 118 | |||
| 119 | return results | ||
| 120 | |||
| 121 | @staticmethod | ||
| 122 | def _extract_url_from_dict(file_info, baseurl): | ||
| 123 | """Extract URL from file info dictionary.""" | ||
| 124 | if 'url' in file_info: | ||
| 125 | return file_info['url'] | ||
| 126 | elif 'rp' in file_info: | ||
| 127 | return f"{baseurl}/{file_info['rp']}" | ||
| 128 | elif 'path' in file_info: | ||
| 129 | return f"{baseurl}/{file_info['path'].lstrip('/')}" | ||
| 130 | elif 'name' in file_info: | ||
| 131 | return f"{baseurl}/{file_info['name'].lstrip('/')}" | ||
| 132 | else: | ||
| 133 | return None | ||
| 134 | |||
| 135 | @staticmethod | ||
| 136 | async def search_copyparty_autocomplete(query: str, max_results: int = 10): | ||
| 137 | """Wrapper for autocomplete searches using copyparty (general search - no file type filtering). | ||
| 138 | |||
| 139 | Args: | ||
| 140 | query: Search query string | ||
| 141 | max_results: Maximum number of results to return | ||
| 142 | |||
| 143 | Returns: | ||
| 144 | List of matching file URLs | ||
| 145 | """ | ||
| 146 | if not query: | ||
| 147 | return [] | ||
| 148 | |||
| 149 | return await UniversalSearcher.search_copyparty(query, max_results) | ||
| 150 | |||
| 151 | @staticmethod | ||
| 152 | async def search_copyparty_autocomplete_audio(query: str, max_results: int = 10): | ||
| 153 | """Wrapper for autocomplete searches using copyparty (audio files only). | ||
| 154 | |||
| 155 | Args: | ||
| 156 | query: Search query string | ||
| 157 | max_results: Maximum number of results to return | ||
| 158 | |||
| 159 | Returns: | ||
| 160 | List of matching file URLs (audio files only) | ||
| 161 | """ | ||
| 162 | if not query: | ||
| 163 | return [] | ||
| 164 | |||
| 165 | # Audio file extensions for music commands | ||
| 166 | audio_extensions = ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'mp4', "mkv"] | ||
| 167 | return await UniversalSearcher.search_copyparty(query, max_results, audio_extensions) | ||
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py deleted file mode 100644 index b1a585c..0000000 --- a/cogs/utils/settings.py +++ /dev/null | |||
| @@ -1,114 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import logging | ||
| 7 | import sqlite3 | ||
| 8 | import os | ||
| 9 | from dataclasses import dataclass, field | ||
| 10 | |||
| 11 | from cogs.utils.database import get_connection, init_database | ||
| 12 | |||
| 13 | logger = logging.getLogger(__name__) | ||
| 14 | |||
| 15 | DEFAULT_BASEURL = "http://localhost:3939" | ||
| 16 | |||
| 17 | init_database() | ||
| 18 | |||
| 19 | |||
| 20 | @dataclass | ||
| 21 | class Settings: | ||
| 22 | volume: float = 1.0 | ||
| 23 | baseurl: str = DEFAULT_BASEURL | ||
| 24 | _data: dict = field(default_factory=dict, init=False) | ||
| 25 | |||
| 26 | async def load(self) -> "Settings": | ||
| 27 | default_settings = { | ||
| 28 | "volume": 1.0, | ||
| 29 | "baseurl": DEFAULT_BASEURL, | ||
| 30 | } | ||
| 31 | |||
| 32 | try: | ||
| 33 | with get_connection() as conn: | ||
| 34 | c = conn.cursor() | ||
| 35 | c.execute("SELECT key, value FROM settings") | ||
| 36 | rows = c.fetchall() | ||
| 37 | |||
| 38 | if rows: | ||
| 39 | self._data = {row[0]: row[1] for row in rows} | ||
| 40 | self._sync_from_data() | ||
| 41 | logger.info("Settings loaded from database") | ||
| 42 | else: | ||
| 43 | self._data = default_settings.copy() | ||
| 44 | self._sync_from_data() | ||
| 45 | await self.save() | ||
| 46 | logger.info("Created default settings in database") | ||
| 47 | except Exception as e: | ||
| 48 | logger.error(f"Error loading settings: {e}") | ||
| 49 | self._data = default_settings.copy() | ||
| 50 | self._sync_from_data() | ||
| 51 | |||
| 52 | return self | ||
| 53 | |||
| 54 | def _sync_from_data(self): | ||
| 55 | try: | ||
| 56 | self.volume = float(self._data.get("volume", 1.0)) | ||
| 57 | except (ValueError, TypeError): | ||
| 58 | self.volume = 1.0 | ||
| 59 | self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) | ||
| 60 | |||
| 61 | def _sync_to_data(self): | ||
| 62 | self._data["volume"] = str(self.volume) | ||
| 63 | self._data["baseurl"] = self.baseurl | ||
| 64 | |||
| 65 | async def save(self): | ||
| 66 | self._sync_to_data() | ||
| 67 | with get_connection() as conn: | ||
| 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() | ||
| 76 | |||
| 77 | |||
| 78 | _settings_instance: Settings | None = None | ||
| 79 | |||
| 80 | |||
| 81 | async def load_settings() -> Settings: | ||
| 82 | global _settings_instance | ||
| 83 | _settings_instance = Settings() | ||
| 84 | await _settings_instance.load() | ||
| 85 | return _settings_instance | ||
| 86 | |||
| 87 | |||
| 88 | async def save_settings(): | ||
| 89 | if _settings_instance: | ||
| 90 | await _settings_instance.save() | ||
| 91 | |||
| 92 | |||
| 93 | def get_settings() -> Settings | None: | ||
| 94 | return _settings_instance | ||
| 95 | |||
| 96 | |||
| 97 | def get_volume() -> float: | ||
| 98 | return _settings_instance.volume if _settings_instance else 1.0 | ||
| 99 | |||
| 100 | |||
| 101 | def get_baseurl() -> str: | ||
| 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/temp.py b/cogs/utils/temp.py deleted file mode 100644 index 0b2c8d7..0000000 --- a/cogs/utils/temp.py +++ /dev/null | |||
| @@ -1,18 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import tempfile | ||
| 8 | |||
| 9 | def get_temp_dir(subdir: str = "senator_sperma") -> str: | ||
| 10 | """Get or create a temp subdirectory. | ||
| 11 | |||
| 12 | Uses system temp directory (/tmp on Linux, %TEMP% on Windows). | ||
| 13 | OS handles cleanup automatically. | ||
| 14 | """ | ||
| 15 | base_temp = tempfile.gettempdir() | ||
| 16 | temp_path = os.path.join(base_temp, subdir) | ||
| 17 | os.makedirs(temp_path, exist_ok=True) | ||
| 18 | return temp_path | ||
diff --git a/cogs/utils/url_helper.py b/cogs/utils/url_helper.py deleted file mode 100644 index eac06f1..0000000 --- a/cogs/utils/url_helper.py +++ /dev/null | |||
| @@ -1,21 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | |||
| 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 deleted file mode 100644 index 35826d4..0000000 --- a/cogs/vclogs.py +++ /dev/null | |||
| @@ -1,316 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import datetime | ||
| 7 | import logging | ||
| 8 | import os | ||
| 9 | import asyncio | ||
| 10 | import discord | ||
| 11 | from discord.ext import commands | ||
| 12 | from gtts import gTTS | ||
| 13 | from cogs.voice import get_voice_manager | ||
| 14 | from cogs.utils.settings import get_volume, save_settings | ||
| 15 | from cogs.utils.database import get_connection, init_database | ||
| 16 | |||
| 17 | import tempfile | ||
| 18 | |||
| 19 | logger = logging.getLogger(__name__) | ||
| 20 | |||
| 21 | init_database() | ||
| 22 | |||
| 23 | |||
| 24 | def load_activity_data(): | ||
| 25 | try: | ||
| 26 | with get_connection() as conn: | ||
| 27 | c = conn.cursor() | ||
| 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 {} | ||
| 46 | |||
| 47 | |||
| 48 | def save_activity_data(data): | ||
| 49 | try: | ||
| 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}") | ||
| 75 | |||
| 76 | |||
| 77 | def format_duration(seconds): | ||
| 78 | hours, remainder = divmod(int(seconds), 3600) | ||
| 79 | minutes, seconds = divmod(remainder, 60) | ||
| 80 | if hours > 0: | ||
| 81 | return f"{hours}h {minutes}m {seconds}s" | ||
| 82 | elif minutes > 0: | ||
| 83 | return f"{minutes}m {seconds}s" | ||
| 84 | return f"{seconds}s" | ||
| 85 | |||
| 86 | |||
| 87 | class VcLogs(commands.Cog): | ||
| 88 | def __init__(self, bot): | ||
| 89 | self.bot = bot | ||
| 90 | self._voice_manager = None | ||
| 91 | |||
| 92 | def get_voice_manager(self): | ||
| 93 | if self._voice_manager is None: | ||
| 94 | self._voice_manager = get_voice_manager(self.bot) | ||
| 95 | return self._voice_manager | ||
| 96 | |||
| 97 | @staticmethod | ||
| 98 | async def handle_state_change(member, channel, state_type, is_enabled, log_channel): | ||
| 99 | """Helper method to handle voice state changes""" | ||
| 100 | state_messages = { | ||
| 101 | 'deaf': { | ||
| 102 | True: f"{member.display_name} jest bity przez ojca", | ||
| 103 | False: f"{member.display_name} uciek od ojca" | ||
| 104 | }, | ||
| 105 | 'mute': { | ||
| 106 | True: f"{member.display_name} idzie robić jajecznice", | ||
| 107 | False: f"{member.display_name} zrobił jajecznice" | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | tts_text = state_messages[state_type][is_enabled] | ||
| 112 | return tts_text, channel | ||
| 113 | |||
| 114 | async def play_tts(self, channel, text): | ||
| 115 | from cogs.utils.temp import get_temp_dir | ||
| 116 | |||
| 117 | voice_mgr = self.get_voice_manager() | ||
| 118 | temp_dir = get_temp_dir() | ||
| 119 | tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") | ||
| 120 | tts = gTTS(text=text, lang='pl') | ||
| 121 | logger.info("saving file") | ||
| 122 | |||
| 123 | loop = asyncio.get_event_loop() | ||
| 124 | await loop.run_in_executor(None, tts.save, tts_file) | ||
| 125 | |||
| 126 | try: | ||
| 127 | voice_status = await voice_mgr.get_voice_status(channel.guild) | ||
| 128 | voice_client = voice_status[0] if voice_status else None | ||
| 129 | is_connected = voice_status[1] if voice_status else False | ||
| 130 | is_playing = voice_status[2] if voice_status else False | ||
| 131 | |||
| 132 | if is_connected: | ||
| 133 | from cogs.voice import is_voice_client_alive | ||
| 134 | if not is_voice_client_alive(voice_client, channel.guild): | ||
| 135 | logger.warning("Stale voice client in play_tts, reconnecting") | ||
| 136 | try: | ||
| 137 | await voice_client.disconnect() | ||
| 138 | except Exception: | ||
| 139 | pass | ||
| 140 | is_connected = False | ||
| 141 | |||
| 142 | if not is_connected: | ||
| 143 | voice_client = await voice_mgr.join_channel(channel) | ||
| 144 | voice_status = await voice_mgr.get_voice_status(channel.guild) | ||
| 145 | voice_client = voice_status[0] if voice_status else None | ||
| 146 | is_connected = voice_status[1] if voice_status else False | ||
| 147 | is_playing = voice_status[2] if voice_status else False | ||
| 148 | |||
| 149 | if not is_connected or voice_client is None: | ||
| 150 | logger.error("Failed to join voice channel") | ||
| 151 | return | ||
| 152 | |||
| 153 | volume = get_volume() | ||
| 154 | logger.info(f"connected: {voice_client}") | ||
| 155 | |||
| 156 | if voice_client: | ||
| 157 | if is_playing: | ||
| 158 | logger.info("voice already playing, skipping TTS") | ||
| 159 | return | ||
| 160 | |||
| 161 | logger.info("preparing to play audio") | ||
| 162 | audio_source = discord.FFmpegPCMAudio(tts_file) | ||
| 163 | await voice_mgr.play_source(channel, audio_source, volume=volume) | ||
| 164 | except Exception as e: | ||
| 165 | logger.error(f"TTS playback error: {e}") | ||
| 166 | finally: | ||
| 167 | if os.path.exists(tts_file): | ||
| 168 | os.remove(tts_file) | ||
| 169 | |||
| 170 | @commands.slash_command( | ||
| 171 | name="log_volume" | ||
| 172 | ) | ||
| 173 | async def volume(self, ctx: discord.ApplicationContext, volume: int): | ||
| 174 | from cogs.utils.settings import get_settings | ||
| 175 | settings = get_settings() | ||
| 176 | settings.volume = volume | ||
| 177 | await save_settings() | ||
| 178 | await ctx.followup.send(f"Volume has been set to: {volume}") | ||
| 179 | logger.info(f"Volume changed to: {volume}") | ||
| 180 | |||
| 181 | @commands.Cog.listener() | ||
| 182 | async def on_voice_state_update(self, member, before, after): | ||
| 183 | log_channel_id = os.getenv("STATUS_CHANNEL_ID") | ||
| 184 | if not log_channel_id: | ||
| 185 | logger.warning("STATUS_CHANNEL_ID not set in environment") | ||
| 186 | return | ||
| 187 | |||
| 188 | log_channel = self.bot.get_channel(int(log_channel_id)) | ||
| 189 | if log_channel is None: | ||
| 190 | logger.warning("Log channel not found or not accessible") | ||
| 191 | return | ||
| 192 | |||
| 193 | current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") | ||
| 194 | current_ts = datetime.datetime.now().timestamp() | ||
| 195 | tts_text = None | ||
| 196 | tts_channel = None | ||
| 197 | |||
| 198 | if member.bot: | ||
| 199 | return | ||
| 200 | |||
| 201 | user_id = str(member.id) | ||
| 202 | activity_data = load_activity_data() | ||
| 203 | |||
| 204 | if before.channel is None and after.channel is not None: | ||
| 205 | is_afk = after.self_mute or after.self_deaf | ||
| 206 | activity_data[user_id] = { | ||
| 207 | 'join_time': current_ts, | ||
| 208 | 'total_active': 0, | ||
| 209 | 'total_afk': 0, | ||
| 210 | 'is_afk': is_afk, | ||
| 211 | 'afk_start': current_ts if is_afk else None, | ||
| 212 | 'active_start': current_ts if not is_afk else None, | ||
| 213 | 'channel_name': after.channel.name | ||
| 214 | } | ||
| 215 | save_activity_data(activity_data) | ||
| 216 | |||
| 217 | message = f"[{current_time}] {member.name} dołączył na {after.channel.name} <t:{int(current_ts)}:R>" | ||
| 218 | tts_text = f"{member.display_name} sie zespawnił" | ||
| 219 | tts_channel = after.channel | ||
| 220 | await log_channel.send(message) | ||
| 221 | logger.info(f"User joined voice channel: {message}") | ||
| 222 | |||
| 223 | elif before.channel is not None and after.channel is None: | ||
| 224 | if user_id in activity_data: | ||
| 225 | data = activity_data[user_id] | ||
| 226 | |||
| 227 | if data.get('is_afk') and data.get('afk_start'): | ||
| 228 | afk_duration = current_ts - data['afk_start'] | ||
| 229 | data['total_afk'] += afk_duration | ||
| 230 | elif data.get('active_start'): | ||
| 231 | active_since_start = current_ts - data['active_start'] | ||
| 232 | data['total_active'] += active_since_start | ||
| 233 | |||
| 234 | active_str = format_duration(data['total_active']) | ||
| 235 | afk_str = format_duration(data['total_afk']) | ||
| 236 | |||
| 237 | message = ( | ||
| 238 | f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>\n" | ||
| 239 | f"Aktywny: {active_str} | AFK: {afk_str}" | ||
| 240 | ) | ||
| 241 | tts_text = f"{member.display_name} jest chujem" | ||
| 242 | tts_channel = before.channel | ||
| 243 | await log_channel.send(message) | ||
| 244 | logger.info(f"User left voice channel: {message}") | ||
| 245 | |||
| 246 | del activity_data[user_id] | ||
| 247 | save_activity_data(activity_data) | ||
| 248 | else: | ||
| 249 | message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>" | ||
| 250 | tts_text = f"{member.display_name} jest chujem" | ||
| 251 | tts_channel = before.channel | ||
| 252 | await log_channel.send(message) | ||
| 253 | logger.info(f"User left voice channel: {message}") | ||
| 254 | |||
| 255 | elif user_id in activity_data: | ||
| 256 | data = activity_data[user_id] | ||
| 257 | |||
| 258 | if data.get('is_afk') and not (after.self_mute or after.self_deaf): | ||
| 259 | afk_duration = current_ts - data['afk_start'] | ||
| 260 | data['total_afk'] += afk_duration | ||
| 261 | data['afk_start'] = None | ||
| 262 | data['is_afk'] = False | ||
| 263 | data['active_start'] = current_ts | ||
| 264 | save_activity_data(activity_data) | ||
| 265 | |||
| 266 | afk_str = format_duration(afk_duration) | ||
| 267 | |||
| 268 | if after.self_mute: | ||
| 269 | activity = "odciszył" | ||
| 270 | else: | ||
| 271 | activity = "odgłuchnął" | ||
| 272 | |||
| 273 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n" | ||
| 274 | f"AFK: {afk_str}" ) | ||
| 275 | await log_channel.send(message) | ||
| 276 | logger.info(f"User returned from AFK: {message}") | ||
| 277 | elif not data.get('is_afk') and (after.self_mute or after.self_deaf): | ||
| 278 | active_since_start = 0 | ||
| 279 | if data.get('active_start'): | ||
| 280 | active_since_start = current_ts - data['active_start'] | ||
| 281 | data['total_active'] += active_since_start | ||
| 282 | data['afk_start'] = current_ts | ||
| 283 | data['is_afk'] = True | ||
| 284 | data['active_start'] = None | ||
| 285 | save_activity_data(activity_data) | ||
| 286 | |||
| 287 | if after.self_mute: | ||
| 288 | activity = "wyciszył" | ||
| 289 | else: | ||
| 290 | activity = "ogłuchnął" | ||
| 291 | |||
| 292 | active_str = format_duration(active_since_start) | ||
| 293 | message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n" | ||
| 294 | f"Aktywny: {active_str}" ) | ||
| 295 | await log_channel.send(message) | ||
| 296 | logger.info(f"User went AFK: {message}") | ||
| 297 | |||
| 298 | if before.self_deaf != after.self_deaf: | ||
| 299 | tts_text, tts_channel = await self.handle_state_change( | ||
| 300 | member, after.channel, 'deaf', after.self_deaf, log_channel | ||
| 301 | ) | ||
| 302 | if tts_text and tts_channel: | ||
| 303 | await self.play_tts(tts_channel, tts_text) | ||
| 304 | return | ||
| 305 | |||
| 306 | if before.self_mute != after.self_mute: | ||
| 307 | tts_text, tts_channel = await self.handle_state_change( | ||
| 308 | member, after.channel, 'mute', after.self_mute, log_channel | ||
| 309 | ) | ||
| 310 | |||
| 311 | if tts_text and tts_channel: | ||
| 312 | await self.play_tts(tts_channel, tts_text) | ||
| 313 | |||
| 314 | |||
| 315 | def setup(bot): | ||
| 316 | bot.add_cog(VcLogs(bot)) | ||
diff --git a/cogs/voice.py b/cogs/voice.py deleted file mode 100644 index a224336..0000000 --- a/cogs/voice.py +++ /dev/null | |||
| @@ -1,444 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import discord | ||
| 7 | import logging | ||
| 8 | import asyncio | ||
| 9 | import os | ||
| 10 | import urllib.parse | ||
| 11 | from dataclasses import dataclass, field | ||
| 12 | from typing import Any | ||
| 13 | |||
| 14 | import mutagen | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | ||
| 17 | |||
| 18 | |||
| 19 | _voice_managers: dict[int, "VoiceManager"] = {} | ||
| 20 | |||
| 21 | |||
| 22 | def get_voice_manager(bot) -> "VoiceManager": | ||
| 23 | """Get or create VoiceManager for a specific bot instance.""" | ||
| 24 | bot_id = bot.user.id if bot.user else id(bot) | ||
| 25 | if bot_id not in _voice_managers: | ||
| 26 | _voice_managers[bot_id] = VoiceManager(bot) | ||
| 27 | return _voice_managers[bot_id] | ||
| 28 | |||
| 29 | |||
| 30 | def is_voice_client_alive(voice_client, guild): | ||
| 31 | """Check if a voice client connection is healthy.""" | ||
| 32 | if voice_client is None: | ||
| 33 | return False | ||
| 34 | if not voice_client.is_connected(): | ||
| 35 | return False | ||
| 36 | if voice_client.channel is None: | ||
| 37 | return False | ||
| 38 | if guild.get_channel(voice_client.channel.id) is None: | ||
| 39 | return False | ||
| 40 | |||
| 41 | try: | ||
| 42 | ws = voice_client._connection | ||
| 43 | s = ws.socket | ||
| 44 | if s is None or s.fileno() == -1: | ||
| 45 | return False | ||
| 46 | except Exception: | ||
| 47 | return False | ||
| 48 | |||
| 49 | return True | ||
| 50 | |||
| 51 | |||
| 52 | @dataclass | ||
| 53 | class VoiceManager: | ||
| 54 | bot: Any | ||
| 55 | _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) | ||
| 56 | _connecting: set[int] = field(default_factory=set, init=False) | ||
| 57 | |||
| 58 | # --- Connection ----------------------------------------------------------- | ||
| 59 | |||
| 60 | async def _ensure_voice_client(self, channel): | ||
| 61 | """Unified connect/move/reconnect logic. | ||
| 62 | |||
| 63 | Returns a healthy VoiceClient connected to *channel*, or None on failure. | ||
| 64 | """ | ||
| 65 | guild = channel.guild | ||
| 66 | guild_id = guild.id | ||
| 67 | |||
| 68 | async with self._lock: | ||
| 69 | if guild_id in self._connecting: | ||
| 70 | logger.info(f"Connection already in progress for guild {guild_id}, skipping") | ||
| 71 | return None | ||
| 72 | |||
| 73 | vc = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 74 | |||
| 75 | # --- resolve existing client -------------------------------------- | ||
| 76 | if vc is not None: | ||
| 77 | if vc.is_connected(): | ||
| 78 | if vc.channel == channel: | ||
| 79 | if is_voice_client_alive(vc, guild): | ||
| 80 | logger.info(f"Already connected to {channel}") | ||
| 81 | return vc | ||
| 82 | logger.warning(f"Stale connection to {channel}, reconnecting") | ||
| 83 | # Disconnect resets the state machine to 'disconnected' | ||
| 84 | # but keeps the VoiceClient alive. Reusing it avoids a | ||
| 85 | # race where Discord sends VOICE_SERVER_UPDATE between | ||
| 86 | # the destroy and the fresh-connect below, causing the | ||
| 87 | # new client to timeout waiting for the event (since the | ||
| 88 | # old one already consumed it). | ||
| 89 | try: | ||
| 90 | await vc.disconnect() | ||
| 91 | except Exception: | ||
| 92 | pass | ||
| 93 | try: | ||
| 94 | await vc.connect( | ||
| 95 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False | ||
| 96 | ) | ||
| 97 | logger.info(f"Reconnected to voice channel: {channel}") | ||
| 98 | return vc | ||
| 99 | except Exception as e: | ||
| 100 | logger.error(f"Failed to reconnect stale voice client: {e}") | ||
| 101 | try: | ||
| 102 | channel._state._remove_voice_client(guild.id) | ||
| 103 | except Exception: | ||
| 104 | pass | ||
| 105 | vc = None | ||
| 106 | else: | ||
| 107 | try: | ||
| 108 | await vc.move_to(channel) | ||
| 109 | logger.info(f"Moved to voice channel: {channel}") | ||
| 110 | return vc | ||
| 111 | except Exception as e: | ||
| 112 | logger.error(f"Failed to move voice client: {e}") | ||
| 113 | try: | ||
| 114 | await vc.disconnect() | ||
| 115 | except Exception: | ||
| 116 | pass | ||
| 117 | try: | ||
| 118 | await vc.connect( | ||
| 119 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False | ||
| 120 | ) | ||
| 121 | logger.info(f"Reconnected after failed move: {channel}") | ||
| 122 | return vc | ||
| 123 | except Exception: | ||
| 124 | pass | ||
| 125 | try: | ||
| 126 | channel._state._remove_voice_client(guild.id) | ||
| 127 | except Exception: | ||
| 128 | pass | ||
| 129 | vc = None | ||
| 130 | else: | ||
| 131 | logger.info("Voice client exists but disconnected, reconnecting") | ||
| 132 | try: | ||
| 133 | await vc.disconnect() | ||
| 134 | except Exception: | ||
| 135 | pass | ||
| 136 | try: | ||
| 137 | await vc.connect( | ||
| 138 | reconnect=False, timeout=10.0, self_deaf=False, self_mute=False | ||
| 139 | ) | ||
| 140 | logger.info(f"Reconnected disconnected client: {channel}") | ||
| 141 | return vc | ||
| 142 | except Exception as e: | ||
| 143 | logger.error(f"Failed to reconnect disconnected client: {e}") | ||
| 144 | try: | ||
| 145 | channel._state._remove_voice_client(guild.id) | ||
| 146 | except Exception: | ||
| 147 | pass | ||
| 148 | vc = None | ||
| 149 | |||
| 150 | # --- create fresh connection -------------------------------------- | ||
| 151 | self._connecting.add(guild_id) | ||
| 152 | try: | ||
| 153 | await asyncio.sleep(0.3) | ||
| 154 | try: | ||
| 155 | vc = await channel.connect(timeout=10.0, reconnect=False) | ||
| 156 | logger.info(f"Connected to voice channel: {channel}") | ||
| 157 | return vc | ||
| 158 | except discord.ClientException as e: | ||
| 159 | if "Already connected" in str(e): | ||
| 160 | logger.warning(f"Stale voice state detected, forcing reconnect: {e}") | ||
| 161 | vc = discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 162 | if vc: | ||
| 163 | try: | ||
| 164 | await vc.disconnect() | ||
| 165 | except Exception: | ||
| 166 | pass | ||
| 167 | else: | ||
| 168 | try: | ||
| 169 | channel._state._remove_voice_client(guild.id) | ||
| 170 | except Exception: | ||
| 171 | pass | ||
| 172 | await asyncio.sleep(0.3) | ||
| 173 | try: | ||
| 174 | vc = await channel.connect(timeout=10.0, reconnect=False) | ||
| 175 | logger.info(f"Reconnected to voice channel: {channel}") | ||
| 176 | return vc | ||
| 177 | except Exception as e2: | ||
| 178 | logger.error(f"Failed to reconnect after stale cleanup: {e2}") | ||
| 179 | return None | ||
| 180 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 181 | return None | ||
| 182 | except Exception as e: | ||
| 183 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 184 | return None | ||
| 185 | finally: | ||
| 186 | self._connecting.discard(guild_id) | ||
| 187 | |||
| 188 | async def connect(self, channel): | ||
| 189 | """Join a voice channel (public alias).""" | ||
| 190 | return await self._ensure_voice_client(channel) | ||
| 191 | |||
| 192 | async def join_channel(self, channel): | ||
| 193 | """Backward-compatible alias for connect().""" | ||
| 194 | return await self.connect(channel) | ||
| 195 | |||
| 196 | async def join_by_channel_id(self, channel_id): | ||
| 197 | """Join a voice channel by channel ID.""" | ||
| 198 | channel = self.bot.get_channel(channel_id) | ||
| 199 | if not channel: | ||
| 200 | logger.error(f"Channel {channel_id} not found") | ||
| 201 | return None | ||
| 202 | if not isinstance(channel, discord.VoiceChannel): | ||
| 203 | logger.error(f"Channel {channel_id} is not a voice channel") | ||
| 204 | return None | ||
| 205 | return await self.connect(channel) | ||
| 206 | |||
| 207 | async def get_client(self, guild): | ||
| 208 | """Get existing voice client for a guild.""" | ||
| 209 | return discord.utils.get(self.bot.voice_clients, guild=guild) | ||
| 210 | |||
| 211 | async def get_voice_client(self, guild): | ||
| 212 | """Backward-compatible alias for get_client().""" | ||
| 213 | return await self.get_client(guild) | ||
| 214 | |||
| 215 | async def get_status(self, guild): | ||
| 216 | """Get voice client status. | ||
| 217 | |||
| 218 | Returns: | ||
| 219 | tuple: (voice_client, is_connected, is_playing) or (None, False, False) | ||
| 220 | """ | ||
| 221 | vc = await self.get_client(guild) | ||
| 222 | if vc is None: | ||
| 223 | return None, False, False | ||
| 224 | return vc, vc.is_connected(), vc.is_playing() | ||
| 225 | |||
| 226 | async def get_voice_status(self, guild): | ||
| 227 | """Backward-compatible alias for get_status().""" | ||
| 228 | return await self.get_status(guild) | ||
| 229 | |||
| 230 | async def disconnect(self, guild): | ||
| 231 | """Stop playback and disconnect from voice.""" | ||
| 232 | vc = await self.get_client(guild) | ||
| 233 | if vc: | ||
| 234 | vc.stop() | ||
| 235 | logger.info("Disconnecting from voice") | ||
| 236 | await vc.disconnect() | ||
| 237 | logger.info(f"Disconnected from guild {guild}") | ||
| 238 | |||
| 239 | async def stop(self, guild): | ||
| 240 | """Backward-compatible alias for disconnect().""" | ||
| 241 | await self.disconnect(guild) | ||
| 242 | |||
| 243 | # --- Playback ------------------------------------------------------------ | ||
| 244 | |||
| 245 | async def _play_await(self, vc, audio_source): | ||
| 246 | """Play an audio source and wait for completion using an asyncio.Future. | ||
| 247 | |||
| 248 | Returns True on success, False on error. No polling, no magic sleeps. | ||
| 249 | """ | ||
| 250 | loop = asyncio.get_event_loop() | ||
| 251 | future = loop.create_future() | ||
| 252 | |||
| 253 | def after(error): | ||
| 254 | if not future.done(): | ||
| 255 | if error: | ||
| 256 | future.set_exception(Exception(str(error))) | ||
| 257 | else: | ||
| 258 | future.set_result(True) | ||
| 259 | |||
| 260 | vc.play(audio_source, after=after) | ||
| 261 | |||
| 262 | try: | ||
| 263 | await future | ||
| 264 | return True | ||
| 265 | except Exception as e: | ||
| 266 | logger.error(f"Playback error: {e}") | ||
| 267 | return False | ||
| 268 | |||
| 269 | async def play_source(self, channel, audio_source, interrupt=True, volume=1.0): | ||
| 270 | """Connect to *channel* and play a pre-made audio source. | ||
| 271 | |||
| 272 | Returns True on success, False on failure. | ||
| 273 | """ | ||
| 274 | vc = await self.connect(channel) | ||
| 275 | if not vc: | ||
| 276 | logger.error("play_source: could not connect to voice channel") | ||
| 277 | return False | ||
| 278 | |||
| 279 | if interrupt and (vc.is_playing() or vc.is_paused()): | ||
| 280 | vc.stop() | ||
| 281 | |||
| 282 | if abs(volume - 1.0) > 1e-9: | ||
| 283 | audio_source = discord.PCMVolumeTransformer(audio_source) | ||
| 284 | audio_source.volume = volume | ||
| 285 | |||
| 286 | logger.info("Playing via play_source") | ||
| 287 | return await self._play_await(vc, audio_source) | ||
| 288 | |||
| 289 | # --- Source preparation (folded from AudioPlaybackManager) --------------- | ||
| 290 | |||
| 291 | @staticmethod | ||
| 292 | async def validate_voice_channel(ctx): | ||
| 293 | """Validate user is in a voice channel. Returns the channel or None.""" | ||
| 294 | if not ctx.author.voice or not ctx.author.voice.channel: | ||
| 295 | await ctx.respond( | ||
| 296 | "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True | ||
| 297 | ) | ||
| 298 | return None | ||
| 299 | return ctx.author.voice.channel | ||
| 300 | |||
| 301 | @staticmethod | ||
| 302 | async def prepare_file_source(source): | ||
| 303 | """Validate a local file and return (display_name, absolute_path) or (None, None).""" | ||
| 304 | logger.info(f"Attempting to play local file: {source}") | ||
| 305 | |||
| 306 | if not os.path.isabs(source): | ||
| 307 | source = os.path.abspath(source) | ||
| 308 | logger.debug(f"Converted to absolute path: {source}") | ||
| 309 | |||
| 310 | if not os.path.exists(source): | ||
| 311 | logger.error(f"File not found: {source}") | ||
| 312 | return None, None | ||
| 313 | |||
| 314 | filename = os.path.basename(source) | ||
| 315 | display_name = os.path.splitext(filename)[0] | ||
| 316 | return display_name, source | ||
| 317 | |||
| 318 | @staticmethod | ||
| 319 | async def prepare_audio_source(source, is_url, display_name): | ||
| 320 | """Prepare FFmpegPCMAudio from a URL or local file. | ||
| 321 | |||
| 322 | Returns (audio_source, final_display_name). | ||
| 323 | """ | ||
| 324 | if is_url: | ||
| 325 | before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" | ||
| 326 | audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) | ||
| 327 | filename = os.path.basename(source.split("?")[0]) | ||
| 328 | filename = urllib.parse.unquote(filename) | ||
| 329 | clean_filename = os.path.splitext(filename)[0] | ||
| 330 | return audio_source, clean_filename | ||
| 331 | else: | ||
| 332 | audio_source = discord.FFmpegPCMAudio(source=source) | ||
| 333 | try: | ||
| 334 | metadata = mutagen.open(source) | ||
| 335 | if metadata: | ||
| 336 | title = ( | ||
| 337 | metadata.get("title", [None])[0] | ||
| 338 | if hasattr(metadata, "get") | ||
| 339 | else None | ||
| 340 | ) | ||
| 341 | if title: | ||
| 342 | display_name = title | ||
| 343 | logger.debug(f"Read metadata - title: {title}") | ||
| 344 | else: | ||
| 345 | display_name = os.path.splitext(os.path.basename(source))[0] | ||
| 346 | except Exception as e: | ||
| 347 | logger.warning(f"Could not read metadata from {source}: {str(e)}") | ||
| 348 | display_name = os.path.splitext(os.path.basename(source))[0] | ||
| 349 | |||
| 350 | return audio_source, display_name | ||
| 351 | |||
| 352 | @staticmethod | ||
| 353 | async def prepare_source(source): | ||
| 354 | """Determine whether *source* is a URL or local file. | ||
| 355 | |||
| 356 | Returns (source_path, display_name, is_url) or (None, None, None) on failure. | ||
| 357 | """ | ||
| 358 | is_url = source.startswith(("http://", "https://")) | ||
| 359 | |||
| 360 | if is_url: | ||
| 361 | logger.info(f"Attempting to play stream from URL: {source}") | ||
| 362 | return source, source, is_url | ||
| 363 | |||
| 364 | display_name, absolute_source = await VoiceManager.prepare_file_source(source) | ||
| 365 | if not display_name: | ||
| 366 | return None, None, None | ||
| 367 | |||
| 368 | return absolute_source, display_name, is_url | ||
| 369 | |||
| 370 | @staticmethod | ||
| 371 | async def cleanup_temp_file(source): | ||
| 372 | """Remove a temporary file if it exists.""" | ||
| 373 | if source and os.path.exists(source): | ||
| 374 | try: | ||
| 375 | os.remove(source) | ||
| 376 | logger.info(f"Cleaned up temporary file: {source}") | ||
| 377 | except Exception as e: | ||
| 378 | logger.error(f"Error cleaning up file {source}: {str(e)}") | ||
| 379 | |||
| 380 | @staticmethod | ||
| 381 | async def cleanup_if_needed(source, delete_after_play, is_url): | ||
| 382 | """Conditionally clean up a file after playback.""" | ||
| 383 | if delete_after_play and not is_url: | ||
| 384 | await VoiceManager.cleanup_temp_file(source) | ||
| 385 | |||
| 386 | # --- High-level play (full flow) ----------------------------------------- | ||
| 387 | |||
| 388 | async def play(self, ctx, source, delete_after_play=False, interrupt=True): | ||
| 389 | """High-level playback: validate context, prepare source, connect, play, cleanup. | ||
| 390 | |||
| 391 | Sends ``grom: {name}`` immediately on start, and an error follow-up | ||
| 392 | if playback fails. | ||
| 393 | |||
| 394 | Args: | ||
| 395 | ctx: Discord application context (used for channel resolution & error messages). | ||
| 396 | source: Local file path or URL to stream. | ||
| 397 | delete_after_play: Remove local file after playback (no-op for URLs). | ||
| 398 | interrupt: Stop currently playing audio before starting. | ||
| 399 | |||
| 400 | Returns: | ||
| 401 | bool: True if playback completed successfully, False otherwise. | ||
| 402 | """ | ||
| 403 | prepared_source, display_name, is_url = await VoiceManager.prepare_source(source) | ||
| 404 | if not prepared_source: | ||
| 405 | await VoiceManager.cleanup_if_needed(source, delete_after_play, False) | ||
| 406 | return False | ||
| 407 | |||
| 408 | target_channel = await VoiceManager.validate_voice_channel(ctx) | ||
| 409 | if not target_channel: | ||
| 410 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 411 | return False | ||
| 412 | |||
| 413 | vc = await self.connect(target_channel) | ||
| 414 | if not vc: | ||
| 415 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 416 | return False | ||
| 417 | |||
| 418 | if interrupt and (vc.is_playing() or vc.is_paused()): | ||
| 419 | vc.stop() | ||
| 420 | |||
| 421 | audio_source, final_display_name = await VoiceManager.prepare_audio_source( | ||
| 422 | prepared_source, is_url, display_name | ||
| 423 | ) | ||
| 424 | |||
| 425 | try: | ||
| 426 | await ctx.followup.send(f"grom: {final_display_name}") | ||
| 427 | except Exception as e: | ||
| 428 | logger.error(e) | ||
| 429 | |||
| 430 | success = await self._play_await(vc, audio_source) | ||
| 431 | |||
| 432 | if success: | ||
| 433 | logger.info(f"Playback completed: {final_display_name}") | ||
| 434 | else: | ||
| 435 | logger.error(f"Playback failed: {prepared_source}") | ||
| 436 | try: | ||
| 437 | await ctx.followup.send( | ||
| 438 | "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True | ||
| 439 | ) | ||
| 440 | except Exception: | ||
| 441 | pass | ||
| 442 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 443 | |||
| 444 | return success | ||
diff --git a/cogs/web.py b/cogs/web.py deleted file mode 100644 index fd22f9f..0000000 --- a/cogs/web.py +++ /dev/null | |||
| @@ -1,90 +0,0 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import discord | ||
| 7 | from discord import Embed | ||
| 8 | from discord.ext import commands | ||
| 9 | import requests | ||
| 10 | from bs4 import BeautifulSoup | ||
| 11 | import urllib.parse | ||
| 12 | import yt_dlp | ||
| 13 | import logging | ||
| 14 | from cogs.utils.url_helper import clean_url_for_ytdlp | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | ||
| 17 | |||
| 18 | |||
| 19 | class Web(commands.Cog): | ||
| 20 | def __init__(self, bot): | ||
| 21 | self.bot = bot | ||
| 22 | |||
| 23 | def _extract_youtube_title(self, url): | ||
| 24 | """Extract YouTube video title if URL is a YouTube link.""" | ||
| 25 | if "youtube.com/watch" not in url: | ||
| 26 | return None | ||
| 27 | |||
| 28 | url = clean_url_for_ytdlp(url) | ||
| 29 | logger.info("youtube link") | ||
| 30 | try: | ||
| 31 | ydl_opts = {"quiet": True, "nowarnings": True} | ||
| 32 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: | ||
| 33 | info = ydl.extract_info(url, download=False) | ||
| 34 | return info.get("title") if info else None | ||
| 35 | except Exception: | ||
| 36 | pass | ||
| 37 | return None | ||
| 38 | |||
| 39 | def _process_search_results(self, links, query): | ||
| 40 | """Process search results and create embed.""" | ||
| 41 | embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) | ||
| 42 | view = discord.ui.View(disable_on_timeout=True) | ||
| 43 | |||
| 44 | for i, link in enumerate(links[:5], start=1): | ||
| 45 | title = link.text | ||
| 46 | youtube_title = self._extract_youtube_title(link.get("href")) | ||
| 47 | if youtube_title: | ||
| 48 | title = youtube_title | ||
| 49 | |||
| 50 | embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) | ||
| 51 | |||
| 52 | return embed, view | ||
| 53 | |||
| 54 | @commands.slash_command( | ||
| 55 | name="web", | ||
| 56 | description="Search the web using DuckDuckGo" | ||
| 57 | ) | ||
| 58 | async def web( | ||
| 59 | self, | ||
| 60 | ctx: discord.ApplicationContext, | ||
| 61 | query: str | ||
| 62 | ): | ||
| 63 | await ctx.defer() | ||
| 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"} | ||
| 65 | encoded_query = urllib.parse.quote_plus(query) | ||
| 66 | url = f"https://duckduckgo.com/html/?q={encoded_query}" | ||
| 67 | response = requests.get(url, headers=headers) | ||
| 68 | |||
| 69 | logger.info(f"Query: {query}") | ||
| 70 | |||
| 71 | if response.status_code != 200: | ||
| 72 | await ctx.respond(content=f"Error: {response.status_code}", ephemeral=True) | ||
| 73 | logger.error(f"Error: {response.status_code}") | ||
| 74 | return | ||
| 75 | |||
| 76 | soup = BeautifulSoup(response.text, "html.parser") | ||
| 77 | links = soup.find_all("a", class_="result__a") | ||
| 78 | |||
| 79 | if not links: | ||
| 80 | await ctx.respond(content="No results found.", ephemeral=True) | ||
| 81 | logger.warning("No results found") | ||
| 82 | return | ||
| 83 | |||
| 84 | embed, view = self._process_search_results(links, query) | ||
| 85 | await ctx.respond(embed=embed, view=view) | ||
| 86 | logger.info("Search results sent") | ||
| 87 | |||
| 88 | |||
| 89 | def setup(bot): | ||
| 90 | bot.add_cog(Web(bot)) | ||
