From cd8ed8bc5793985df2c1692072a932ceee517b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 8 Jul 2026 02:13:35 +0200 Subject: OPEN THE RUST GATES Signed-off-by: salmonik --- cogs/ai.py | 78 -------- cogs/assistant.py | 130 ------------ cogs/compression.py | 306 ---------------------------- cogs/conversion.py | 245 ----------------------- cogs/cron.py | 403 ------------------------------------- cogs/downloader.py | 234 ---------------------- cogs/math_commands.py | 83 -------- cogs/music/join.py | 29 --- cogs/music/loop.py | 40 ---- cogs/music/media_processing.py | 135 ------------- cogs/music/play_commands.py | 320 ----------------------------- cogs/music/stop.py | 29 --- cogs/qr.py | 154 -------------- cogs/say.py | 28 --- cogs/search.py | 73 ------- cogs/trigger.py | 260 ------------------------ cogs/tts.py | 111 ----------- cogs/uploader.py | 156 --------------- cogs/utils/database.py | 126 ------------ cogs/utils/file_search.py | 167 ---------------- cogs/utils/settings.py | 114 ----------- cogs/utils/temp.py | 18 -- cogs/utils/url_helper.py | 21 -- cogs/vclogs.py | 316 ----------------------------- cogs/voice.py | 444 ----------------------------------------- cogs/web.py | 90 --------- 26 files changed, 4110 deletions(-) delete mode 100644 cogs/ai.py delete mode 100644 cogs/assistant.py delete mode 100644 cogs/compression.py delete mode 100644 cogs/conversion.py delete mode 100644 cogs/cron.py delete mode 100644 cogs/downloader.py delete mode 100644 cogs/math_commands.py delete mode 100644 cogs/music/join.py delete mode 100644 cogs/music/loop.py delete mode 100644 cogs/music/media_processing.py delete mode 100644 cogs/music/play_commands.py delete mode 100644 cogs/music/stop.py delete mode 100644 cogs/qr.py delete mode 100644 cogs/say.py delete mode 100644 cogs/search.py delete mode 100644 cogs/trigger.py delete mode 100644 cogs/tts.py delete mode 100644 cogs/uploader.py delete mode 100644 cogs/utils/database.py delete mode 100644 cogs/utils/file_search.py delete mode 100644 cogs/utils/settings.py delete mode 100644 cogs/utils/temp.py delete mode 100644 cogs/utils/url_helper.py delete mode 100644 cogs/vclogs.py delete mode 100644 cogs/voice.py delete mode 100644 cogs/web.py (limited to 'cogs') 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import discord -from discord.ext import commands -import logging -import httpx -from gtts import gTTS -import io - -logger = logging.getLogger(__name__) - -class AI(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @commands.slash_command( - name="senator", - description="SEENATOR", - ) - async def senator( - self, - ctx: discord.ApplicationContext, - text: str = discord.Option(str, "message", required=True), - tts: str = discord.Option(bool, "face?", default=False) - ): - await ctx.defer() - - - url = "https://openrouter.ai/api/v1/chat/completions" - headers = { - "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", - "Content-Type": "application/json", - } - payload = { - "model": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", - "messages": [ - { - "role": "system", - "content": "You are Senator Steaven Sperma Armstrong from Metal Gear Rising Vengeance. \ - You are racist and you hate other minorities. Respond in plain text, no markdown tricks. \ - If the message is in Polish, respond in Polish. Respond in 100 words." - }, - { - "role": "user", - "content": text - } - ], - - } - async with httpx.AsyncClient() as client: - response = await client.post(url=url, headers=headers, json=payload) - response_data = response.json() - logger.info(response_data) - if not response_data["choices"]: - await ctx.send_followup("bot siem zesroł") - else: - response_text = response_data["choices"][0]["message"]["content"] - if tts: - tts_obj = gTTS(text=response_text, lang='pl') - tts_buffer = io.BytesIO() - tts_obj.write_to_fp(tts_buffer) - tts_buffer.seek(0) - from cogs.voice import get_voice_manager - vm = get_voice_manager(self.bot) - channel = ctx.author.voice.channel - await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False) - - else: - await ctx.send_followup(response_text) - - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import discord -from discord.ext import commands -import httpx -import logging -import os - -logger = logging.getLogger(__name__) - -class Jarvis(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.keyword = os.getenv("ASSISTANT_KEYWORD", "jarvis").lower() - - @commands.Cog.listener() - async def on_message(self, message): - if message.author == self.bot.user: - return - - logger.info(message.content) - - if message.mentions: - if message.mentions[0].id == self.bot.user.id: - await self.assistant(message) - - elif self.keyword in message.content.lower(): - await self.assistant(message) - - @staticmethod - async def assistant(message): - headers = { - "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", - "Content-Type": "application/json" - } - - chain = await Jarvis._build_message_chain(message) - messages = Jarvis._build_messages(message, chain) - payload = Jarvis._build_payload(messages) - - response_data = await Jarvis._safe_api_call(payload, headers) - if response_data is None: - await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.") - return - - response_message = Jarvis._parse_response(response_data) - - channel = message.channel - await message.reply(response_message) - - @staticmethod - async def _build_message_chain(message): - chain = [] - current = message - max_depth = 5 - - while current and len(chain) < max_depth: - chain.append(current) - if not current.reference: - break - try: - if current.reference.resolved: - current = current.reference.resolved - else: - current = await current.channel.fetch_message(current.reference.message_id) - except discord.NotFound: - break - - return list(reversed(chain)) - - @staticmethod - def _build_messages(message, chain=None): - messages = [{ - "role": "system", - "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis." - "Twoim zadaniem jest asystowanie użytkownika" - "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" - "nie pytaj sie użytkownika czy chce abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" - "Nie pozostawiaj wiadomości pustej oraz nie dawaj swojej wiadomości w pole myślenia" - }] - - if chain is None: - chain = [message] - - for msg in chain: - messages.append({ - "role": "user", - "content": f"USER: {msg.author.display_name}\nMESSAGE: {msg.content}" - }) - - return messages - - @staticmethod - def _build_payload(messages): - return { - "model": os.getenv("OPENROUTER_MODEL"), - "messages": messages, - "plugins": [{"id": "response-healing"}] - } - - @staticmethod - async def _make_api_request(payload, headers): - async with httpx.AsyncClient(timeout=30.0) as client: - url = "https://openrouter.ai/api/v1/chat/completions" - response = await client.post(url=url, headers=headers, json=payload) - response_data = response.json() - logger.info(response_data) - return response_data - - @staticmethod - async def _safe_api_call(payload, headers): - try: - return await Jarvis._make_api_request(payload, headers) - except httpx.HTTPError as e: - logger.error(f"API request failed: {e}") - return None - - @staticmethod - def _parse_response(response_data): - content = response_data['choices'][0]['message']['content'] - - logger.info(content) - - return content - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import mimetypes -import os -import asyncio -import aiofiles -import requests -import urllib.parse -from typing import List, Optional -from os import PathLike -import discord -from discord.ext import commands -from discord import AutocompleteContext -from cogs.utils import file_search as search_utils -import shlex -import logging - -logger = logging.getLogger(__name__) - -def filetype(filename): - mime_type, _ = mimetypes.guess_type(filename) - if mime_type: - if mime_type.startswith('video/'): - return "Video" - elif mime_type.startswith('audio/'): - return "Audio" - elif mime_type.startswith('image/'): - return "Image" - return "Unknown" - -class Compression(commands.Cog): - def __init__(self, bot): - from cogs.utils.temp import get_temp_dir - from cogs.utils.settings import get_baseurl - self.bot = bot - self.temp_dir = get_temp_dir() - self._cached_urls = {} - self._baseurl = get_baseurl() - - async def _file_autocomplete(self, ctx: AutocompleteContext): - query = ctx.value.lower() if ctx.value else "" - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) - - user_id = ctx.interaction.user.id - self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} - - choices = [] - for url in results[:25]: - filename = urllib.parse.unquote(url.split('/')[-1]) - display_name = filename[:99] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=filename)) - return choices - - async def _download_from_url(self, url: str) -> Optional[str]: - """Download file from copyparty URL and return local path""" - try: - response = requests.get(url) - if response.status_code != 200: - return None - - filename = url.split('/')[-1].split('?')[0] - filename = urllib.parse.unquote(filename) - file_path = os.path.join(self.temp_dir, filename) - - async with aiofiles.open(file_path, 'wb') as f: - await f.write(response.content) - return file_path - except Exception as e: - logger.error(f"Error downloading from {url}: {e}") - return None - - async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: - """Upload file to copyparty and return download URL""" - try: - async with aiofiles.open(file_path, 'rb') as f: - file_content = await f.read() - - files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} - headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} - params = {'j': '1', 'ck': 'b2'} - - response = requests.post(self._baseurl, files=files, headers=headers, params=params) - - if response.status_code in [200, 201]: - result = response.json() - return result.get('fileurl') - except Exception as e: - logger.error(f"Upload error: {e}") - return None - - def _process_video(self, file_path: str, output_path: str) -> str: - """Process video compression""" - 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)}" - return command - - def _process_audio(self, file_path: str, output_path: str) -> str: - """Process audio compression""" - if output_path.lower().endswith('.mp3'): - output_path = os.path.splitext(output_path)[0] + os.path.splitext(output_path)[1].replace('.mp3', '') - if not output_path.lower().endswith('.ogg'): - output_path = os.path.splitext(output_path)[0] + '.ogg' - command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:a libopus -b:a 500 -ar 12000 -ac 1 {shlex.quote(output_path)}" - return command - - def _process_image(self, file_path: str, output_path: str) -> tuple[str, str]: - """Process image compression""" - ext = os.path.splitext(file_path)[1].lower() - if ext in [".jpg", ".jpeg"]: - command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" - elif ext == ".gif": - command = f"magick {shlex.quote(file_path)} -coalesce -resize 30% -colors 32 -strip -layers Optimize {shlex.quote(output_path)}" - else: - output_path = os.path.splitext(output_path)[0] + ".jpg" - command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" - return command, output_path - - async def _execute_compression(self, command: str) -> bool: - """Execute compression command""" - proc = await asyncio.create_subprocess_exec( - *shlex.split(command), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - await proc.wait() - return proc.returncode == 0 - - async def _compress_attachment(self, attachment: discord.Attachment) -> Optional[discord.File]: - """Compress a single attachment and return the compressed file""" - try: - file_path: str = os.path.join(self.temp_dir, attachment.filename) - output_path = os.path.join(self.temp_dir, f"better_{attachment.filename}") - - # Save the original file - await attachment.save(file_path) - - # Process the file - file_type = filetype(file_path) - logger.info(f"Compression started for: {file_type}") - - command = self._get_compression_command(file_type, file_path, output_path) - if command is None: - logger.info(f"Unsupported file type: {file_type}") - os.remove(file_path) - return None - - # Update output_path if it was modified - if file_type == "Image": - command, output_path = command - elif file_type == "Audio": - # output_path is already modified in _process_audio - pass - - # Run the compression - success = await self._execute_compression(command) - - if success and os.path.exists(output_path): - logger.info(f"Successfully compressed: {attachment.filename}") - return discord.File(output_path, filename=f"compressed_{attachment.filename}") - return None - - except Exception as e: - logger.error(f"Error processing {attachment.filename}: {str(e)}") - return None - - finally: - # Clean up temporary files - if 'file_path' in locals() and os.path.exists(file_path): - os.remove(file_path) - - def _get_compression_command(self, file_type: str, file_path: str, output_path: str): - """Get the appropriate compression command based on file type""" - if file_type == "Video": - return self._process_video(file_path, output_path) - elif file_type == "Audio": - return self._process_audio(file_path, output_path) - elif file_type == "Image": - return self._process_image(file_path, output_path) - else: - return None - - async def _compress_attachments(self, attachments: List[discord.Attachment]) -> List[discord.File]: - """Compress multiple attachments and return list of compressed files""" - compressed_files = [] - for attachment in attachments: - compressed_file = await self._compress_attachment(attachment) - if compressed_file: - compressed_files.append(compressed_file) - return compressed_files - - @commands.message_command( - name="kompresja", - description="skompresuj wideło albo dźwienk :)", - ) - async def message_compression(self, ctx, message: discord.Message): - if not message.attachments: - await ctx.respond("Brak załączników w wiadomości", ephemeral=True) - return - - await ctx.defer() - compressed_files = await self._compress_attachments(message.attachments) - - # Send all compressed files in one message - if compressed_files: - await ctx.respond( - f"Skompresowano {len(compressed_files)} z {len(message.attachments)} plików:", - files=compressed_files - ) - - # Clean up compressed files - for file in compressed_files: - if os.path.exists(file.fp.name): # type: ignore[attr-defined] - os.remove(file.fp.name) # type: ignore[attr-defined] - else: - await ctx.respond("Nie udało się skompresować żadnych plików.", ephemeral=True) - - @commands.slash_command( - name="kompresja", - description="skompresuj wideło albo dźwienk :)", - ) - @discord.option( - name="plik", - description="plik z copyparty", - required=False, - autocomplete=_file_autocomplete - ) - @discord.option( - name="zalacznik", - description="plik z discorda", - required=False, - type=discord.Attachment - ) - @discord.option( - name="serwer?", - description="wysłać plik na serwer?", - required=False, - default=False, - type=bool - ) - async def compression(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server = False): - await ctx.defer() - - file_path = None - original_filename = None - - if plik: - url = self._cached_urls.get(ctx.author.id, {}).get(plik) - if not url: - await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) - return - file_path = await self._download_from_url(url) - original_filename = plik - elif zalacznik: - file_path = os.path.join(self.temp_dir, zalacznik.filename) - original_filename = zalacznik.filename - await zalacznik.save(file_path) - else: - await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) - return - - if not file_path or not os.path.exists(file_path): - await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) - return - - try: - file_type = filetype(file_path) - output_path = os.path.join(self.temp_dir, f"better_{original_filename}") - - command = self._get_compression_command(file_type, file_path, output_path) - if command is None: - await ctx.respond("Nieobsługiwany typ pliku", ephemeral=True) - return - - if file_type == "Image": - command, output_path = command - elif file_type == "Audio": - # Audio output path remains unchanged - already processed in _process_audio - pass - - success = await self._execute_compression(command) - - if not success or not os.path.exists(output_path): - await ctx.respond("Kompresja nie powiodła się", ephemeral=True) - return - - file_size = os.path.getsize(output_path) - if file_size > 1024 * 1024 * 10 or server: - dl_url = await self._upload_to_copyparty(output_path) - if dl_url: - await ctx.respond(f"Skompresowano\n{dl_url}") - else: - await ctx.respond("Nie udało wysłać na serwer", ephemeral=True) - else: - await ctx.respond(file=discord.File(output_path, filename=f"compressed_{original_filename}")) - - finally: - if file_path and os.path.exists(file_path): - os.remove(file_path) - if 'output_path' in locals() and os.path.exists(output_path): - os.remove(output_path) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import discord -import requests -from discord.ext import commands -import logging -import os -import asyncio -import aiofiles -import mimetypes -import subprocess -import urllib.parse -import shlex -from typing import Optional -from discord import AutocompleteContext -from cogs.utils import file_search as search_utils - -logger = logging.getLogger(__name__) - -def filetype(filename): - mime_type, _ = mimetypes.guess_type(filename) - if mime_type: - if mime_type.startswith('video/'): - return "Video" - elif mime_type.startswith('audio/'): - return "Audio" - elif mime_type.startswith('image/'): - return "Image" - return "Unknown" - -class Conversion(commands.Cog): - def __init__(self, bot): - self.bot = bot - from cogs.utils.temp import get_temp_dir - from cogs.utils.settings import get_baseurl, get_uploadurl - self.temp_dir = get_temp_dir() - self._cached_urls = {} - self._baseurl = get_baseurl() - self._uploadurl = get_uploadurl() - - async def _file_autocomplete(self, ctx: AutocompleteContext): - query = ctx.value.lower() if ctx.value else "" - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) - - user_id = ctx.interaction.user.id - self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} - - choices = [] - for url in results[:25]: - filename = urllib.parse.unquote(url.split('/')[-1]) - display_name = filename[:99] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=filename)) - return choices - - async def _download_from_url(self, url: str) -> Optional[str]: - """Download file from copyparty URL and return local path""" - try: - response = requests.get(url) - if response.status_code != 200: - return None - - filename = url.split('/')[-1].split('?')[0] - filename = urllib.parse.unquote(filename) - file_path = os.path.join(self.temp_dir, filename) - - async with aiofiles.open(file_path, 'wb') as f: - await f.write(response.content) - return file_path - except Exception as e: - logger.error(f"Error downloading from {url}: {e}") - return None - - async def _setup_file_paths(self, plik, output_format): - """Setup input and output file paths""" - from cogs.utils.temp import get_temp_dir - - temp_dir = get_temp_dir() - file_path = os.path.join(temp_dir, plik.filename) - filename_noext = os.path.splitext(plik.filename)[0] - await plik.save(file_path) - - input_path = file_path - output_path = os.path.join(temp_dir, f"{filename_noext}.{output_format}") - return input_path, output_path - - def _build_conversion_command(self, file_type, input_path, output_path, args): - """Build the appropriate conversion command based on file type""" - if file_type in ["Video", "Audio"]: - return f"ffmpeg -i {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" - elif file_type == "Image": - return f"magick {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" - else: - return None - - async def _upload_to_copyparty(self, output_path): - """Upload file to copyparty and return download URL""" - try: - async with aiofiles.open(output_path, 'rb') as f: - file_content = await f.read() - - files = { - 'f': (os.path.basename(output_path), file_content, 'application/octet-stream') - } - headers = { - 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}' - } - params = { - 'j': '1', - 'ck': 'b2' - } - - response = requests.post(self._uploadurl, files=files, headers=headers, params=params) - - if response.status_code in [200, 201]: - result = response.json() - if 'fileurl' in result: - return result['fileurl'] - else: - raise ValueError(f"Server response missing fileurl: {result}") - else: - error_msg = response.text if response.text else 'Unknown error' - raise requests.RequestException(f"HTTP {response.status_code}: {error_msg}") - - except Exception as e: - logger.error(f"copyparty upload error: {e}", exc_info=True) - raise - - async def _handle_file_output(self, ctx, output_path): - """Handle file output based on size""" - if os.path.getsize(output_path) > 1024*1024*10: - try: - dl_url = await self._upload_to_copyparty(output_path) - await ctx.followup.send(f"wysyłon\n{dl_url}\n") - except Exception as upload_error: - await ctx.followup.send(f"copyparty mnie nienawidzi :cccc\n {str(upload_error)}") - else: - await ctx.respond(file=discord.File(output_path)) - - def _cleanup_files(self, *file_paths): - """Clean up temporary files""" - for path in file_paths: - if os.path.exists(path): - os.remove(path) - - @commands.slash_command(name="konwersja", description="konwertuj plik na inny format") - @discord.option( - name="plik", - description="plik z copyparty", - required=False, - autocomplete=_file_autocomplete - ) - @discord.option( - name="zalacznik", - description="plik z discorda", - required=False, - type=discord.Attachment - ) - @discord.option( - name="serwer?", - description="wysłać plik na serwer?", - required=False, - type=bool, - default=False - ) - @discord.option( - name="format", - description="format konwersji", - required=True, - type=str - ) - async def convert(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server: bool = False, output_format: str = None): - await ctx.defer() - - input_path = None - original_filename = None - - try: - if plik: - url = self._cached_urls.get(ctx.author.id, {}).get(plik) - if not url: - await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) - return - input_path = await self._download_from_url(url) - original_filename = plik - elif zalacznik: - input_path = os.path.join(self.temp_dir, zalacznik.filename) - original_filename = zalacznik.filename - await zalacznik.save(input_path) - else: - await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) - return - - if not input_path or not os.path.exists(input_path): - await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) - return - - output_path = os.path.join(self.temp_dir, f"{os.path.splitext(original_filename)[0]}.{output_format}") - - file_type = filetype(input_path) - logger.info(f"Conversion started for: {file_type}") - - command = self._build_conversion_command(file_type, input_path, output_path, "") - logger.info(f"Running: {command}") - if not command: - await ctx.respond("Plik jest nieobsługiwany", ephemeral=True) - return - - proc = await asyncio.create_subprocess_exec( - *shlex.split(command), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await proc.communicate() - - if proc.returncode != 0: - logger.error(f"FFmpeg error: {stderr.decode() if stderr else 'unknown'}") - await ctx.respond(f"Błąd konwersji: {stderr.decode() if stderr else 'unknown'}", ephemeral=True) - return - - if os.path.getsize(output_path) > 1024 * 1024 * 10 or server: - dl_url = await self._upload_to_copyparty(output_path) - if dl_url: - await ctx.respond(f"Skonwertowano\n{dl_url}") - else: - await ctx.respond("Nie udało się wysłać na serwer", ephemeral=True) - else: - await ctx.respond(file=discord.File(output_path, filename=f"{original_filename}")) - - logger.info(f"Conversion completed for: {file_type}") - - except subprocess.CalledProcessError as e: - await ctx.respond(f"Conversion failed: {str(e)}", ephemeral=True) - except Exception as e: - await ctx.respond(f"An error occurred: {str(e)}", ephemeral=True) - finally: - if input_path and os.path.exists(input_path): - os.remove(input_path) - if 'output_path' in locals() and os.path.exists(output_path): - os.remove(output_path) - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import asyncio -import logging -from datetime import datetime -from venv import logger - -import discord -from croniter import croniter -from discord.ext import commands - -from cogs.utils import file_search as search_utils -from cogs.voice import get_voice_manager -from cogs.utils.database import get_connection, init_database - -logger = logging.getLogger(__name__) - -init_database() - - -class Cron(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.scheduled_tasks = [] - self.load_tasks() - self.task = bot.loop.create_task(self.schedule_checker()) - - def load_tasks(self): - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") - rows = c.fetchall() - - self.scheduled_tasks = [ - { - "id": row[0], - "cron_expression": row[1], - "type": row[2], - "channel_id": row[3], - "active": bool(row[4]), - "audio_filename": row[5], - "message": row[6], - } - for row in rows - ] - logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database") - except Exception as e: - logger.error(f"Error loading cron jobs: {e}") - self.scheduled_tasks = [] - - def save_tasks(self): - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("PRAGMA foreign_keys = ON") - - c.execute("DELETE FROM cron_jobs") - - for task in self.scheduled_tasks: - c.execute( - """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - ( - task.get("id"), - task["cron_expression"], - task["type"], - task["channel_id"], - int(task.get("active", True)), - task.get("audio_filename"), - task.get("message"), - ), - ) - conn.commit() - except Exception as e: - logger.error(f"Error saving cron jobs: {e}") - - async def schedule_checker(self): - """Background task to check and execute scheduled jobs""" - await self.bot.wait_until_ready() - while not self.bot.is_closed(): - try: - now = datetime.now() - for task in self.scheduled_tasks: - if not task.get("active", True): - continue - if self.should_run_task(task, now): - await self.execute_task(task) - await asyncio.sleep(30) - except Exception as e: - logger.error(f"Error in schedule checker: {e}") - await asyncio.sleep(30) - - def should_run_task(self, task, now): - """Check if a task should run now based on its cron expression""" - try: - cron_expr = task["cron_expression"] - cron = croniter(cron_expr, now) - prev_time = cron.get_prev(datetime) - time_diff = abs((now - prev_time).total_seconds()) - result = time_diff <= 30 - logger.debug( - f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}" - ) - return result - except Exception as e: - logger.error( - f"Error checking cron expression {task.get('cron_expression')}: {e}" - ) - return False - - async def execute_task(self, task): - """Execute a scheduled task""" - try: - task_type = task["type"] - channel_id = task["channel_id"] - - channel = self.bot.get_channel(channel_id) - if not channel: - logger.warning(f"Channel {channel_id} not found for task ID: {task.get('id')}") - return - - logger.info(f"Found channel {channel} (type: {type(channel).__name__})") - - perms = channel.permissions_for(channel.guild.me) - logger.debug( - f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}" - ) - - if task_type in ("zagraj_audio", "audio"): - await self.play_audio_task(task, channel) - elif task_type in ("wyślij_wiadomość", "send_message", "wiadomość"): - await self.send_message_task(task, channel) - - logger.info(f"Executed task {task['id']} of type {task_type}") - except Exception as e: - logger.error(f"Error executing task {task.get('id')}: {e}") - - async def play_audio_task(self, task, channel): - """Play audio task implementation using voice manager""" - audio_url = task["audio_filename"] - - if isinstance(channel, discord.VoiceChannel): - voice_mgr = get_voice_manager(self.bot) - - try: - audio_source = discord.FFmpegPCMAudio(audio_url) - success = await voice_mgr.play_source(channel, audio_source) - - if success: - logger.info(f"Finished playing audio: {audio_url}") - else: - raise Exception("Playback failed") - - await voice_mgr.disconnect(channel.guild) - except Exception as e: - logger.error(f"Error playing audio: {e}") - try: - await voice_mgr.disconnect(channel.guild) - except Exception: - pass - if channel.guild.system_channel: - await channel.guild.system_channel.send( - f"Błąd podczas odgrywania audio: {audio_url}" - ) - else: - if hasattr(channel, "send"): - await channel.send("Odtwarzanie audio wymaga kanału głosowego") - - async def send_message_task(self, task, channel): - """Send message task implementation""" - message = task["message"] - logger.info(f"Message to send: '{message}' | Channel: {channel.id}") - await channel.send(message) - logger.debug("Message sent successfully") - - async def audio_autocomplete(self, ctx: discord.AutocompleteContext): - """Autocomplete for audio files from file_search""" - query = ctx.value.lower() if ctx.value else "" - if not query: - return "" - - audio_extensions = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "mp4", "mkv"] - results = await search_utils.UniversalSearcher.search_copyparty( - query, max_results=25, file_extensions=audio_extensions - ) - - choices = [] - for url in results[:25]: - filename = url.split("/")[-1] - display_name = filename[:98] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=url)) - - return ( - choices - if choices - else [discord.OptionChoice(name="Brak wyników", value="")] - ) - - def _get_baseurl(self): - """Get base URL from settings""" - try: - from cogs.utils.settings import get_baseurl - - return get_baseurl() - except Exception: - return "" - - trigger_group = discord.SlashCommandGroup("cron", "komendy cron") - - @trigger_group.command( - name="dodaj_audio", description="Dodaj nowe zadanie cron audio" - ) - async def cron_add_audio( - self, - ctx, - cron_expression: str = discord.Option( - str, - "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", - required=True, - ), - channel: discord.VoiceChannel = discord.Option( - discord.VoiceChannel, - "Kanał głosowy na którym będzie wykonywana akcja", - required=True, - ), - audio_filename: str = discord.Option( - str, - "Plik audio z serwera copyparty", - required=True, - autocomplete=audio_autocomplete, - ), - ): - try: - croniter(cron_expression) - except Exception: - await ctx.respond( - "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", - ephemeral=True, - ) - return - - task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 - - task = { - "id": task_id, - "cron_expression": cron_expression, - "type": "audio", - "channel_id": channel.id, - "active": True, - } - - task["audio_filename"] = audio_filename - - self.scheduled_tasks.append(task) - self.save_tasks() - - await ctx.respond( - f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" - ) - - @trigger_group.command( - name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" - ) - async def cron_add_message( - self, - ctx, - cron_expression: str = discord.Option( - str, - "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", - required=True, - ), - channel: discord.TextChannel = discord.Option( - discord.TextChannel, "Kanał na którym będzie wykonana akcja", required=True - ), - message: str = discord.Option(str, "Wiadomość do wysłania", required=True), - ): - """Add a new cron job""" - - try: - croniter(cron_expression) - except Exception: - await ctx.respond( - "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", - ephemeral=True, - ) - return - - task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 - - task = { - "id": task_id, - "cron_expression": cron_expression, - "type": "wiadomość", - "channel_id": channel.id, - "active": True, - "message": message, - } - - self.scheduled_tasks.append(task) - self.save_tasks() - - await ctx.respond( - f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" - ) - - @trigger_group.command( - name="pokaż", description="Pokaż wszystkie zadania cron" - ) - async def cron_list(self, ctx): - """List all cron jobs""" - if not self.scheduled_tasks: - await ctx.respond("Brak zadań cron") - return - - embed = discord.Embed(title="Zaplanowane prace cron", color=0x00FF00) - - for task in self.scheduled_tasks: - channel = self.bot.get_channel(task["channel_id"]) - channel_name = f"#{channel}" - status = "AKTYWNA" if task.get("active", True) else "PAUZA" - - if task["type"] in ("zagraj_audio", "audio"): - action_desc = f"Zagraj audio: `{task.get('audio_filename', 'N/A')}`" - else: - msg = task.get("message", "") - action_desc = ( - f"Wyślij wiadomość: `{msg[:50]}{'...' if len(msg) > 50 else ''}`" - ) - - embed.add_field( - name=f"Zadanie #{task['id']} ({status})", - value=f"**Czas:** `{task['cron_expression']}`\n**Akcja:** {action_desc}\n**Kanał:** {channel_name}", - inline=False, - ) - - await ctx.respond(embed=embed) - - @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID") - async def cron_remove( - self, - ctx, - job_id: int = discord.Option( - int, "ID zadania cron do usunięcia", required=True - ), - ): - """Remove a cron job by ID""" - for i, task in enumerate(self.scheduled_tasks): - if task["id"] == job_id: - self.scheduled_tasks.pop(i) - self.save_tasks() - await ctx.respond(f"Usunięto zadanie cron #{job_id}") - return - - await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") - - @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID") - async def cron_pause( - self, - ctx, - job_id: int = discord.Option( - int, "ID zadania cron do zatrzymania", required=True - ), - ): - """Pause a cron job by ID""" - for task in self.scheduled_tasks: - if task["id"] == job_id: - task["active"] = False - self.save_tasks() - await ctx.respond(f"Zapauzowano zadanie cron #{job_id}") - return - - await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") - - @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID") - async def cron_resume( - self, - ctx, - job_id: int = discord.Option( - int, "ID zadania cron do wznowienia", required=True - ), - ): - """Resume a paused cron job by ID""" - for task in self.scheduled_tasks: - if task["id"] == job_id: - task["active"] = True - self.save_tasks() - await ctx.respond(f"Wznowiono zadanie cron z ID #{job_id}") - return - - await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") - - def cog_unload(self): - """Clean up when cog is unloaded""" - if hasattr(self, "task"): - self.task.cancel() - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import tempfile -import logging -import requests -import discord -import re -import yt_dlp -from discord.ext import commands -import aiofiles -from cogs.utils.url_helper import clean_url_for_ytdlp - -logger = logging.getLogger(__name__) - - -class Downloader(commands.Cog): - def __init__(self, bot): - self.bot = bot - - URL_PATTERN = re.compile( - r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*' - ) - - @commands.message_command( - name="download", - description="Download and convert video/audio from internet" - ) - async def message_download(self, ctx, message: discord.Message): - # check if message has a valid url - url = [m.group(0) for m in self.URL_PATTERN.finditer(message.content)] - if url: - for url in url: - await self.download(ctx=ctx, url=url, fformat="mp4") - else: - await ctx.respond("Brak załączników w wiadomości", ephemeral=True) - - @commands.slash_command( - name="download", - description="Download and convert video/audio from internet" - ) - async def download( - self, - ctx: discord.ApplicationContext, - url: str = discord.Option(str, description="Link media", required=True), - catbox: bool = discord.Option(bool, description="wysłać na serwer??", required=False, default=False), - fformat: str = discord.Option(str, description="jaki format? (mp4, mp3, wav, etc.)", required=False, default="mp4"), - filename_user: str = discord.Option(str, description="jaka nazwa pliku?", required=False) - ): - """Download and convert media from a URL to the specified format""" - await ctx.defer() - # Call the function and get the response - response = await self.download_standalone(url=url, catbox=catbox, fformat=fformat, filename_user=filename_user) - if isinstance(response, dict): - if response["status"] == "success": - if response["type"] == "url": - await ctx.respond(f"pobioron i wysyłon: {response['content']}") - elif response["type"] == "file": - file_data = response["content"] - await ctx.respond(file=discord.File(file_data)) - else: - error_msg = response["content"] - if 'details' in response: - error_msg += f"\n{response['details']}" - await ctx.respond(f"Błąd: {error_msg}", ephemeral=True) - logger.error(f"Error: {response['content']}") - if 'details' in response: - logger.error(f"Details: {response['details']}") - - @staticmethod - async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): - try: - url = clean_url_for_ytdlp(url) - logger.info(f"Downloading {url} as {fformat}") - - with tempfile.TemporaryDirectory(delete=False) as temp_dir: - ydl_opts = Downloader._get_ydl_options(filename_user, fformat, temp_dir) - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - - actual_file, filename = Downloader._find_and_prepare_file(temp_dir, filename_user) - - if Downloader._should_upload_to_server(actual_file, catbox): - return await Downloader._upload_to_server(actual_file, filename, temp_dir) - else: - return Downloader._return_file_response(actual_file, filename, temp_dir) - - except Exception as e: - return Downloader._create_error_response("Download failed", str(e), None) - - @staticmethod - def _sanitize_filename(name: str) -> str: - invalid = '<>:"/\\|?*' - for c in invalid: - name = name.replace(c, '') - return name.strip() - - @staticmethod - def _get_ydl_options(filename: str = None, fformat: str = None, temp_dir: str = None) -> dict: - if filename: - filename = Downloader._sanitize_filename(filename)[:100] - ydl_opts = { - 'outtmpl': os.path.join(temp_dir, filename if filename is not None else '%(title).100s.%(ext)s'), - 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', - 'merge_output_format': 'mp4', - 'postprocessors': [], - 'remote-components': 'ejs:github', - 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}}, - } - - if fformat in ['mp3', 'wav', 'm4a']: - ydl_opts['postprocessors'].append({ - 'key': 'FFmpegExtractAudio', - 'preferredcodec': fformat, - 'preferredquality': '192', - }) - elif fformat != 'mp4': - ydl_opts['postprocessors'].append({ - 'key': 'FFmpegVideoConvertor', - 'preferedformat': fformat, - }) - - return ydl_opts - - @staticmethod - def _find_and_prepare_file(temp_dir: str, filename_user: str) -> tuple: - files = [f for f in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, f))] - if not files: - raise FileNotFoundError("No file was downloaded") - - actual_file = os.path.join(temp_dir, files[0]) - extension = actual_file.split('.')[-1] - extension = Downloader._sanitize_filename(extension) - - if not filename_user: - filename = os.path.basename(actual_file) - filename = Downloader._sanitize_filename(filename) - if len(filename) > 100: - filename = filename[:100] - else: - filename_user = Downloader._sanitize_filename(filename_user)[:100] - filename = filename_user + '.' + extension - actual_file = os.path.join(temp_dir, filename) - - if not os.path.exists(actual_file): - raise FileNotFoundError(f"Downloaded file not found: {actual_file}") - - return actual_file, filename - - @staticmethod - def _should_upload_to_server(actual_file: str, catbox: bool) -> bool: - return os.path.getsize(actual_file) > 1024*1024*10 or catbox - - @staticmethod - async def _upload_to_server(actual_file: str, filename: str, temp_dir: str) -> dict: - try: - async with aiofiles.open(actual_file, 'rb') as f: - content = await f.read() - files = {'f': (filename, content, 'application/octet-stream')} - headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} - params = {'j': '1', 'ck': 'b2'} - base_url = os.getenv("COPYPARTY_URL_UPLOAD") - - response = requests.post(base_url, files=files, headers=headers, params=params) - - if response.status_code in [200, 201]: - return Downloader._handle_upload_success(response, filename, temp_dir) - elif response.status_code == 413: - return Downloader._create_error_response( - "File too large for upload", - "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)", - temp_dir - ) - else: - return Downloader._create_error_response( - f"Server returned {response.status_code}", - response.text or 'Unknown error', - temp_dir - ) - except Exception as upload_error: - return Downloader._create_error_response("Upload failed", str(upload_error), temp_dir) - - @staticmethod - def _handle_upload_success(response: requests.Response, filename: str, temp_dir: str) -> dict: - try: - result = response.json() - if 'fileurl' in result: - return { - "status": "success", - "type": "url", - "content": result['fileurl'], - "filename": filename, - "temp_dir": temp_dir - } - else: - return Downloader._create_error_response( - "Invalid response from file server", - str(result), - temp_dir - ) - except ValueError: - return Downloader._create_error_response( - "Failed to parse server response", - response.text[:200], - temp_dir - ) - - @staticmethod - def _return_file_response(actual_file: str, filename: str, temp_dir: str) -> dict: - return { - "status": "success", - "type": "file", - "content": actual_file, - "filename": filename, - "temp_dir": temp_dir - } - - @staticmethod - def _create_error_response(content: str, details: str, temp_dir: str | None) -> dict: - return { - "status": "error", - "type": "error", - "content": content, - "details": details, - "temp_dir": temp_dir - } - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging -import asyncio -import re - -from discord import ApplicationContext -from discord.ext import commands - -logger = logging.getLogger(__name__) - -MATH_SANITIZE_PATTERN = re.compile(r'^[\d\s\+\-\*\/\^\%\(\)\.\,\=\\<\>\!\|\&\~\?\:\'\"\[\]\{\}a-zA-Z]+$') - - -def sanitize_math_input(equation: str) -> str | None: - if not equation or len(equation) > 1000: - return None - equation = equation.strip() - if not equation: - return None - if not MATH_SANITIZE_PATTERN.match(equation): - return None - for char in equation: - if ord(char) < 32 or ord(char) > 126: - return None - if equation.count('(') != equation.count(')'): - return None - if equation.count('[') != equation.count(']'): - return None - if equation.count('{') != equation.count('}'): - return None - dangerous_patterns = [ - r'eval', r'exec', r'__import__', r'os\.', r'sys\.', - r'subprocess', r'open(', r'file', r'shutil', - r'lambda:', r'compile', r'dir(', r'vars(', - r'\|\|', r'&&', r';', r'`', r'\$', r'\\n', r'\\r', - r'\x', r'\0', r'\a', r'\b', r'\f', r'\v', - ] - for pattern in dangerous_patterns: - if re.search(pattern, equation, re.IGNORECASE): - return None - return equation - - -class Math(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @commands.slash_command( - name="math", - description="wrug szyskih matfisuf" - ) - async def math(self, ctx: ApplicationContext, equation: str): - sanitized = sanitize_math_input(equation) - if sanitized is None: - await ctx.respond("Invalid equation. Only basic math expressions allowed.", ephemeral=True) - logger.warning(f"Math command rejected invalid input: {equation}") - return - try: - math_proc = await asyncio.create_subprocess_exec( - "qalc", "-e", sanitized, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await math_proc.communicate() - if math_proc.returncode != 0: - error_output = stderr.decode('utf-8', errors='replace').strip() - await ctx.respond(f"Calculation error: {error_output}", ephemeral=True) - logger.error(f"qalc error: {error_output}") - return - result = stdout.decode('utf-8', errors='replace').strip() - await ctx.respond(f"{sanitized} = {result}") - logger.info(f"Math command executed: {sanitized}") - except Exception as e: - await ctx.respond(f"Error: {str(e)}", ephemeral=True) - logger.error(f"Error in math command: {e}", exc_info=True) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging - -from discord.ext import commands -from cogs.voice import get_voice_manager - -logger = logging.getLogger(__name__) - - -class Join(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @commands.slash_command(name="join", description="dołącza do kanalu") - async def join(self, ctx): - voice_mgr = get_voice_manager(self.bot) - channel = ctx.author.voice.channel - await voice_mgr.connect(channel) - await ctx.respond(content="\u200b", ephemeral=True) - await ctx.interaction.delete_original_response() - logger.info("Bot connected to voice channel") - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging - -from discord.ext import commands - -logger = logging.getLogger(__name__) - - -class Loop(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.looping = {} - - def is_looping(self, guild_id): - return self.looping.get(guild_id, False) - - def set_looping(self, guild_id, value): - self.looping[guild_id] = value - - @commands.slash_command( - name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji" - ) - async def loop(self, ctx): - guild_id = ctx.guild.id - looping = not self.is_looping(guild_id) - self.set_looping(guild_id, looping) - await ctx.respond( - f"looping jest teraz {'włączony' if looping else 'wyłączony'}." - ) - logger.info( - f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}" - ) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import asyncio -import logging -import os - -import yt_dlp -from youtubesearchpython import VideosSearch -from cogs.utils.url_helper import clean_url_for_ytdlp - -logger = logging.getLogger(__name__) - - -class MediaDownloader: - """Handles downloading media from various sources.""" - - @staticmethod - async def download(ctx, url): - """Download audio from YouTube URL.""" - from cogs.utils.temp import get_temp_dir - - temp_dir = get_temp_dir("senator_sperma_music") - - ytdl_opts = { - "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), - "postprocessors": [ - { - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": "192", - } - ], - "force_ipv4": True, - } - - try: - url = clean_url_for_ytdlp(url) - with yt_dlp.YoutubeDL(ytdl_opts) as ydl: - info_dict = ydl.extract_info(url, download=True) - # Get the base filename without extension - base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] - # Add .mp3 extension since we're extracting audio - filename = f"{base_filename}.mp3" - logger.info(f"Downloaded file: {filename}") - return filename - except Exception as e: - logger.error(f"Error downloading video: {e}") - await ctx.respond("eror pobirajync wideło", ephemeral=True) - - @staticmethod - async def get_url(ctx, query): - """Search YouTube and get URL for a query.""" - search = VideosSearch(query, limit=1) - if search.result()["result"]: - title = search.result()["result"][0]["title"] - url = search.result()["result"][0]["link"] - logger.info(f"Found video: {title}") - return url, title - else: - logger.warning("No results found for the query.") - await ctx.respond("nic ni ma", ephemeral=True) - return None - - -class AudioCompressor: - """Handles audio compression using FFmpeg.""" - - @staticmethod - async def compress(ctx, filename): - """Compress audio file to opus format.""" - from cogs.utils.temp import get_temp_dir - - if not filename: - await ctx.respond("dzie wideło", ephemeral=True) - return None - - temp_dir = get_temp_dir("senator_sperma_compressed") - - # Create a temporary output filename in the temp directory - base_name = os.path.basename(filename) - output_filename = os.path.join( - temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus" - ) - - # Ensure the output directory exists - os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True) - - # Use proper quoting for filenames with spaces - ffmpeg_command = [ - "ffmpeg", - "-y", # Overwrite output file if it exists - "-i", - filename, - "-ac", - "1", # Mono audio - "-b:a", - "500", # 500k bitrate - "-acodec", - "libopus", - output_filename, - ] - - try: - logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") - process = await asyncio.create_subprocess_exec( - *ffmpeg_command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - _, stderr = await process.communicate() - - if process.returncode != 0: - error_msg = stderr.decode() - logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") - await ctx.respond("eror kompresujync pilk", ephemeral=True) - return None - - if not os.path.exists(output_filename): - logger.error(f"Output file was not created: {output_filename}") - await ctx.respond( - "eror kompresujync pilk - output file not created", ephemeral=True - ) - return None - - logger.info(f"Successfully compressed file: {output_filename}") - return output_filename - - except Exception as e: - logger.error(f"Error compressing file: {str(e)}", exc_info=True) - await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True) - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging -import os -import urllib.parse -import asyncio -from os import PathLike -from typing import Awaitable, Callable, List, Tuple -import re -import requests -from yt_dlp import YoutubeDL - -import discord -from discord.commands import OptionChoice -from discord.ext import commands - -from cogs.music import media_processing -from cogs.utils.file_search import UniversalSearcher -from cogs.utils.settings import get_uploadurl -from cogs.utils.url_helper import clean_url_for_ytdlp -from cogs.voice import get_voice_manager - -logger = logging.getLogger(__name__) - -EXTERNAL_URL_PATTERN = re.compile( - r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" -) - - -class PlayCommands(commands.Cog): - """Discord slash commands for music playback.""" - - def __init__(self, bot): - from cogs.utils.temp import get_temp_dir - - self.bot = bot - self.temp_dir = get_temp_dir() - self.path_mapping = {} # Maps display names to full paths - - @staticmethod - async def _create_search_embed( - candidates: List[str], - ) -> Tuple[discord.Embed, discord.ui.View]: - """Create an embed and view for search results.""" - embed = discord.Embed( - title="Wyniki wyszukiwania", - description="Wybierz plik do odtworzenia:", - color=discord.Color.blue(), - ) - - view = discord.ui.View(disable_on_timeout=True) - - for i, candidate in enumerate(candidates, start=1): - filename = candidate.split("/")[-1].split("?")[0] - display_name = os.path.splitext(filename)[0] - embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) - - return embed, view - - async def _handle_search_results( - self, - ctx: discord.ApplicationContext, - candidates: List[str], - play_callback: Callable[[discord.Interaction, str], Awaitable[None]], - ) -> None: - """Handle the display of search results and button interactions.""" - if not candidates: - await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) - return - - embed, view = await self._create_search_embed(candidates) - - # Add buttons for each candidate - for i, candidate in enumerate(candidates, start=1): - button = discord.ui.Button(label=f"Play {i}") - - async def button_callback( - interaction: discord.Interaction, candidate_path=candidate - ): - await interaction.response.defer(ephemeral=True) - await ctx.interaction.delete_original_response() - try: - await play_callback(interaction, candidate_path) - except Exception as e: - logger.error(f"Error in play callback: {str(e)}", exc_info=True) - await interaction.followup.send( - f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True - ) - - button.callback = button_callback - view.add_item(button) - - await ctx.respond(embed=embed, view=view, ephemeral=True) - - async def _search_autocomplete( - self, ctx: discord.AutocompleteContext - ) -> List[OptionChoice]: - """Autocomplete handler for search queries.""" - if ctx.value.lower().startswith(("http://", "https://")): - return [] - - query = ctx.value.lower() - try: - results = await UniversalSearcher.search_copyparty_autocomplete_audio( - query, max_results=25 - ) - choices = [] - self.path_mapping = {} # Reset the mapping for each new query - - for url in results: - filename = url.split("/")[-1] - filename = filename.split("?")[0] # Remove query params if any - filename = urllib.parse.unquote(filename) # Decode URL encoding - display = os.path.splitext(filename)[0] # Remove extension - - display = display.strip() - - if len(display) > 100: - display = display[:99] + "…" - - self.path_mapping[display] = url - choices.append(OptionChoice(display, display)) - - if len(choices) >= 25: - break - - return choices - - except Exception as e: - logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) - return [] - - async def _handle_compressed_file_play( - self, ctx: discord.ApplicationContext, url: str - ) -> None: - """Download from copyparty, compress, then play locally.""" - import aiohttp - - local_path = None - compressed_path = None - try: - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status != 200: - await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) - return - - filename = url.split("/")[-1].split("?")[0] - filename = urllib.parse.unquote(filename) - local_path = os.path.join(self.temp_dir, filename) - os.makedirs(self.temp_dir, exist_ok=True) - - loop = asyncio.get_event_loop() - with open(local_path, "wb") as f: - async for chunk in response.content.iter_chunked(8192): - await loop.run_in_executor(None, f.write, chunk) - - compressed_path = await media_processing.AudioCompressor.compress( - ctx, local_path - ) - if not compressed_path: - return - - await get_voice_manager(self.bot).play( - ctx, compressed_path, delete_after_play=True - ) - except Exception as e: - logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) - await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) - finally: - if local_path and os.path.exists(local_path): - try: - os.remove(local_path) - except Exception: - pass - - async def _handle_external_url_play( - self, ctx: discord.ApplicationContext, url: str - ) -> None: - """Download from external URL, upload to copyparty, then play.""" - local_path = None - try: - ytdl_opts = { - "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"), - "postprocessors": [ - { - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": "192", - } - ], - "force_ipv4": True, - } - - loop = asyncio.get_event_loop() - url = clean_url_for_ytdlp(url) - with YoutubeDL(ytdl_opts) as ydl: - info_dict = await loop.run_in_executor( - None, lambda: ydl.extract_info(url, download=True) - ) - title = info_dict.get("title", "nieznany") - base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] - local_path = f"{base_filename}.mp3" - - if not os.path.exists(local_path): - await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) - return - - upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) - - await get_voice_manager(self.bot).play( - ctx, local_path, delete_after_play=True - ) - - try: - await asyncio.wait_for(upload_task, timeout=30.0) - except asyncio.TimeoutError: - logger.warning("Upload timed out") - except Exception as e: - logger.error(f"Error downloading external URL: {str(e)}", exc_info=True) - if local_path and os.path.exists(local_path): - try: - os.remove(local_path) - except Exception: - pass - await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True) - - async def _upload_to_copyparty_async(self, file_path: str) -> None: - """Upload file to copyparty asynchronously (fire and forget).""" - try: - logger.info(f"Starting async upload: {file_path}") - baseurl = get_uploadurl() - password = os.getenv("COPYPARTY_PASSWORD", "") - - logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") - - if baseurl == "http://localhost:3939": - logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") - - with open(file_path, "rb") as f: - content = f.read() - - files = {"f": (os.path.basename(file_path), content, "application/octet-stream")} - headers = {"Cookie": f"cppwd={password}"} - params = {"j": "1", "ck": "b2"} - - logger.info(f"Posting to {baseurl}") - response = requests.post(baseurl, files=files, headers=headers, params=params) - text = response.text - logger.info(f"Upload response: {response.status_code} - {text[:200] if text else 'empty'}") - - if response.status_code in [200, 201]: - result = response.json() - logger.info(f"Uploaded to copyparty: {result.get('fileurl')}") - except Exception as e: - logger.error(f"Async upload failed: {e}", exc_info=True) - - async def _handle_local_file_play( - self, ctx: discord.ApplicationContext, file: str, compressed: bool - ) -> None: - """Handle file playback from copyparty URL.""" - url = self.path_mapping.get(file, file) # Look up URL from display name - - if compressed: - await self._handle_compressed_file_play(ctx, url) - else: - await get_voice_manager(self.bot).play(ctx, url) - - @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") - async def play( - self, - ctx: discord.ApplicationContext, - melodia: str = discord.Option( - str, - "odtwórz plik z serwera bądź strumienia", - autocomplete=_search_autocomplete, - required=False, - ), - file: discord.Attachment = discord.Option( - discord.Attachment, "plik do zagrania", required=False - ), - compressed: bool = discord.Option( - bool, "Czy skompresować dźwięk?", default=False - ), - ): - """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" - await ctx.defer() - - if melodia and file: - await ctx.respond( - "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True - ) - return - - if not melodia and not file: - await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True) - return - - if file: - file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) - await file.save(file_path) - await get_voice_manager(self.bot).play(ctx, file_path) - else: - try: - if EXTERNAL_URL_PATTERN.match(melodia.lower()): - await self._handle_external_url_play(ctx, melodia) - else: - await self._handle_local_file_play(ctx, melodia, compressed) - except Exception as e: - logger.error(f"Error playing: {str(e)}", exc_info=True) - await ctx.respond( - f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True - ) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -from discord.ext import commands - -from cogs.voice import get_voice_manager - -import logging - -logger = logging.getLogger(__name__) - - -class Stop(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @commands.slash_command(name="stop", description="stopuj bota") - async def stop(self, ctx): - await ctx.respond(content="\u200b", ephemeral=True) - await ctx.interaction.delete_original_response() - voice_mgr = get_voice_manager(self.bot) - await voice_mgr.disconnect(ctx.guild) - logger.info("Bot disconnected from voice channel") - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import asyncio -import logging -import os -import tempfile - -import aiohttp -import discord -import qrcode -from discord.ext import commands - -logger = logging.getLogger(__name__) - -_pil_image = None -_decode_func = None -_decode_available = False - - -def _ensure_qr(): - global _pil_image, _decode_func, _decode_available - - if _pil_image is not None: - return _decode_available - - try: - from PIL import Image - from pyzbar.pyzbar import decode as _decode_func - _pil_image = Image - _decode_available = True - except ImportError: - _decode_available = False - logger.warning("pyzbar or PIL not available, decode command will be disabled") - return _decode_available - - -class QR(commands.Cog): - def __init__(self, bot): - self.bot = bot - - async def _download_image(self, url): - """Download image from URL to temporary file""" - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 200: - content = await response.read() - with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f: - await asyncio.get_event_loop().run_in_executor(None, f.write, content) - return f.name - return None - - @commands.slash_command( - name="qr_generuj", description="Generuje kod QR z podanego tekstu" - ) - async def qr( - self, - ctx, - text: str = discord.Option(str, "Tekst do zakodowania", required=True), - ): - await ctx.defer() - - from cogs.utils.temp import get_temp_dir - temp_path = os.path.join( - get_temp_dir(), f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png" - ) - - try: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self._generate_qr, text, temp_path) - await ctx.respond(file=discord.File(temp_path, filename="qr.png")) - logger.info(f"Generated QR for text: {text}") - except Exception as e: - logger.error(f"QR generation error: {e}") - await ctx.respond("Error generating QR code", ephemeral=True) - finally: - try: - if os.path.exists(temp_path): - os.unlink(temp_path) - except Exception as e: - logger.error(f"Error cleaning up temp file: {e}") - - def _generate_qr(self, text, output_path): - """Generate QR code (runs in executor)""" - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore[attr-defined] - box_size=10, - border=4, - ) - qr.add_data(text) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - img.save(output_path) - - @commands.slash_command(name="qr_dekoduj", description="Dekoduje kod QR z obrazka") - async def decode( - self, - ctx, - image: discord.Attachment = discord.Option( - discord.Attachment, "Obrazek z kodem QR", required=True - ), - ): - if not _ensure_qr(): - await ctx.respond( - "Dekodowanie nie jest dostępne (brak biblioteki pyzbar)", ephemeral=True - ) - return - - await ctx.defer() - - try: - temp_path = await self._download_image(image.url) - if not temp_path: - await ctx.respond("Nie udało się pobrać obrazka", ephemeral=True) - return - - try: - loop = asyncio.get_event_loop() - decoded = await loop.run_in_executor(None, self._decode_qr, temp_path) - - if decoded: - await ctx.respond(f"```\n{decoded}\n```") - logger.info(f"Decoded QR: {decoded}") - else: - await ctx.respond( - "Nie znaleziono kodu QR na obrazku", ephemeral=True - ) - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) - except Exception as e: - logger.error(f"QR decode error: {e}") - await ctx.respond("Error dekodowania QR", ephemeral=True) - - def _decode_qr(self, image_path): - """Decode QR code from image (runs in executor)""" - if not _ensure_qr() or _decode_func is None: - return None - - try: - img = _pil_image.open(image_path) - decoded_list = _decode_func(img) - if decoded_list: - return decoded_list[0].data.decode("utf-8") - except Exception as e: - logger.error(f"Error decoding QR: {e}") - return None - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -from discord.ext import commands -import discord -import logging - -logger = logging.getLogger(__name__) - -class Say(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @discord.slash_command(name="say", description="senator, gadaj") - async def say( - self, - ctx: discord.ApplicationContext, - text: str = discord.Option(str, "Tekst do przeczytania", required=True) - ): - await ctx.defer() - await ctx.respond(content="\u200b", ephemeral=True) - await ctx.interaction.delete_original_response() - await ctx.send(content=text) - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import urllib.parse - -import discord -from discord.ext import commands -import logging -from discord import AutocompleteContext -from cogs.utils import file_search as search_utils - -logger = logging.getLogger(__name__) - -class Search(commands.Cog): - def __init__(self, bot): - self.bot = bot - self._cached_results = {} - - async def file_autocomplete(self, ctx: AutocompleteContext): - """Autocomplete function for file search.""" - query = ctx.value.lower() if ctx.value else "" - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) - - user_id = ctx.interaction.user.id - self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} - - choices = [] - for url in results[:25]: - filename = urllib.parse.unquote(url.split('/')[-1]) - display_name = filename[:99] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=filename)) - return choices - - @commands.slash_command( - name="search", - description="wyszukaj pliku" - ) - @discord.option( - name="filename", - description="Search for a file to send", - autocomplete=file_autocomplete - ) - async def search(self, ctx, filename: str): - """Send a file via URL from autocomplete selection.""" - if not filename: - await ctx.reply("Please select a file from the autocomplete options.") - return - - await ctx.defer() - logger.info(f"Search command called by {ctx.author} for file: {filename}") - - cached = self._cached_results.get(ctx.author.id) - if not cached: - await ctx.send_followup("Search results expired. Please search again.") - return - - matching_url = cached.get(filename) - if not matching_url: - await ctx.send_followup(f"File '{filename}' not found.") - return - - try: - await ctx.send_followup(matching_url) - logger.info(f"Successfully sent URL: {matching_url}") - del self._cached_results[ctx.author.id] - except Exception as e: - logger.error(f"Error sending URL {matching_url}: {str(e)}") - await ctx.send_followup(str(e)) - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import discord -from discord.ext import commands -import random -import logging - -from cogs.utils.database import get_connection, init_database - -logger = logging.getLogger(__name__) - -init_database() - -_SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?" -_SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?" -_SELECT_TRIGGER_ID_SQL = "SELECT id FROM triggers WHERE name=?" -_TRIGGER_NOT_FOUND_MSG = "Trigger not found." -_TRIGGER_NAME_OPTION = "the name of the trigger" - - -class Trigger(commands.Cog): - def __init__(self, bot): - self.bot = bot - - trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") - - @commands.Cog.listener() - async def on_message(self, message): - if message.author == self.bot.user: - return - - await self.trigger(message) - - @staticmethod - async def trigger(message): - content = message.content.lower() - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT id, name FROM triggers") - for trigger_id, name in c.fetchall(): - c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) - keywords = [row[0] for row in c.fetchall()] - if any(keyword in content for keyword in keywords): - c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) - responses = [row[0] for row in c.fetchall()] - if responses: - await message.channel.send(random.choice(responses).replace('\\n', '\n')) - logger.info(f"{message.author} triggered {name}") - break - - @staticmethod - def get_type(name): - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT name FROM triggers") - return [row[0] for row in c.fetchall()] - - @trigger_group.command( - description="Create a new trigger", - ) - async def create( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete= discord.utils.basic_autocomplete(get_type), - required=True - ) - ): - with get_connection() as conn: - c = conn.cursor() - c.execute("INSERT OR IGNORE INTO triggers (name) VALUES (?)", (name,)) - conn.commit() - - await ctx.respond("trigger added") - - @trigger_group.command( - description="Add a trigger or response to the name", - ) - async def add( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete= discord.utils.basic_autocomplete(get_type), - required=True - ), - trigger = discord.Option( - str, - "the trigger to add to the name", - required=False - ), - response = discord.Option( - str, - "the response to add to the name", - required=False - ) - ): - with get_connection() as conn: - c = conn.cursor() - c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) - row = c.fetchone() - if not row: - await ctx.respond(_TRIGGER_NOT_FOUND_MSG) - logger.warning(f"Trigger not found: {name}") - return - trigger_id = row[0] - if trigger: - for keyword in trigger.split('\\'): - keyword = keyword.strip() - if keyword: - c.execute("INSERT INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)", (trigger_id, keyword)) - if response: - for resp in response.split('\\'): - resp = resp.strip() - if resp: - c.execute("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)", (trigger_id, resp)) - conn.commit() - - await ctx.respond("trigger updated") - logger.info(f"Trigger updated: {name}") - - @trigger_group.command( - description="Remove a trigger or response from the name", - ) - async def remove( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete= discord.utils.basic_autocomplete(get_type), - required=True - ), - trigger = discord.Option( - str, - "the trigger to remove from the name", - required=False - ), - response = discord.Option( - str, - "the response to remove from the name", - required=False - ) - ): - with get_connection() as conn: - c = conn.cursor() - c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) - row = c.fetchone() - if not row: - await ctx.respond(_TRIGGER_NOT_FOUND_MSG) - logger.warning(f"Trigger not found: {name}") - return - trigger_id = row[0] - if trigger: - c.execute("DELETE FROM trigger_keywords WHERE trigger_id=? AND keyword=?", (trigger_id, trigger)) - if response: - c.execute("DELETE FROM trigger_responses WHERE trigger_id=? AND response_text=?", (trigger_id, response)) - conn.commit() - - await ctx.respond("trigger removed") - logger.info(f"Trigger removed: {name}") - - @trigger_group.command( - description="Delete the whole trigger", - ) - async def delete( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete= discord.utils.basic_autocomplete(get_type), - required=True - ) - ): - with get_connection() as conn: - c = conn.cursor() - c.execute(_SELECT_TRIGGER_ID_SQL, (name,)) - row = c.fetchone() - if not row: - await ctx.respond(_TRIGGER_NOT_FOUND_MSG) - return - trigger_id = row[0] - c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) - c.execute("DELETE FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) - c.execute("DELETE FROM triggers WHERE id=?", (trigger_id,)) - conn.commit() - - await ctx.respond("trigger deleted") - logger.info(f"Trigger deleted: {name}") - - - @trigger_group.command( - description="List all triggers", - ) - async def list( - self, - ctx: discord.ApplicationContext, - name = discord.Option( - str, - _TRIGGER_NAME_OPTION, - autocomplete=discord.utils.basic_autocomplete(get_type), - required=True - ) - ): - with get_connection() as conn: - c = conn.cursor() - if name: - c.execute("SELECT id FROM triggers WHERE name=?", (name,)) - row = c.fetchone() - if not row: - await ctx.respond(_TRIGGER_NOT_FOUND_MSG) - logger.warning(f"Trigger not found: {name}") - return - trigger_id = row[0] - c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) - triggers = [row[0] for row in c.fetchall()] - c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) - responses = [row[0] for row in c.fetchall()] - trigger_list = "\n".join(triggers) - response_list = "\n".join(responses) - message = f"**Trigger: {name}**\n**Triggers:**\n{trigger_list}\n**Responses:**\n{response_list}" - await ctx.respond(message) - logger.info(f"Trigger listed: {name}") - else: - c.execute("SELECT id, name FROM triggers") - messages = [] - for trigger_id, name in c.fetchall(): - c.execute(_SELECT_KEYWORD_SQL, (trigger_id,)) - triggers = ", ".join([row[0] for row in c.fetchall()]) - c.execute(_SELECT_RESPONSE_SQL, (trigger_id,)) - responses = ", ".join([row[0] for row in c.fetchall()]) - messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}") - await ctx.respond("\n\n".join(messages)) - logger.info("Triggers listed") - - @trigger_group.command( - description="how to use trigger group?" - ) - async def help( - self, - ctx: discord.ApplicationContext, - ): - await ctx.respond("Trigger create - creates a group for triggers/responses\n" - "Trigger add - adds triggers/responses to a group, \\ is the delimeter\n" - "Trigger remove - removes triggers/responses from a group, \\ is the delimeter\n" - "Trigger delete - deletes the whole group\n" - "Trigger list - lists triggers/responses of a group\n" - "Trigger help - display this message\n") - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import json -import discord -import logging -import tempfile -from discord.ext import commands -from gtts import gTTS -import asyncio -from cogs.utils.settings import get_volume - -logger = logging.getLogger(__name__) - -class TTS(commands.Cog): - def __init__(self, bot): - self.bot = bot - - async def _handle_file_output(self, ctx, temp_path, text): - """Handle TTS file output""" - try: - await ctx.respond(file=discord.File(temp_path, filename='tts.mp3')) - logger.info(f"Sent TTS for text: {text}") - except Exception as e: - logger.error(f"Error sending file: {e}") - await ctx.respond("eror :c", ephemeral=True) - - def _get_voice_manager(self): - from cogs.voice import get_voice_manager - return get_voice_manager(self.bot) - - async def _get_or_connect_voice_client(self, channel): - """Get existing voice client or connect to new channel""" - voice_mgr = self._get_voice_manager() - return await voice_mgr.join_channel(channel) - - async def _handle_voice_output(self, ctx, temp_path, text, volume): - """Handle TTS voice channel output""" - await ctx.respond(content="\u200b", ephemeral=True) - await ctx.interaction.delete_original_response() - - if not (ctx.author.voice and ctx.author.voice.channel): - logger.info("User is not in a voice channel") - return - - channel = ctx.author.voice.channel - try: - voice_mgr = self._get_voice_manager() - - if voice_mgr.get_voice_client and await voice_mgr.get_client(channel.guild): - vc = await voice_mgr.get_client(channel.guild) - if vc and vc.is_playing(): - logger.info("Audio already playing, skipping TTS") - return - - audio_source = discord.FFmpegPCMAudio( - temp_path, - options=f'-filter:a "volume={volume}"' - ) - logger.info(f"Playing TTS: {text}") - await voice_mgr.play_source(channel, audio_source) - - except Exception as e: - logger.error(f"Error in voice channel handling: {e}") - await ctx.respond("eror :c", ephemeral=True) - - async def _cleanup_temp_file(self, temp_path): - """Clean up temporary file""" - try: - if temp_path and os.path.exists(temp_path): - os.unlink(temp_path) - except Exception as e: - logger.error(f"Error cleaning up temp file {temp_path}: {e}") - - @commands.slash_command(name="tts", description="Generuje TTS dla danego tekstu") - async def tts(self, ctx, - text: str = discord.Option(str, "Tekst do przeczytania", required=True), - output: str = discord.Option(str, "Wybierz sposób odtwarzania", choices=["file", "vc"], default="vc") - ): - await ctx.defer() - - # Create temporary file in temp folder - from cogs.utils.temp import get_temp_dir - temp_path = os.path.join(get_temp_dir(), f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") - - try: - # Generate and save TTS to temp file - tts = gTTS(text=text, lang='pl') - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, tts.save, temp_path) - - # Load settings - volume = get_volume() - - # Handle output based on user choice - if output == "file": - await self._handle_file_output(ctx, temp_path, text) - elif output == "vc": - await self._handle_voice_output(ctx, temp_path, text, volume) - - except Exception as e: - logger.error(f"TTS generation error: {e}") - await ctx.respond("Error generating TTS", ephemeral=True) - finally: - await self._cleanup_temp_file(temp_path) - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import aiofiles -import requests -import urllib.parse -from typing import Optional -import discord -from discord.ext import commands -from discord import AutocompleteContext -from cogs.utils import file_search as search_utils -from cogs.utils.settings import get_uploadurl -import logging - -logger = logging.getLogger(__name__) - - -class Uploader(commands.Cog): - def __init__(self, bot): - from cogs.utils.temp import get_temp_dir - self.bot = bot - self.temp_dir = get_temp_dir() - self._cached_urls = {} - self._uploadurl = get_uploadurl() - - async def _file_autocomplete(self, ctx: AutocompleteContext): - query = ctx.value.lower() if ctx.value else "" - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) - - user_id = ctx.interaction.user.id - self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} - - choices = [] - for url in results[:25]: - filename = urllib.parse.unquote(url.split('/')[-1]) - display_name = filename[:99] + "…" if len(filename) > 100 else filename - choices.append(discord.OptionChoice(name=display_name, value=filename)) - return choices - - async def _download_from_url(self, url: str) -> Optional[str]: - """Download file from copyparty URL and return local path""" - try: - response = requests.get(url) - if response.status_code != 200: - return None - - filename = url.split('/')[-1].split('?')[0] - filename = urllib.parse.unquote(filename) - file_path = os.path.join(self.temp_dir, filename) - - async with aiofiles.open(file_path, 'wb') as f: - await f.write(response.content) - return file_path - except Exception as e: - logger.error(f"Error downloading from {url}: {e}") - return None - - async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: - """Upload file to copyparty and return download URL""" - try: - async with aiofiles.open(file_path, 'rb') as f: - file_content = await f.read() - - files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} - headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} - params = {'j': '1', 'ck': 'b2'} - - response = requests.post(self._uploadurl, files=files, headers=headers, params=params) - - if response.status_code in [200, 201]: - result = response.json() - return result.get('fileurl') - except Exception as e: - logger.error(f"Upload error: {e}") - return None - - async def _upload_file(self, file_path: str) -> Optional[str]: - """Upload a file and return the URL""" - if self._uploadurl == "http://localhost:3939": - logger.warning("COPYPARTY_URL_UPLOAD not set! Using default") - return None - - return await self._upload_to_copyparty(file_path) - - @commands.message_command( - name="upload", - description="wyślij załączniki na copyparty", - ) - async def message_upload(self, ctx, message: discord.Message): - if not message.attachments: - await ctx.respond("Brak załączników w wiadomości", ephemeral=True) - return - - await ctx.defer() - - uploaded_urls = [] - failed_count = 0 - - for attachment in message.attachments: - file_path = os.path.join(self.temp_dir, attachment.filename) - await attachment.save(file_path) - - url = await self._upload_file(file_path) - if url: - uploaded_urls.append(url) - else: - failed_count += 1 - - if os.path.exists(file_path): - os.remove(file_path) - - if uploaded_urls: - response = f"Wysłano {len(uploaded_urls)} plików:\n" + "\n".join(uploaded_urls) - if failed_count > 0: - response += f"\n\nNie wysłano {failed_count} plików" - await ctx.respond(response) - else: - await ctx.respond("Nie udało się wysłać żadnych plików", ephemeral=True) - - @commands.slash_command( - name="upload", - description="wyślij plik na copyparty", - ) - @discord.option( - name="zalacznik", - description="plik z discorda", - required=True, - type=discord.Attachment - ) - async def upload(self, ctx, zalacznik: discord.Attachment = None): - await ctx.defer() - - file_path = None - file_path = os.path.join(self.temp_dir, zalacznik.filename) - await zalacznik.save(file_path) - - if not file_path or not os.path.exists(file_path): - await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) - return - - try: - dl_url = await self._upload_file(file_path) - if dl_url: - await ctx.respond(dl_url) - else: - await ctx.respond("Nie udało się wysłać pliku", ephemeral=True) - finally: - if file_path and os.path.exists(file_path): - os.remove(file_path) - - -def setup(bot): - 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 @@ -import os -import sqlite3 -import logging -from contextlib import contextmanager - -logger = logging.getLogger(__name__) - -DB_PATH = "data/senator.db" - -_initialized = False - - -def get_connection(): - conn = sqlite3.connect(get_db_path()) - return _ConnectionContextManager(conn) - - -def get_db_path(): - return os.getenv("SENATOR_DB_PATH", DB_PATH) - - -def set_db_path(path): - global DB_PATH - DB_PATH = path - - -class _ConnectionContextManager: - """Context manager for sqlite3 connections that auto-closes.""" - def __init__(self, conn): - self.conn = conn - - def __enter__(self): - return self.conn - - def __exit__(self, exc_type, exc_val, exc_tb): - self.conn.close() - return False - - -def init_database(): - global _initialized - if _initialized: - return - - _initialized = True - - db_path = get_db_path() - db_dir = os.path.dirname(db_path) - if db_dir and not os.path.exists(db_dir): - os.makedirs(db_dir) - - conn = sqlite3.connect(db_path) - try: - c = conn.cursor() - - c.execute("PRAGMA foreign_keys = ON") - - c.execute(""" - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT - )""") - - c.execute(""" - CREATE TABLE IF NOT EXISTS cron_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - cron_expression TEXT NOT NULL, - type TEXT NOT NULL, - channel_id INTEGER NOT NULL, - active INTEGER DEFAULT 1, - audio_filename TEXT, - message TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )""") - - c.execute(""" - CREATE TABLE IF NOT EXISTS voice_activity ( - user_id TEXT PRIMARY KEY, - join_time REAL, - total_active REAL DEFAULT 0, - total_afk REAL DEFAULT 0, - is_afk INTEGER DEFAULT 0, - afk_start REAL, - active_start REAL, - channel_name TEXT, - last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )""") - - c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)") - c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)") - - c.execute(""" - CREATE TABLE IF NOT EXISTS triggers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )""") - - c.execute(""" - CREATE TABLE IF NOT EXISTS trigger_keywords ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trigger_id INTEGER NOT NULL, - keyword TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, - UNIQUE(trigger_id, keyword) - )""") - - c.execute(""" - CREATE TABLE IF NOT EXISTS trigger_responses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trigger_id INTEGER NOT NULL, - response_text TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE - )""") - - c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)") - c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)") - c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)") - - conn.commit() - logger.info("Database initialized successfully") - finally: - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import json -import logging -import aiohttp -import os -from typing import List -from cogs.utils.settings import get_baseurl - -logger = logging.getLogger(__name__) - - -class UniversalSearcher: - """Handles searching for files via copyparty API.""" - - @staticmethod - async def search_copyparty(query: str, max_results: int = 25, file_extensions: List[str] | None = None): - """Search for files using copyparty API (jPOST method). - - Args: - query: Search query string - max_results: Maximum number of results to return - file_extensions: Optional list of file extensions to filter by (e.g., ['mp3', 'wav']) - - Returns: - List of matching file URLs - """ - if not query: - return [] - - query = query.strip() - logger.info(f"Starting copyparty search for query: '{query}'") - - tokens = query.split() - if not tokens: - return [] - - token_queries = [] - for token in tokens: - path_query = f'path like "*{token}*"' - name_query = f'name like "*{token}*"' - token_queries.append(f"({path_query} or {name_query})") - - if len(token_queries) == 1: - name_query = token_queries[0] - else: - name_query = " and ".join(token_queries) - - if file_extensions: - ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions] - ext_query = " or ".join(ext_filters) - query = f"{name_query} and ({ext_query})" - else: - query = name_query - - try: - baseurl = get_baseurl() - password = os.getenv("COPYPARTY_PASSWORD") - - async with aiohttp.ClientSession() as session: - headers = {"Content-Type": "application/json"} - if password: - headers["PW"] = password - async with session.post(f"{baseurl}/?srch", headers=headers, data=json.dumps({"n": max_results, "q": query})) as response: - if response.status == 200: - data = await response.json() - files = UniversalSearcher._extract_files_from_response(data) - results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results) - - logger.info(f"Copyparty search complete. Found {len(results)} matches.") - return results - else: - logger.error(f"Copyparty API returned status {response.status}") - return [] - - except aiohttp.ClientError as e: - logger.error(f"HTTP error during copyparty search: {e}") - return [] - except Exception as e: - logger.error(f"Error during copyparty search: {str(e)}", exc_info=True) - return [] - - @staticmethod - def _extract_files_from_response(data): - """Extract files list from copyparty API response.""" - if isinstance(data, dict): - if 'hits' in data: - return data['hits'] - elif 'files' in data: - return data['files'] - elif 'results' in data: - return data['results'] - else: - return data if isinstance(data, list) else [] - elif isinstance(data, list): - return data - else: - return [] - - @staticmethod - def _convert_files_to_urls(files, baseurl, max_results): - """Convert file info objects to URLs.""" - results = [] - - for file_info in files[:max_results]: - if isinstance(file_info, dict): - file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl) - if file_url: - results.append(file_url) - elif isinstance(file_info, str): - if file_info.startswith('http'): - results.append(file_info) - else: - results.append(f"{baseurl}/{file_info.lstrip('/')}") - - return results - - @staticmethod - def _extract_url_from_dict(file_info, baseurl): - """Extract URL from file info dictionary.""" - if 'url' in file_info: - return file_info['url'] - elif 'rp' in file_info: - return f"{baseurl}/{file_info['rp']}" - elif 'path' in file_info: - return f"{baseurl}/{file_info['path'].lstrip('/')}" - elif 'name' in file_info: - return f"{baseurl}/{file_info['name'].lstrip('/')}" - else: - return None - - @staticmethod - async def search_copyparty_autocomplete(query: str, max_results: int = 10): - """Wrapper for autocomplete searches using copyparty (general search - no file type filtering). - - Args: - query: Search query string - max_results: Maximum number of results to return - - Returns: - List of matching file URLs - """ - if not query: - return [] - - return await UniversalSearcher.search_copyparty(query, max_results) - - @staticmethod - async def search_copyparty_autocomplete_audio(query: str, max_results: int = 10): - """Wrapper for autocomplete searches using copyparty (audio files only). - - Args: - query: Search query string - max_results: Maximum number of results to return - - Returns: - List of matching file URLs (audio files only) - """ - if not query: - return [] - - # Audio file extensions for music commands - audio_extensions = ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'mp4', "mkv"] - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import logging -import sqlite3 -import os -from dataclasses import dataclass, field - -from cogs.utils.database import get_connection, init_database - -logger = logging.getLogger(__name__) - -DEFAULT_BASEURL = "http://localhost:3939" - -init_database() - - -@dataclass -class Settings: - volume: float = 1.0 - baseurl: str = DEFAULT_BASEURL - _data: dict = field(default_factory=dict, init=False) - - async def load(self) -> "Settings": - default_settings = { - "volume": 1.0, - "baseurl": DEFAULT_BASEURL, - } - - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT key, value FROM settings") - rows = c.fetchall() - - if rows: - self._data = {row[0]: row[1] for row in rows} - self._sync_from_data() - logger.info("Settings loaded from database") - else: - self._data = default_settings.copy() - self._sync_from_data() - await self.save() - logger.info("Created default settings in database") - except Exception as e: - logger.error(f"Error loading settings: {e}") - self._data = default_settings.copy() - self._sync_from_data() - - return self - - def _sync_from_data(self): - try: - self.volume = float(self._data.get("volume", 1.0)) - except (ValueError, TypeError): - self.volume = 1.0 - self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL) - - def _sync_to_data(self): - self._data["volume"] = str(self.volume) - self._data["baseurl"] = self.baseurl - - async def save(self): - self._sync_to_data() - with get_connection() as conn: - c = conn.cursor() - c.execute("PRAGMA foreign_keys = ON") - for key, value in self._data.items(): - c.execute( - "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", - (key, value), - ) - conn.commit() - - -_settings_instance: Settings | None = None - - -async def load_settings() -> Settings: - global _settings_instance - _settings_instance = Settings() - await _settings_instance.load() - return _settings_instance - - -async def save_settings(): - if _settings_instance: - await _settings_instance.save() - - -def get_settings() -> Settings | None: - return _settings_instance - - -def get_volume() -> float: - return _settings_instance.volume if _settings_instance else 1.0 - - -def get_baseurl() -> str: - url = os.getenv("COPYPARTY_URL") - if not url: - logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939") - return DEFAULT_BASEURL - return url.rstrip("/") - - -def get_uploadurl() -> str: - url = os.getenv("COPYPARTY_URL_UPLOAD") - if not url: - logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939") - return DEFAULT_BASEURL - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import os -import tempfile - -def get_temp_dir(subdir: str = "senator_sperma") -> str: - """Get or create a temp subdirectory. - - Uses system temp directory (/tmp on Linux, %TEMP% on Windows). - OS handles cleanup automatically. - """ - base_temp = tempfile.gettempdir() - temp_path = os.path.join(base_temp, subdir) - os.makedirs(temp_path, exist_ok=True) - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - - -def clean_url_for_ytdlp(url: str) -> str: - """Clean URL by removing everything after the first '&'. - - This prevents yt-dlp from interpreting additional query parameters - (like &list=...) as playlist URLs. - - Example: - Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM& - Output: https://www.youtube.com/watch?v=lPGipwoJiOM - """ - # Find the first '&' and truncate - first_ampersand = url.find('&') - if first_ampersand != -1: - return url[:first_ampersand] - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import datetime -import logging -import os -import asyncio -import discord -from discord.ext import commands -from gtts import gTTS -from cogs.voice import get_voice_manager -from cogs.utils.settings import get_volume, save_settings -from cogs.utils.database import get_connection, init_database - -import tempfile - -logger = logging.getLogger(__name__) - -init_database() - - -def load_activity_data(): - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") - rows = c.fetchall() - - return { - row[0]: { - "join_time": row[1], - "total_active": row[2], - "total_afk": row[3], - "is_afk": bool(row[4]), - "afk_start": row[5], - "active_start": row[6], - "channel_name": row[7], - } - for row in rows - } - except Exception as e: - logger.error(f"Error loading voice activity: {e}") - return {} - - -def save_activity_data(data): - try: - with get_connection() as conn: - c = conn.cursor() - c.execute("PRAGMA foreign_keys = ON") - - c.execute("DELETE FROM voice_activity") - - for user_id, user_data in data.items(): - c.execute( - """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - ( - user_id, - user_data.get("join_time"), - user_data.get("total_active", 0), - user_data.get("total_afk", 0), - int(user_data.get("is_afk", False)), - user_data.get("afk_start"), - user_data.get("active_start"), - user_data.get("channel_name"), - ), - ) - conn.commit() - logger.info(f"Saved voice activity for {len(data)} users") - except Exception as e: - logger.error(f"Error saving voice activity: {e}") - - -def format_duration(seconds): - hours, remainder = divmod(int(seconds), 3600) - minutes, seconds = divmod(remainder, 60) - if hours > 0: - return f"{hours}h {minutes}m {seconds}s" - elif minutes > 0: - return f"{minutes}m {seconds}s" - return f"{seconds}s" - - -class VcLogs(commands.Cog): - def __init__(self, bot): - self.bot = bot - self._voice_manager = None - - def get_voice_manager(self): - if self._voice_manager is None: - self._voice_manager = get_voice_manager(self.bot) - return self._voice_manager - - @staticmethod - async def handle_state_change(member, channel, state_type, is_enabled, log_channel): - """Helper method to handle voice state changes""" - state_messages = { - 'deaf': { - True: f"{member.display_name} jest bity przez ojca", - False: f"{member.display_name} uciek od ojca" - }, - 'mute': { - True: f"{member.display_name} idzie robić jajecznice", - False: f"{member.display_name} zrobił jajecznice" - } - } - - tts_text = state_messages[state_type][is_enabled] - return tts_text, channel - - async def play_tts(self, channel, text): - from cogs.utils.temp import get_temp_dir - - voice_mgr = self.get_voice_manager() - temp_dir = get_temp_dir() - tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") - tts = gTTS(text=text, lang='pl') - logger.info("saving file") - - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, tts.save, tts_file) - - try: - voice_status = await voice_mgr.get_voice_status(channel.guild) - voice_client = voice_status[0] if voice_status else None - is_connected = voice_status[1] if voice_status else False - is_playing = voice_status[2] if voice_status else False - - if is_connected: - from cogs.voice import is_voice_client_alive - if not is_voice_client_alive(voice_client, channel.guild): - logger.warning("Stale voice client in play_tts, reconnecting") - try: - await voice_client.disconnect() - except Exception: - pass - is_connected = False - - if not is_connected: - voice_client = await voice_mgr.join_channel(channel) - voice_status = await voice_mgr.get_voice_status(channel.guild) - voice_client = voice_status[0] if voice_status else None - is_connected = voice_status[1] if voice_status else False - is_playing = voice_status[2] if voice_status else False - - if not is_connected or voice_client is None: - logger.error("Failed to join voice channel") - return - - volume = get_volume() - logger.info(f"connected: {voice_client}") - - if voice_client: - if is_playing: - logger.info("voice already playing, skipping TTS") - return - - logger.info("preparing to play audio") - audio_source = discord.FFmpegPCMAudio(tts_file) - await voice_mgr.play_source(channel, audio_source, volume=volume) - except Exception as e: - logger.error(f"TTS playback error: {e}") - finally: - if os.path.exists(tts_file): - os.remove(tts_file) - - @commands.slash_command( - name="log_volume" - ) - async def volume(self, ctx: discord.ApplicationContext, volume: int): - from cogs.utils.settings import get_settings - settings = get_settings() - settings.volume = volume - await save_settings() - await ctx.followup.send(f"Volume has been set to: {volume}") - logger.info(f"Volume changed to: {volume}") - - @commands.Cog.listener() - async def on_voice_state_update(self, member, before, after): - log_channel_id = os.getenv("STATUS_CHANNEL_ID") - if not log_channel_id: - logger.warning("STATUS_CHANNEL_ID not set in environment") - return - - log_channel = self.bot.get_channel(int(log_channel_id)) - if log_channel is None: - logger.warning("Log channel not found or not accessible") - return - - current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - current_ts = datetime.datetime.now().timestamp() - tts_text = None - tts_channel = None - - if member.bot: - return - - user_id = str(member.id) - activity_data = load_activity_data() - - if before.channel is None and after.channel is not None: - is_afk = after.self_mute or after.self_deaf - activity_data[user_id] = { - 'join_time': current_ts, - 'total_active': 0, - 'total_afk': 0, - 'is_afk': is_afk, - 'afk_start': current_ts if is_afk else None, - 'active_start': current_ts if not is_afk else None, - 'channel_name': after.channel.name - } - save_activity_data(activity_data) - - message = f"[{current_time}] {member.name} dołączył na {after.channel.name} " - tts_text = f"{member.display_name} sie zespawnił" - tts_channel = after.channel - await log_channel.send(message) - logger.info(f"User joined voice channel: {message}") - - elif before.channel is not None and after.channel is None: - if user_id in activity_data: - data = activity_data[user_id] - - if data.get('is_afk') and data.get('afk_start'): - afk_duration = current_ts - data['afk_start'] - data['total_afk'] += afk_duration - elif data.get('active_start'): - active_since_start = current_ts - data['active_start'] - data['total_active'] += active_since_start - - active_str = format_duration(data['total_active']) - afk_str = format_duration(data['total_afk']) - - message = ( - f"[{current_time}] {member.name} rozłączył się z {before.channel.name} \n" - f"Aktywny: {active_str} | AFK: {afk_str}" - ) - tts_text = f"{member.display_name} jest chujem" - tts_channel = before.channel - await log_channel.send(message) - logger.info(f"User left voice channel: {message}") - - del activity_data[user_id] - save_activity_data(activity_data) - else: - message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} " - tts_text = f"{member.display_name} jest chujem" - tts_channel = before.channel - await log_channel.send(message) - logger.info(f"User left voice channel: {message}") - - elif user_id in activity_data: - data = activity_data[user_id] - - if data.get('is_afk') and not (after.self_mute or after.self_deaf): - afk_duration = current_ts - data['afk_start'] - data['total_afk'] += afk_duration - data['afk_start'] = None - data['is_afk'] = False - data['active_start'] = current_ts - save_activity_data(activity_data) - - afk_str = format_duration(afk_duration) - - if after.self_mute: - activity = "odciszył" - else: - activity = "odgłuchnął" - - message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} \n" - f"AFK: {afk_str}" ) - await log_channel.send(message) - logger.info(f"User returned from AFK: {message}") - elif not data.get('is_afk') and (after.self_mute or after.self_deaf): - active_since_start = 0 - if data.get('active_start'): - active_since_start = current_ts - data['active_start'] - data['total_active'] += active_since_start - data['afk_start'] = current_ts - data['is_afk'] = True - data['active_start'] = None - save_activity_data(activity_data) - - if after.self_mute: - activity = "wyciszył" - else: - activity = "ogłuchnął" - - active_str = format_duration(active_since_start) - message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} \n" - f"Aktywny: {active_str}" ) - await log_channel.send(message) - logger.info(f"User went AFK: {message}") - - if before.self_deaf != after.self_deaf: - tts_text, tts_channel = await self.handle_state_change( - member, after.channel, 'deaf', after.self_deaf, log_channel - ) - if tts_text and tts_channel: - await self.play_tts(tts_channel, tts_text) - return - - if before.self_mute != after.self_mute: - tts_text, tts_channel = await self.handle_state_change( - member, after.channel, 'mute', after.self_mute, log_channel - ) - - if tts_text and tts_channel: - await self.play_tts(tts_channel, tts_text) - - -def setup(bot): - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import discord -import logging -import asyncio -import os -import urllib.parse -from dataclasses import dataclass, field -from typing import Any - -import mutagen - -logger = logging.getLogger(__name__) - - -_voice_managers: dict[int, "VoiceManager"] = {} - - -def get_voice_manager(bot) -> "VoiceManager": - """Get or create VoiceManager for a specific bot instance.""" - bot_id = bot.user.id if bot.user else id(bot) - if bot_id not in _voice_managers: - _voice_managers[bot_id] = VoiceManager(bot) - return _voice_managers[bot_id] - - -def is_voice_client_alive(voice_client, guild): - """Check if a voice client connection is healthy.""" - if voice_client is None: - return False - if not voice_client.is_connected(): - return False - if voice_client.channel is None: - return False - if guild.get_channel(voice_client.channel.id) is None: - return False - - try: - ws = voice_client._connection - s = ws.socket - if s is None or s.fileno() == -1: - return False - except Exception: - return False - - return True - - -@dataclass -class VoiceManager: - bot: Any - _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) - _connecting: set[int] = field(default_factory=set, init=False) - - # --- Connection ----------------------------------------------------------- - - async def _ensure_voice_client(self, channel): - """Unified connect/move/reconnect logic. - - Returns a healthy VoiceClient connected to *channel*, or None on failure. - """ - guild = channel.guild - guild_id = guild.id - - async with self._lock: - if guild_id in self._connecting: - logger.info(f"Connection already in progress for guild {guild_id}, skipping") - return None - - vc = discord.utils.get(self.bot.voice_clients, guild=guild) - - # --- resolve existing client -------------------------------------- - if vc is not None: - if vc.is_connected(): - if vc.channel == channel: - if is_voice_client_alive(vc, guild): - logger.info(f"Already connected to {channel}") - return vc - logger.warning(f"Stale connection to {channel}, reconnecting") - # Disconnect resets the state machine to 'disconnected' - # but keeps the VoiceClient alive. Reusing it avoids a - # race where Discord sends VOICE_SERVER_UPDATE between - # the destroy and the fresh-connect below, causing the - # new client to timeout waiting for the event (since the - # old one already consumed it). - try: - await vc.disconnect() - except Exception: - pass - try: - await vc.connect( - reconnect=False, timeout=10.0, self_deaf=False, self_mute=False - ) - logger.info(f"Reconnected to voice channel: {channel}") - return vc - except Exception as e: - logger.error(f"Failed to reconnect stale voice client: {e}") - try: - channel._state._remove_voice_client(guild.id) - except Exception: - pass - vc = None - else: - try: - await vc.move_to(channel) - logger.info(f"Moved to voice channel: {channel}") - return vc - except Exception as e: - logger.error(f"Failed to move voice client: {e}") - try: - await vc.disconnect() - except Exception: - pass - try: - await vc.connect( - reconnect=False, timeout=10.0, self_deaf=False, self_mute=False - ) - logger.info(f"Reconnected after failed move: {channel}") - return vc - except Exception: - pass - try: - channel._state._remove_voice_client(guild.id) - except Exception: - pass - vc = None - else: - logger.info("Voice client exists but disconnected, reconnecting") - try: - await vc.disconnect() - except Exception: - pass - try: - await vc.connect( - reconnect=False, timeout=10.0, self_deaf=False, self_mute=False - ) - logger.info(f"Reconnected disconnected client: {channel}") - return vc - except Exception as e: - logger.error(f"Failed to reconnect disconnected client: {e}") - try: - channel._state._remove_voice_client(guild.id) - except Exception: - pass - vc = None - - # --- create fresh connection -------------------------------------- - self._connecting.add(guild_id) - try: - await asyncio.sleep(0.3) - try: - vc = await channel.connect(timeout=10.0, reconnect=False) - logger.info(f"Connected to voice channel: {channel}") - return vc - except discord.ClientException as e: - if "Already connected" in str(e): - logger.warning(f"Stale voice state detected, forcing reconnect: {e}") - vc = discord.utils.get(self.bot.voice_clients, guild=guild) - if vc: - try: - await vc.disconnect() - except Exception: - pass - else: - try: - channel._state._remove_voice_client(guild.id) - except Exception: - pass - await asyncio.sleep(0.3) - try: - vc = await channel.connect(timeout=10.0, reconnect=False) - logger.info(f"Reconnected to voice channel: {channel}") - return vc - except Exception as e2: - logger.error(f"Failed to reconnect after stale cleanup: {e2}") - return None - logger.error(f"Failed to connect to voice channel: {e}") - return None - except Exception as e: - logger.error(f"Failed to connect to voice channel: {e}") - return None - finally: - self._connecting.discard(guild_id) - - async def connect(self, channel): - """Join a voice channel (public alias).""" - return await self._ensure_voice_client(channel) - - async def join_channel(self, channel): - """Backward-compatible alias for connect().""" - return await self.connect(channel) - - async def join_by_channel_id(self, channel_id): - """Join a voice channel by channel ID.""" - channel = self.bot.get_channel(channel_id) - if not channel: - logger.error(f"Channel {channel_id} not found") - return None - if not isinstance(channel, discord.VoiceChannel): - logger.error(f"Channel {channel_id} is not a voice channel") - return None - return await self.connect(channel) - - async def get_client(self, guild): - """Get existing voice client for a guild.""" - return discord.utils.get(self.bot.voice_clients, guild=guild) - - async def get_voice_client(self, guild): - """Backward-compatible alias for get_client().""" - return await self.get_client(guild) - - async def get_status(self, guild): - """Get voice client status. - - Returns: - tuple: (voice_client, is_connected, is_playing) or (None, False, False) - """ - vc = await self.get_client(guild) - if vc is None: - return None, False, False - return vc, vc.is_connected(), vc.is_playing() - - async def get_voice_status(self, guild): - """Backward-compatible alias for get_status().""" - return await self.get_status(guild) - - async def disconnect(self, guild): - """Stop playback and disconnect from voice.""" - vc = await self.get_client(guild) - if vc: - vc.stop() - logger.info("Disconnecting from voice") - await vc.disconnect() - logger.info(f"Disconnected from guild {guild}") - - async def stop(self, guild): - """Backward-compatible alias for disconnect().""" - await self.disconnect(guild) - - # --- Playback ------------------------------------------------------------ - - async def _play_await(self, vc, audio_source): - """Play an audio source and wait for completion using an asyncio.Future. - - Returns True on success, False on error. No polling, no magic sleeps. - """ - loop = asyncio.get_event_loop() - future = loop.create_future() - - def after(error): - if not future.done(): - if error: - future.set_exception(Exception(str(error))) - else: - future.set_result(True) - - vc.play(audio_source, after=after) - - try: - await future - return True - except Exception as e: - logger.error(f"Playback error: {e}") - return False - - async def play_source(self, channel, audio_source, interrupt=True, volume=1.0): - """Connect to *channel* and play a pre-made audio source. - - Returns True on success, False on failure. - """ - vc = await self.connect(channel) - if not vc: - logger.error("play_source: could not connect to voice channel") - return False - - if interrupt and (vc.is_playing() or vc.is_paused()): - vc.stop() - - if abs(volume - 1.0) > 1e-9: - audio_source = discord.PCMVolumeTransformer(audio_source) - audio_source.volume = volume - - logger.info("Playing via play_source") - return await self._play_await(vc, audio_source) - - # --- Source preparation (folded from AudioPlaybackManager) --------------- - - @staticmethod - async def validate_voice_channel(ctx): - """Validate user is in a voice channel. Returns the channel or None.""" - if not ctx.author.voice or not ctx.author.voice.channel: - await ctx.respond( - "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True - ) - return None - return ctx.author.voice.channel - - @staticmethod - async def prepare_file_source(source): - """Validate a local file and return (display_name, absolute_path) or (None, None).""" - logger.info(f"Attempting to play local file: {source}") - - if not os.path.isabs(source): - source = os.path.abspath(source) - logger.debug(f"Converted to absolute path: {source}") - - if not os.path.exists(source): - logger.error(f"File not found: {source}") - return None, None - - filename = os.path.basename(source) - display_name = os.path.splitext(filename)[0] - return display_name, source - - @staticmethod - async def prepare_audio_source(source, is_url, display_name): - """Prepare FFmpegPCMAudio from a URL or local file. - - Returns (audio_source, final_display_name). - """ - if is_url: - before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" - audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) - filename = os.path.basename(source.split("?")[0]) - filename = urllib.parse.unquote(filename) - clean_filename = os.path.splitext(filename)[0] - return audio_source, clean_filename - else: - audio_source = discord.FFmpegPCMAudio(source=source) - try: - metadata = mutagen.open(source) - if metadata: - title = ( - metadata.get("title", [None])[0] - if hasattr(metadata, "get") - else None - ) - if title: - display_name = title - logger.debug(f"Read metadata - title: {title}") - else: - display_name = os.path.splitext(os.path.basename(source))[0] - except Exception as e: - logger.warning(f"Could not read metadata from {source}: {str(e)}") - display_name = os.path.splitext(os.path.basename(source))[0] - - return audio_source, display_name - - @staticmethod - async def prepare_source(source): - """Determine whether *source* is a URL or local file. - - Returns (source_path, display_name, is_url) or (None, None, None) on failure. - """ - is_url = source.startswith(("http://", "https://")) - - if is_url: - logger.info(f"Attempting to play stream from URL: {source}") - return source, source, is_url - - display_name, absolute_source = await VoiceManager.prepare_file_source(source) - if not display_name: - return None, None, None - - return absolute_source, display_name, is_url - - @staticmethod - async def cleanup_temp_file(source): - """Remove a temporary file if it exists.""" - if source and os.path.exists(source): - try: - os.remove(source) - logger.info(f"Cleaned up temporary file: {source}") - except Exception as e: - logger.error(f"Error cleaning up file {source}: {str(e)}") - - @staticmethod - async def cleanup_if_needed(source, delete_after_play, is_url): - """Conditionally clean up a file after playback.""" - if delete_after_play and not is_url: - await VoiceManager.cleanup_temp_file(source) - - # --- High-level play (full flow) ----------------------------------------- - - async def play(self, ctx, source, delete_after_play=False, interrupt=True): - """High-level playback: validate context, prepare source, connect, play, cleanup. - - Sends ``grom: {name}`` immediately on start, and an error follow-up - if playback fails. - - Args: - ctx: Discord application context (used for channel resolution & error messages). - source: Local file path or URL to stream. - delete_after_play: Remove local file after playback (no-op for URLs). - interrupt: Stop currently playing audio before starting. - - Returns: - bool: True if playback completed successfully, False otherwise. - """ - prepared_source, display_name, is_url = await VoiceManager.prepare_source(source) - if not prepared_source: - await VoiceManager.cleanup_if_needed(source, delete_after_play, False) - return False - - target_channel = await VoiceManager.validate_voice_channel(ctx) - if not target_channel: - await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) - return False - - vc = await self.connect(target_channel) - if not vc: - await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) - return False - - if interrupt and (vc.is_playing() or vc.is_paused()): - vc.stop() - - audio_source, final_display_name = await VoiceManager.prepare_audio_source( - prepared_source, is_url, display_name - ) - - try: - await ctx.followup.send(f"grom: {final_display_name}") - except Exception as e: - logger.error(e) - - success = await self._play_await(vc, audio_source) - - if success: - logger.info(f"Playback completed: {final_display_name}") - else: - logger.error(f"Playback failed: {prepared_source}") - try: - await ctx.followup.send( - "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True - ) - except Exception: - pass - await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) - - 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 @@ -# This file is part of Senator Sperma -# Licensed under the GPL-3.0 License -# Copyright (C) 2026 Salmonik @ salmonik@tuta.io -# https://codeberg.org/salmonik/senator-sperma - -import discord -from discord import Embed -from discord.ext import commands -import requests -from bs4 import BeautifulSoup -import urllib.parse -import yt_dlp -import logging -from cogs.utils.url_helper import clean_url_for_ytdlp - -logger = logging.getLogger(__name__) - - -class Web(commands.Cog): - def __init__(self, bot): - self.bot = bot - - def _extract_youtube_title(self, url): - """Extract YouTube video title if URL is a YouTube link.""" - if "youtube.com/watch" not in url: - return None - - url = clean_url_for_ytdlp(url) - logger.info("youtube link") - try: - ydl_opts = {"quiet": True, "nowarnings": True} - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - return info.get("title") if info else None - except Exception: - pass - return None - - def _process_search_results(self, links, query): - """Process search results and create embed.""" - embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) - view = discord.ui.View(disable_on_timeout=True) - - for i, link in enumerate(links[:5], start=1): - title = link.text - youtube_title = self._extract_youtube_title(link.get("href")) - if youtube_title: - title = youtube_title - - embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) - - return embed, view - - @commands.slash_command( - name="web", - description="Search the web using DuckDuckGo" - ) - async def web( - self, - ctx: discord.ApplicationContext, - query: str - ): - await ctx.defer() - 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"} - encoded_query = urllib.parse.quote_plus(query) - url = f"https://duckduckgo.com/html/?q={encoded_query}" - response = requests.get(url, headers=headers) - - logger.info(f"Query: {query}") - - if response.status_code != 200: - await ctx.respond(content=f"Error: {response.status_code}", ephemeral=True) - logger.error(f"Error: {response.status_code}") - return - - soup = BeautifulSoup(response.text, "html.parser") - links = soup.find_all("a", class_="result__a") - - if not links: - await ctx.respond(content="No results found.", ephemeral=True) - logger.warning("No results found") - return - - embed, view = self._process_search_results(links, query) - await ctx.respond(embed=embed, view=view) - logger.info("Search results sent") - - -def setup(bot): - bot.add_cog(Web(bot)) -- cgit v1.2.3