From a2b5b10625126b7e98643af9fe40569c9e004047 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 18 Apr 2026 00:24:50 +0200 Subject: refactor + preparing bot for public use Signed-off-by: salmonik --- cogs/ai.py | 8 +- cogs/assistant.py | 198 ++++++++++++++++++++++++++++++++++++++++ cogs/compression.py | 202 +++++++++++++++++++++++++++++++++-------- cogs/conversion.py | 198 +++++++++++++++++++++++++++++----------- cogs/downloader.py | 8 +- cogs/jarvis.py | 198 ---------------------------------------- cogs/music/audio_playback.py | 13 +-- cogs/music/media_processing.py | 16 ++-- cogs/music/play_commands.py | 17 ++-- cogs/plot.py | 65 +++++++------ cogs/qr.py | 46 ++++++---- cogs/say.py | 2 +- cogs/search.py | 12 +-- cogs/trigger.py | 64 ++++++------- cogs/tts.py | 21 ++--- cogs/utils/file_search.py | 7 +- cogs/utils/settings.py | 108 ++++++++++++++-------- cogs/utils/temp.py | 18 ++++ cogs/vclogs.py | 30 +++--- cogs/voice.py | 39 +++++--- cogs/web.py | 41 +++++---- 21 files changed, 792 insertions(+), 519 deletions(-) create mode 100644 cogs/assistant.py delete mode 100644 cogs/jarvis.py create mode 100644 cogs/utils/temp.py (limited to 'cogs') diff --git a/cogs/ai.py b/cogs/ai.py index 14e077d..4a04693 100644 --- a/cogs/ai.py +++ b/cogs/ai.py @@ -9,7 +9,7 @@ from discord.ext import commands import logging import requests from gtts import gTTS -from cogs.music.play_commands import PlayCommands +from cogs.music import audio_playback import io logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class AI(commands.Cog): ): await ctx.defer() - + url = "https://openrouter.ai/api/v1/chat/completions" headers = { "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", @@ -65,8 +65,8 @@ class AI(commands.Cog): tts_buffer = io.BytesIO() tts_obj.write_to_fp(tts_buffer) tts_buffer.seek(0) - await PlayCommands.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) - + await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) + else: await ctx.send_followup(response_text) diff --git a/cogs/assistant.py b/cogs/assistant.py new file mode 100644 index 0000000..71f5150 --- /dev/null +++ b/cogs/assistant.py @@ -0,0 +1,198 @@ +# 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 re +import discord +from discord.ext import commands +import requests +import json +import logging +import shutil +import os +from tempfile import NamedTemporaryFile +from cogs.downloader import Downloader + +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._make_api_request(payload, headers) + response_message, action = Jarvis._parse_response(response_data) + + channel = message.channel + await message.reply(response_message) + + if action == "download": + await Jarvis._handle_download_action(message, channel) + + @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, po za asystowaniem użytkownika, jest mówienie programowi nadrzędnemu jak sie zachować" + "pole 'message' może być tylko wypełnione w języku Polskim." + "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" + "nie pytaj sie użytkownika czy chcesz abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" + }] + + if chain is None: + chain = [message] + + for msg in chain: + messages.append({ + "role": "user", + "content": f"{msg.author.display_name}: {msg.content}" + }) + + return messages + + @staticmethod + def _build_payload(messages): + return { + "model": "arcee-ai/trinity-large-preview:free", + "messages": messages, + "response_format": Jarvis._get_response_schema(), + "plugins": [{"id": "response-healing"}] + } + + @staticmethod + def _get_response_schema(): + return { + "type": "json_schema", + "json_schema": { + "name": "response", + "strict": True, + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "the message that user will receive", + }, + "action": { + "type": "string", + "description": "the action to do, either download, compress or none" + } + }, + "required": ["message", "action"], + "additionalProperties": False, + } + } + } + + @staticmethod + async def _make_api_request(payload, headers): + url = "https://openrouter.ai/api/v1/chat/completions" + response = requests.post(url=url, headers=headers, json=payload) + response_data = response.json() + logger.info(response_data) + return response_data + + @staticmethod + def _parse_response(response_data): + content = response_data['choices'][0]['message']['content'] + inner_json_str = content.strip('```json\n').strip('```').strip() + inner_data = json.loads(inner_json_str) + + response_message = inner_data['message'] + action = inner_data['action'] + + logger.info(response_message) + logger.info(action) + + return response_message, action + + @staticmethod + async def _handle_download_action(message, channel): + url_match = Jarvis._extract_url(message.content) + if not url_match: + await channel.send("Nie znaleziono poprawnego linku w odpowiedzi.") + return + + response = await Downloader.download_standalone(url=url_match) + await Jarvis._process_download_response(response, channel) + + @staticmethod + def _extract_url(content): + match = re.search(r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*', content) + return match.group(0) if match else None + + @staticmethod + async def _process_download_response(response, channel): + if not isinstance(response, dict): + return + + if response["status"] == "success": + await Jarvis._handle_successful_download(response, channel) + else: + await Jarvis._handle_failed_download(response) + + @staticmethod + async def _handle_successful_download(response, channel): + if response["type"] == "url": + await channel.send(f"pobioron i wysyłon: {response['content']}") + elif response["type"] == "file": + file_data = response["content"] + await channel.send("wysyłon:", file=discord.File(file_data)) + shutil.rmtree(response["temp_dir"]) + + @staticmethod + async def _handle_failed_download(response): + logger.error(f"Error: {response['content']}") + if 'details' in response: + logger.error(f"Details: {response['details']}") + shutil.rmtree(response["temp_dir"]) + +def setup(bot): + bot.add_cog(Jarvis(bot)) diff --git a/cogs/compression.py b/cogs/compression.py index 1fcefb5..150239f 100644 --- a/cogs/compression.py +++ b/cogs/compression.py @@ -6,10 +6,16 @@ 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__) @@ -27,87 +33,142 @@ def filetype(filename): 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 = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") - if not os.path.exists(self.temp_dir): - os.makedirs(self.temp_dir) + self.temp_dir = get_temp_dir() + self._cached_urls = {} + self._baseurl = os.getenv("COPYPARTY_URL") or 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] + 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 {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 {output_path}" + 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 {file_path} -c:a libopus -b:a 500 -ar 12000 -ac 1 {output_path}" + 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 {file_path} -strip -resize 30% -quality 5 {output_path}" + command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" elif ext == ".gif": - command = f"magick {file_path} -coalesce -resize 30% -colors 32 -strip -layers Optimize {output_path}" + 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 {file_path} -strip -resize 30% -quality 5 {output_path}" + 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(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""" - file_path = PathLike[str] try: - file_path: PathLike[str] | str = os.path.join(self.temp_dir, attachment.filename) + 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": @@ -136,21 +197,21 @@ class Compression(commands.Cog): 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): - os.remove(file.fp.name) + 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) @@ -160,20 +221,83 @@ class Compression(commands.Cog): ) @discord.option( name="plik", - description="plik do skompresowania", - required=True, + description="plik z copyparty", + required=False, + autocomplete=_file_autocomplete + ) + @discord.option( + name="zalacznik", + description="plik z discorda", + required=False, type=discord.Attachment ) - async def compression(self, ctx, plik: 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() - compressed_file = await self._compress_attachment(plik) - - if compressed_file: - await ctx.respond(file=compressed_file) - if os.path.exists(compressed_file.fp.name): - os.remove(compressed_file.fp.name) + + 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("Nie udało się skompresować pliku.", ephemeral=True) + 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": + 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): diff --git a/cogs/conversion.py b/cogs/conversion.py index f61d30f..1117caf 100644 --- a/cogs/conversion.py +++ b/cogs/conversion.py @@ -12,6 +12,11 @@ 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__) @@ -29,28 +34,63 @@ def filetype(filename): 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 + self.temp_dir = get_temp_dir() + self._cached_urls = {} + self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() + self._uploadurl = os.getenv("COPYPARTY_URL_UPLOAD") + + 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] + 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""" - bot_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - temp_dir = os.path.join(bot_dir, "temp") - if not os.path.exists(temp_dir): - os.makedirs(temp_dir) - + 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}k.{output_format}") + 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 {input_path} {args} {output_path}" + return f"ffmpeg -i {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" elif file_type == "Image": - return f"magick {input_path} {args} {output_path}" + return f"magick {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" else: return None @@ -58,30 +98,31 @@ class Conversion(commands.Cog): """Upload file to copyparty and return download URL""" try: async with aiofiles.open(output_path, 'rb') as f: - files = { - 'f': (output_path, f, 'application/octet-stream') - } - headers = { - 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}' - } - params = { - 'j': '1', - 'ck': 'b2' - } - - base_url = "https://catbox.2137697.xyz/sperma/" - response = requests.post(base_url, 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}") + 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: - error_msg = response.text if response.text else 'Unknown error' - raise requests.RequestException(f"HTTP {response.status_code}: {error_msg}") - + 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 @@ -105,44 +146,99 @@ class Conversion(commands.Cog): @commands.slash_command(name="konwersja", description="konwertuj plik na inny format") @discord.option( - "plik", - description="plik do konwersji", - required=True, + 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( - "format", + 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, output_format, args): + 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: - input_path, output_path = await self._setup_file_paths(plik, output_format) + 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, args) + + 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) - logger.info("Unsupported file type") return - - proc = await asyncio.create_subprocess_exec(*command.split()) - await proc.wait() - - await self._handle_file_output(ctx, output_path) + + 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' in locals(): - self._cleanup_files(input_path, output_path) + 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)) \ No newline at end of file + bot.add_cog(Conversion(bot)) diff --git a/cogs/downloader.py b/cogs/downloader.py index d2835d8..d364b3a 100644 --- a/cogs/downloader.py +++ b/cogs/downloader.py @@ -11,14 +11,10 @@ import discord import re import yt_dlp from discord.ext import commands -from dotenv import load_dotenv import aiofiles logger = logging.getLogger(__name__) -# Load environment variables -load_dotenv() - class Downloader(commands.Cog): def __init__(self, bot): @@ -70,7 +66,7 @@ class Downloader(commands.Cog): logger.error(f"Details: {response['details']}") @staticmethod - async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str = None): + async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): try: logger.info(f"Downloading {url} as {fformat}") @@ -88,7 +84,7 @@ class Downloader(commands.Cog): return Downloader._return_file_response(actual_file, filename, temp_dir) except Exception as e: - return Downloader._create_error_response("Download failed", str(e), temp_dir if 'temp_dir' in locals() else None) + return Downloader._create_error_response("Download failed", str(e), None) @staticmethod def _get_ydl_options(fformat: str, temp_dir: str) -> dict: diff --git a/cogs/jarvis.py b/cogs/jarvis.py deleted file mode 100644 index 866700e..0000000 --- a/cogs/jarvis.py +++ /dev/null @@ -1,198 +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 re -import discord -from discord.ext import commands -import requests -import json -import logging -import shutil -from dotenv import load_dotenv -import os -from tempfile import NamedTemporaryFile -from cogs.downloader import Downloader - -logger = logging.getLogger(__name__) -load_dotenv() - -class Jarvis(commands.Cog): - def __init__(self, bot): - self.bot = bot - - @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.jarvis(message) - - elif "jarvis" in message.content.lower(): - await self.jarvis(message) - - @staticmethod - async def jarvis(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._make_api_request(payload, headers) - response_message, action = Jarvis._parse_response(response_data) - - channel = message.channel - await message.reply(response_message) - - if action == "download": - await Jarvis._handle_download_action(message, channel) - - @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, po za asystowaniem użytkownika, jest mówienie programowi nadrzędnemu jak sie zachować" - "pole 'message' może być tylko wypełnione w języku Polskim." - "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" - "nie pytaj sie użytkownika czy chcesz abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" - }] - - if chain is None: - chain = [message] - - for msg in chain: - messages.append({ - "role": "user", - "content": f"{msg.author.display_name}: {msg.content}" - }) - - return messages - - @staticmethod - def _build_payload(messages): - return { - "model": "arcee-ai/trinity-large-preview:free", - "messages": messages, - "response_format": Jarvis._get_response_schema(), - "plugins": [{"id": "response-healing"}] - } - - @staticmethod - def _get_response_schema(): - return { - "type": "json_schema", - "json_schema": { - "name": "response", - "strict": True, - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "the message that user will receive", - }, - "action": { - "type": "string", - "description": "the action to do, either download, compress or none" - } - }, - "required": ["message", "action"], - "additionalProperties": False, - } - } - } - - @staticmethod - async def _make_api_request(payload, headers): - url = "https://openrouter.ai/api/v1/chat/completions" - response = requests.post(url=url, headers=headers, json=payload) - response_data = response.json() - logger.info(response_data) - return response_data - - @staticmethod - def _parse_response(response_data): - content = response_data['choices'][0]['message']['content'] - inner_json_str = content.strip('```json\n').strip('```').strip() - inner_data = json.loads(inner_json_str) - - response_message = inner_data['message'] - action = inner_data['action'] - - logger.info(response_message) - logger.info(action) - - return response_message, action - - @staticmethod - async def _handle_download_action(message, channel): - url_match = Jarvis._extract_url(message.content) - if not url_match: - await channel.send("Nie znaleziono poprawnego linku w odpowiedzi.") - return - - response = await Downloader.download_standalone(url=url_match) - await Jarvis._process_download_response(response, channel) - - @staticmethod - def _extract_url(content): - match = re.search(r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*', content) - return match.group(0) if match else None - - @staticmethod - async def _process_download_response(response, channel): - if not isinstance(response, dict): - return - - if response["status"] == "success": - await Jarvis._handle_successful_download(response, channel) - else: - await Jarvis._handle_failed_download(response) - - @staticmethod - async def _handle_successful_download(response, channel): - if response["type"] == "url": - await channel.send(f"pobioron i wysyłon: {response['content']}") - elif response["type"] == "file": - file_data = response["content"] - await channel.send("wysyłon:", file=discord.File(file_data)) - shutil.rmtree(response["temp_dir"]) - - @staticmethod - async def _handle_failed_download(response): - logger.error(f"Error: {response['content']}") - if 'details' in response: - logger.error(f"Details: {response['details']}") - shutil.rmtree(response["temp_dir"]) - -def setup(bot): - bot.add_cog(Jarvis(bot)) diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py index 75608b8..1267fa7 100644 --- a/cogs/music/audio_playback.py +++ b/cogs/music/audio_playback.py @@ -81,11 +81,8 @@ class AudioPlaybackManager: """Prepare audio source for playback. Returns (source, final_display_name).""" if is_url: # For URLs, use FFmpeg with the direct URL and extract clean filename - ffmpeg_options = { - "options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", - "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", - } - audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options) + before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" + audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) # Extract clean filename for URL (remove extension and decode URL encoding) filename = os.path.basename(source.split("?")[0]) # Remove query parameters filename = urllib.parse.unquote(filename) # Decode URL encoding @@ -97,7 +94,7 @@ class AudioPlaybackManager: # Try to get metadata for local files try: - metadata = mutagen.File(source) + metadata = mutagen.open(source) # type: ignore[call-overload] if metadata: title = ( metadata.get("title", [None])[0] @@ -152,8 +149,8 @@ class AudioPlaybackManager: ) # Stop any currently playing audio - if vc.is_playing() or vc.is_paused(): - vc.stop() + if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] + vc.stop() # type: ignore[union-attr] # Prepare audio source ( diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py index e04911c..b9b814c 100644 --- a/cogs/music/media_processing.py +++ b/cogs/music/media_processing.py @@ -19,9 +19,9 @@ class MediaDownloader: @staticmethod async def download(ctx, url): """Download audio from YouTube URL.""" - # Create temp directory if it doesn't exist - temp_dir = os.path.join("cogs", "temp", "music") - os.makedirs(temp_dir, exist_ok=True) + 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"), @@ -69,17 +69,13 @@ class AudioCompressor: @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 - # Create temp directory if it doesn't exist - temp_dir = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "temp", - "compressed", - ) - os.makedirs(temp_dir, exist_ok=True) + temp_dir = get_temp_dir("senator_sperma_compressed") # Create a temporary output filename in the temp directory base_name = os.path.basename(filename) diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index e90d85f..fd5113a 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py @@ -23,10 +23,10 @@ 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 = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp" - ) + self.temp_dir = get_temp_dir() self.path_mapping = {} # Maps display names to full paths @staticmethod @@ -104,12 +104,11 @@ class PlayCommands(commands.Cog): display = display.strip() - self.path_mapping[display] = url + if len(display) > 100: + display = display[:99] + "…" - if len(display) > 90: - display = display[:87] + "..." - - choices.append(OptionChoice(display[:100], url)) + self.path_mapping[display] = url + choices.append(OptionChoice(display, display)) if len(choices) >= 25: break @@ -167,7 +166,7 @@ class PlayCommands(commands.Cog): self, ctx: discord.ApplicationContext, file: str, compressed: bool ) -> None: """Handle file playback from copyparty URL.""" - url = file # The option value is now the URL directly + url = self.path_mapping.get(file, file) # Look up URL from display name if compressed: await self._handle_compressed_file_play(ctx, url) diff --git a/cogs/plot.py b/cogs/plot.py index 1516bc1..65dcae4 100644 --- a/cogs/plot.py +++ b/cogs/plot.py @@ -14,20 +14,28 @@ from discord.ext import commands logger = logging.getLogger(__name__) -try: - import matplotlib - - matplotlib.use("Agg") - import matplotlib.pyplot as plt - - PLOT_AVAILABLE = True -except ImportError: - PLOT_AVAILABLE = False - logger.warning("matplotlib not available, plot command will be disabled") +_plt_module = None +_plot_available = False + + +def _ensure_plot(): + global _plt_module, _plot_available + + if _plt_module is not None: + return _plot_available + + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as _plt_module + _plot_available = True + except ImportError: + _plot_available = False + logger.warning("matplotlib not available, plot command will be disabled") + return _plot_available def parse_expression(expr: str) -> str: - """Convert user expression (x^3) to Python (x**3)""" expr = expr.replace("^", "**") expr = re.sub(r"(\d)([x])", r"\1*\2", expr) expr = re.sub(r"([x])(\d)", r"\1*\2", expr) @@ -39,7 +47,12 @@ class Plot(commands.Cog): self.bot = bot def _generate_plot(self, func_expr: str, output_path: str): - """Generate plot (runs in executor)""" + global _plt_module + + if not _ensure_plot(): + raise RuntimeError("matplotlib not available") + + plt = _plt_module parsed_expr = parse_expression(func_expr) x = [i * 0.1 for i in range(-100, 101)] @@ -83,30 +96,16 @@ class Plot(commands.Cog): plt.savefig(output_path, dpi=100, bbox_inches="tight") plt.close() - @commands.slash_command( - name="funkcja", description="Generuje wykres funkcji matematycznej" - ) - async def plot( - self, - ctx, - funkcja: str = discord.Option( - str, "Funkcja np. x^3, sin(x), x^2 + 2*x - 1", required=True - ), - ): - if not PLOT_AVAILABLE: - await ctx.respond( - "Generowanie wykresów nie jest dostępne (brak matplotlib)", - ephemeral=True, - ) + @commands.slash_command(name="funkcja", description="Generuje wykres funkcji matematycznej") + async def plot(self, ctx, funkcja: str = discord.Option(str, "Funkcja np. x^3, sin(x), x^2 + 2*x - 1", required=True)): + if not _ensure_plot(): + await ctx.respond("Generowanie wykresów nie jest dostępne (brak matplotlib)", ephemeral=True) return await ctx.defer() - temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "temp") - os.makedirs(temp_dir, exist_ok=True) - temp_path = os.path.join( - temp_dir, f"plot_{os.getpid()}_{asyncio.get_event_loop().time()}.png" - ) + from cogs.utils.temp import get_temp_dir + temp_path = os.path.join(get_temp_dir(), f"plot_{os.getpid()}_{asyncio.get_event_loop().time()}.png") try: loop = asyncio.get_event_loop() @@ -125,4 +124,4 @@ class Plot(commands.Cog): def setup(bot): - bot.add_cog(Plot(bot)) + bot.add_cog(Plot(bot)) \ No newline at end of file diff --git a/cogs/qr.py b/cogs/qr.py index 058dac8..c1fd499 100644 --- a/cogs/qr.py +++ b/cogs/qr.py @@ -15,14 +15,26 @@ from discord.ext import commands logger = logging.getLogger(__name__) -try: - from PIL import Image - from pyzbar.pyzbar import decode as decode_qr - - DECODE_AVAILABLE = True -except ImportError: - DECODE_AVAILABLE = False - logger.warning("pyzbar or PIL not available, decode command will be disabled") +_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): @@ -50,10 +62,9 @@ class QR(commands.Cog): ): await ctx.defer() - temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "temp") - os.makedirs(temp_dir, exist_ok=True) + from cogs.utils.temp import get_temp_dir temp_path = os.path.join( - temp_dir, f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png" + get_temp_dir(), f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png" ) try: @@ -75,7 +86,7 @@ class QR(commands.Cog): """Generate QR code (runs in executor)""" qr = qrcode.QRCode( version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, + error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore[attr-defined] box_size=10, border=4, ) @@ -92,7 +103,7 @@ class QR(commands.Cog): discord.Attachment, "Obrazek z kodem QR", required=True ), ): - if not DECODE_AVAILABLE: + if not _ensure_qr(): await ctx.respond( "Dekodowanie nie jest dostępne (brak biblioteki pyzbar)", ephemeral=True ) @@ -126,11 +137,12 @@ class QR(commands.Cog): def _decode_qr(self, image_path): """Decode QR code from image (runs in executor)""" - try: - from PIL import Image + if not _ensure_qr(): + return None - img = Image.open(image_path) - decoded_list = decode_qr(img) + 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: diff --git a/cogs/say.py b/cogs/say.py index 81811ba..29d95fe 100644 --- a/cogs/say.py +++ b/cogs/say.py @@ -25,4 +25,4 @@ class Say(commands.Cog): await ctx.send(content=text) def setup(bot): - bot.add_cog(Say(bot)) \ No newline at end of file + bot.add_cog(Say(bot)) diff --git a/cogs/search.py b/cogs/search.py index 2f8373b..a124cbf 100644 --- a/cogs/search.py +++ b/cogs/search.py @@ -22,10 +22,10 @@ class Search(commands.Cog): """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]) @@ -47,20 +47,20 @@ class Search(commands.Cog): 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}") diff --git a/cogs/trigger.py b/cogs/trigger.py index 4c6a185..0e76cef 100644 --- a/cogs/trigger.py +++ b/cogs/trigger.py @@ -24,25 +24,25 @@ class Trigger(commands.Cog): def __init__(self, bot): self.bot = bot self.db_check() - + trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") @staticmethod def db_check(): if not os.path.exists('data'): os.makedirs('data') - + db_exists = os.path.exists(DB_PATH) if not db_exists: logger.warning("Triggers database not found. Creating new database.") - + try: conn = sqlite3.connect(DB_PATH) c = conn.cursor() - + # Enable foreign key constraints c.execute('PRAGMA foreign_keys = ON') - + # Create triggers table c.execute(''' CREATE TABLE IF NOT EXISTS triggers ( @@ -51,7 +51,7 @@ class Trigger(commands.Cog): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP )''') - + # Create trigger_keywords table with foreign key constraint c.execute(''' CREATE TABLE IF NOT EXISTS trigger_keywords ( @@ -62,7 +62,7 @@ class Trigger(commands.Cog): FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, UNIQUE(trigger_id, keyword) )''') - + # Create trigger_responses table with foreign key constraint c.execute(''' CREATE TABLE IF NOT EXISTS trigger_responses ( @@ -72,29 +72,29 @@ class Trigger(commands.Cog): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE )''') - + # Create indexes for better query performance 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)') - + # Run integrity check integrity_check = c.execute('PRAGMA integrity_check').fetchone() if integrity_check and integrity_check[0] == 'ok': logger.info("Triggers database integrity check passed") else: logger.warning(f"Triggers database integrity check: {integrity_check}") - + conn.commit() - + except sqlite3.Error as e: logger.error(f"Triggers database error: {e}") raise - + finally: if 'conn' in locals(): conn.close() - + if not db_exists: logger.info("Triggers database initialized successfully") @@ -102,7 +102,7 @@ class Trigger(commands.Cog): async def on_message(self, message): if message.author == self.bot.user: return - + await self.trigger(message) @staticmethod @@ -128,13 +128,13 @@ class Trigger(commands.Cog): 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, + self, + ctx: discord.ApplicationContext, name = discord.Option( str, "the name of the trigger", @@ -146,9 +146,9 @@ class Trigger(commands.Cog): 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", ) @@ -192,10 +192,10 @@ class Trigger(commands.Cog): 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", ) @@ -233,7 +233,7 @@ class Trigger(commands.Cog): 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}") @@ -241,8 +241,8 @@ class Trigger(commands.Cog): description="Delete the whole trigger", ) async def delete( - self, - ctx: discord.ApplicationContext, + self, + ctx: discord.ApplicationContext, name = discord.Option( str, "the name of the trigger", @@ -262,23 +262,23 @@ class Trigger(commands.Cog): 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, + self, ctx: discord.ApplicationContext, name = discord.Option( str, "the name of the trigger", autocomplete=discord.utils.basic_autocomplete(get_type), required=True - ) + ) ): with get_connection() as conn: c = conn.cursor() @@ -315,7 +315,7 @@ class Trigger(commands.Cog): description="how to use trigger group?" ) async def help( - self, + self, ctx: discord.ApplicationContext, ): await ctx.respond("Trigger create - creates a group for triggers/responses\n" @@ -324,7 +324,7 @@ class Trigger(commands.Cog): "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 index 65f1fce..3dc4c09 100644 --- a/cogs/tts.py +++ b/cogs/tts.py @@ -46,15 +46,15 @@ class TTS(commands.Cog): 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_client = await self._get_or_connect_voice_client(channel) - + if not voice_client: await ctx.respond("Failed to connect to voice channel", ephemeral=True) return - + # Play the TTS audio with volume adjustment audio_source = discord.FFmpegPCMAudio( temp_path, @@ -62,11 +62,11 @@ class TTS(commands.Cog): ) voice_client.play(audio_source) logger.info(f"Playing TTS: {text}") - + # Wait for the audio to finish playing while voice_client.is_playing(): await asyncio.sleep(0.1) - + except Exception as e: logger.error(f"Error in voice channel handling: {e}") await ctx.respond("eror :c", ephemeral=True) @@ -85,11 +85,10 @@ class TTS(commands.Cog): output: str = discord.Option(str, "Wybierz sposób odtwarzania", choices=["file", "vc"], default="vc") ): await ctx.defer() - + # Create temporary file in temp folder - temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp') - os.makedirs(temp_dir, exist_ok=True) - temp_path = os.path.join(temp_dir, f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") + 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 @@ -105,7 +104,7 @@ class TTS(commands.Cog): 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) @@ -113,4 +112,4 @@ class TTS(commands.Cog): await self._cleanup_temp_file(temp_path) def setup(bot): - bot.add_cog(TTS(bot)) \ No newline at end of file + bot.add_cog(TTS(bot)) diff --git a/cogs/utils/file_search.py b/cogs/utils/file_search.py index 2108074..8111216 100644 --- a/cogs/utils/file_search.py +++ b/cogs/utils/file_search.py @@ -8,11 +8,8 @@ import logging import aiohttp import os from typing import List -from dotenv import load_dotenv from cogs.utils.settings import get_baseurl -load_dotenv() - logger = logging.getLogger(__name__) @@ -20,7 +17,7 @@ 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): + 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: @@ -36,7 +33,7 @@ class UniversalSearcher: query = query.strip() logger.info(f"Starting copyparty search for query: '{query}'") - + # Build the query with optional extension filtering name_query = f'name like "*{query}*"' if file_extensions: diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py index 0a1716f..984ec16 100644 --- a/cogs/utils/settings.py +++ b/cogs/utils/settings.py @@ -7,56 +7,86 @@ import json import logging import aiofiles import os +from dataclasses import dataclass, field logger = logging.getLogger(__name__) -_settings = None settings_file = "data/settings.json" -async def load_settings(): - global _settings - global settings_file - - default_settings = { - 'volume': 1.0, - 'baseurl': 'http://localhost:8080' - } - - try: - if os.path.exists(settings_file): - async with aiofiles.open(settings_file, 'r') as f: - content = await f.read() - if content.strip(): - _settings = json.loads(content) - logger.info("Settings loaded into memory") - else: - _settings = default_settings - await save_settings() - logger.warning("Empty settings.json, reset to defaults") - else: - _settings = default_settings - await save_settings() - logger.info("Created default settings.json") - except (json.JSONDecodeError, Exception) as e: - logger.error(f"Error loading settings: {e}") - _settings = default_settings - - return _settings + +@dataclass +class Settings: + volume: float = 1.0 + baseurl: str = "http://localhost:8080" + _data: dict = field(default_factory=dict, init=False) + + async def load(self) -> "Settings": + default_settings = { + "volume": 1.0, + "baseurl": "http://localhost:8080", + } + + try: + if os.path.exists(settings_file): + async with aiofiles.open(settings_file, "r") as f: + content = await f.read() + if content.strip(): + self._data = json.loads(content) + self._sync_from_data() + logger.info("Settings loaded into memory") + else: + self._data = default_settings.copy() + self._sync_from_data() + await self.save() + logger.warning("Empty settings.json, reset to defaults") + else: + self._data = default_settings.copy() + self._sync_from_data() + await self.save() + logger.info("Created default settings.json") + except (json.JSONDecodeError, 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): + self.volume = self._data.get("volume", 1.0) + self.baseurl = self._data.get("baseurl", "http://localhost:8080") + + def _sync_to_data(self): + self._data["volume"] = self.volume + self._data["baseurl"] = self.baseurl + + async def save(self): + self._sync_to_data() + async with aiofiles.open(settings_file, "w") as f: + await f.write(json.dumps(self._data, indent=2)) + + +_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: - async with aiofiles.open(settings_file, 'w') as f: - await f.write(json.dumps(_settings, indent=2)) + if _settings_instance: + await _settings_instance.save() -def get_settings(): - return _settings +def get_settings() -> Settings | None: + return _settings_instance -def get_volume(): - return _settings.get('volume', 1.0) if _settings else 1.0 +def get_volume() -> float: + return _settings_instance.volume if _settings_instance else 1.0 -def get_baseurl(): - return _settings.get('baseurl', 'http://localhost:8080').rstrip('/') if _settings else 'http://localhost:8080' \ No newline at end of file +def get_baseurl() -> str: + return _settings_instance.baseurl.rstrip("/") if _settings_instance else "http://localhost:8080" diff --git a/cogs/utils/temp.py b/cogs/utils/temp.py new file mode 100644 index 0000000..528d6a3 --- /dev/null +++ b/cogs/utils/temp.py @@ -0,0 +1,18 @@ +# 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 \ No newline at end of file diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 274d6ca..397d296 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -69,14 +69,14 @@ class VcLogs(commands.Cog): 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 = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp') - os.makedirs(temp_dir, exist_ok=True) + temp_dir = get_temp_dir() tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.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) @@ -85,21 +85,21 @@ class VcLogs(commands.Cog): 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: 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 and not is_playing: logger.info("preparing to play audio") audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') @@ -118,7 +118,7 @@ class VcLogs(commands.Cog): async def volume(self, ctx: discord.ApplicationContext, volume: int): from cogs.utils.settings import get_settings settings = get_settings() - settings['volume'] = volume + settings.volume = volume await save_settings() await ctx.followup.send(f"Volume has been set to: {volume}") logger.info(f"Volume changed to: {volume}") @@ -159,17 +159,17 @@ class VcLogs(commands.Cog): 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}" @@ -178,7 +178,7 @@ class VcLogs(commands.Cog): 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: @@ -190,7 +190,7 @@ class VcLogs(commands.Cog): 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 @@ -198,7 +198,7 @@ class VcLogs(commands.Cog): data['is_afk'] = False data['active_start'] = current_ts save_activity_data(activity_data) - + afk_str = format_duration(afk_duration) if after.self_mute: @@ -219,7 +219,7 @@ class VcLogs(commands.Cog): data['is_afk'] = True data['active_start'] = None save_activity_data(activity_data) - + if after.self_mute: activity = "wyciszył" else: diff --git a/cogs/voice.py b/cogs/voice.py index b6b504a..07015cc 100644 --- a/cogs/voice.py +++ b/cogs/voice.py @@ -7,22 +7,27 @@ import discord from discord.ext import commands import logging import asyncio +from dataclasses import dataclass, field +from typing import Any logger = logging.getLogger(__name__) -_voice_manager = None -def get_voice_manager(bot): - global _voice_manager - if _voice_manager is None: - _voice_manager = VoiceManager(bot) - return _voice_manager +_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] + + +@dataclass class VoiceManager: - def __init__(self, bot): - self.bot = bot - self._voice_lock = asyncio.Lock() + bot: Any # type: ignore[no-any-import] + _voice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) async def join_channel(self, channel): """Join a voice channel by channel object.""" @@ -71,17 +76,17 @@ class VoiceManager: async def get_voice_status(self, guild): """Get voice client status including connection and playback state. - + Returns: tuple: (voice_client, is_connected, is_playing) or (None, False, False) if no client """ voice_client = await self.get_voice_client(guild) if voice_client is None: return None, False, False - + is_connected = voice_client.is_connected() is_playing = voice_client.is_playing() if is_connected else False - + return voice_client, is_connected, is_playing async def play_audio(self, channel, audio_source, volume=1.0): @@ -178,14 +183,18 @@ class Voice(commands.Cog): if not await self._validate_user_voice_state(ctx): return None return await self._create_voice_client(ctx) - + if voice_client.channel != ctx.author.voice.channel: return await self._move_voice_client(voice_client, ctx) - + return await self._verify_connection(voice_client) async def connect(self, ctx): voice_client = await self.ensure_voice_client(ctx) if not voice_client: return None - return voice_client \ No newline at end of file + return voice_client + + +def setup(bot): + bot.add_cog(Voice(bot)) \ No newline at end of file diff --git a/cogs/web.py b/cogs/web.py index fcec8f9..e003399 100644 --- a/cogs/web.py +++ b/cogs/web.py @@ -9,27 +9,27 @@ from discord.ext import commands import requests from bs4 import BeautifulSoup import urllib.parse -import pytube +import yt_dlp import logging 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 not url.startswith('https://www.youtube.com/'): + if "youtube.com/watch" not in url: return None - + logger.info("youtube link") try: - parsed_url = urllib.parse.urlparse(url) - video_id = urllib.parse.parse_qs(parsed_url.query).get('v', [None])[0] - if video_id: - yt = pytube.YouTube(f'https://www.youtube.com/watch?v={video_id}') - return yt.title + 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 @@ -37,10 +37,10 @@ class Web(commands.Cog): def _create_button(self, index, link): """Create a button for sending search result.""" button = discord.ui.Button(label=f"Send Result {index}") - + async def button_callback(interaction: discord.Interaction): - await interaction.response.send_message(link.get('href')) - + await interaction.response.send_message(link.get("href")) + button.callback = button_callback return button @@ -48,17 +48,17 @@ class Web(commands.Cog): """Process search results and create embed with buttons.""" 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')) + 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) button = self._create_button(i, link) view.add_item(button) - + return embed, view @commands.slash_command( @@ -71,7 +71,7 @@ class Web(commands.Cog): query: str ): await ctx.defer(ephemeral=True) - 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'} + 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) @@ -83,9 +83,9 @@ class Web(commands.Cog): logger.error(f"Error: {response.status_code}") return - soup = BeautifulSoup(response.text, 'html.parser') - links = soup.find_all('a', class_='result__a') - + 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") @@ -95,5 +95,6 @@ class Web(commands.Cog): await ctx.respond(embed=embed, view=view, ephemeral=True) logger.info("Search results sent") + def setup(bot): - bot.add_cog(Web(bot)) + bot.add_cog(Web(bot)) \ No newline at end of file -- cgit v1.2.3