From 72abc43db8f732743c65d6f6a820369ab8665766 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 4 Feb 2026 16:19:22 +0100 Subject: more asynchronous work and other minor improvements --- cogs/ai.py | 4 +- cogs/compression.py | 76 +++++++++++----- cogs/conversion.py | 181 ++++++++++++++++++++----------------- cogs/downloader.py | 253 +++++++++++++++++++++++++--------------------------- cogs/jarvis.py | 218 +++++++++++++++++++++++++------------------- cogs/math.py | 7 +- cogs/tts.py | 138 ++++++++++++++++------------ cogs/vclogs.py | 29 +++--- cogs/voice.py | 78 ++++++++++------ cogs/web.py | 89 +++++++++++------- 10 files changed, 613 insertions(+), 460 deletions(-) (limited to 'cogs') diff --git a/cogs/ai.py b/cogs/ai.py index f975bbb..14e077d 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_utils import PlayUtils +from cogs.music.play_commands import PlayCommands import io logger = logging.getLogger(__name__) @@ -65,7 +65,7 @@ class AI(commands.Cog): tts_buffer = io.BytesIO() tts_obj.write_to_fp(tts_buffer) tts_buffer.seek(0) - await PlayUtils.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) + await PlayCommands.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) else: await ctx.send_followup(response_text) diff --git a/cogs/compression.py b/cogs/compression.py index d235c6d..1fcefb5 100644 --- a/cogs/compression.py +++ b/cogs/compression.py @@ -5,7 +5,7 @@ import mimetypes import os -import subprocess +import asyncio from typing import List, Optional from os import PathLike import discord @@ -32,6 +32,38 @@ class Compression(commands.Cog): if not os.path.exists(self.temp_dir): os.makedirs(self.temp_dir) + 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}" + 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}" + 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}" + elif ext == ".gif": + command = f"magick {file_path} -coalesce -resize 30% -colors 32 -strip -layers Optimize {output_path}" + else: + output_path = os.path.splitext(output_path)[0] + ".jpg" + command = f"magick {file_path} -strip -resize 30% -quality 5 {output_path}" + return command, output_path + + async def _execute_compression(self, command: str) -> bool: + """Execute compression command""" + proc = await asyncio.create_subprocess_exec(command) + 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] @@ -46,32 +78,23 @@ class Compression(commands.Cog): file_type = filetype(file_path) logger.info(f"Compression started for: {file_type}") - if file_type == "Video": - 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}" - elif file_type == "Audio": - 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}" - elif file_type == "Image": - 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}" - elif ext == ".gif": - command = f"magick {file_path} -coalesce -resize 30% -colors 32 -strip -layers Optimize {output_path}" - else: - output_path = os.path.splitext(output_path)[0] + ".jpg" - command = f"magick {file_path} -strip -resize 30% -quality 5 {output_path}" - else: + 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 - subprocess.run(command, check=True, shell=True) + success = await self._execute_compression(command) - if os.path.exists(output_path): + 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 @@ -84,6 +107,17 @@ class Compression(commands.Cog): # 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""" diff --git a/cogs/conversion.py b/cogs/conversion.py index 11f4f1b..f61d30f 100644 --- a/cogs/conversion.py +++ b/cogs/conversion.py @@ -8,8 +8,10 @@ import requests from discord.ext import commands import logging import os -import subprocess +import asyncio +import aiofiles import mimetypes +import subprocess logger = logging.getLogger(__name__) @@ -28,108 +30,119 @@ class Conversion(commands.Cog): def __init__(self, bot): self.bot = bot - @commands.slash_command(name="konwersja", description="konwertuj plik na inny format") - @discord.option( - "plik", - description="plik do konwersji", - required=True, - type=discord.Attachment - ) - @discord.option( - "format", - description="format konwersji", - required=True, - type=str - ) - async def convert(self, ctx, plik, output_format, args): - await ctx.defer() - # Save files to the temp directory inside the bot's root directory + 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) + 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}") - file_type = filetype(file_path) - logger.info(f"Conversion started for: {file_type}") - if file_type == "Video" or file_type == "Audio": - # Execute ffmpeg command to compress video - ffmpeg_command = f"ffmpeg -i {input_path} {args} {output_path}" + 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}" elif file_type == "Image": - img_command = f"magick {input_path} {args} {output_path}" - ffmpeg_command = img_command + return f"magick {input_path} {args} {output_path}" else: - await ctx.respond("Plik jest nieobsługiwany", ephemeral=True) - logger.info("Unsupported file type") - return + return None + async def _upload_to_copyparty(self, output_path): + """Upload file to copyparty and return download URL""" try: - subprocess.run(ffmpeg_command, check=True, shell=True) - # Upload the compressed file - if os.path.getsize(output_path) > 1024*1024*10: - 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}") + 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 - # Upload to Copyparty - with 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', # Return JSON response - 'ck': 'b2' # Use blake2b checksum - } - - # Ensure the URL ends with a single slash - base_url = "https://catbox.2137697.xyz/sperma/" - - response = requests.post( - base_url, - files=files, - headers=headers, - params=params - ) + 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)) - if response.status_code == 200 or response.status_code == 201: - try: - result = response.json() - if 'fileurl' in result: - dl_url = result['fileurl'] - await ctx.followup.send( - f"wysyłon\n" - f"{dl_url}\n" - ) - else: - await ctx.followup.send( - f"Odpowiedź serwera nie zawiera wymaganych danych: {result}") - except ValueError: - await ctx.followup.send(f"Nieprawidłowa odpowiedź z serwera: {response.text}") - else: - error_msg = response.text if response.text else 'Unknown error' - await ctx.followup.send( - f"nie udało się wysłać pliku (HTTP {response.status_code}): {error_msg}") + def _cleanup_files(self, *file_paths): + """Clean up temporary files""" + for path in file_paths: + if os.path.exists(path): + os.remove(path) - except Exception as upload_error: - await ctx.followup.send(f"copyparty mnie nienawidzi :cccc\n {str(upload_error)}") - logger.error(f"copyparty upload error: {upload_error}", exc_info=True) - else: - await ctx.respond(file=discord.File(output_path)) + @commands.slash_command(name="konwersja", description="konwertuj plik na inny format") + @discord.option( + "plik", + description="plik do konwersji", + required=True, + type=discord.Attachment + ) + @discord.option( + "format", + description="format konwersji", + required=True, + type=str + ) + async def convert(self, ctx, plik, output_format, args): + await ctx.defer() + + try: + input_path, output_path = await self._setup_file_paths(plik, 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) + 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) logger.info(f"Conversion completed for: {file_type}") - # Delete compressed file - os.remove(input_path) - os.remove(output_path) + except subprocess.CalledProcessError as e: await ctx.respond(f"Conversion failed: {str(e)}", ephemeral=True) - if os.path.exists(input_path): - os.remove(input_path) - if os.path.exists(output_path): - os.remove(output_path) + 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) def setup(bot): bot.add_cog(Conversion(bot)) \ No newline at end of file diff --git a/cogs/downloader.py b/cogs/downloader.py index 0331ae0..d2835d8 100644 --- a/cogs/downloader.py +++ b/cogs/downloader.py @@ -12,6 +12,7 @@ import re import yt_dlp from discord.ext import commands from dotenv import load_dotenv +import aiofiles logger = logging.getLogger(__name__) @@ -68,152 +69,144 @@ class Downloader(commands.Cog): 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): try: logger.info(f"Downloading {url} as {fformat}") with tempfile.TemporaryDirectory(delete=False) as temp_dir: - # Configure yt-dlp to use the original filename - ydl_opts = { - 'outtmpl': os.path.join(temp_dir, '%(title)s.%(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'}}, - 'o': '"%(title)s.%(ext)s"' - } - - # Add post-processing for audio formats - if fformat in ['mp3', 'wav', 'm4a']: - ydl_opts['postprocessors'].append({ - 'key': 'FFmpegExtractAudio', - 'preferredcodec': fformat, - 'preferredquality': '192', - }) - elif fformat != 'mp4': # For other video formats - ydl_opts['postprocessors'].append({ - 'key': 'FFmpegVideoConvertor', - 'preferedformat': fformat, - }) + ydl_opts = Downloader._get_ydl_options(fformat, temp_dir) with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) - # Find the downloaded file (should be the only file in the temp directory) - files = [f for f in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, f))] - if not files: - raise Exception(f"No file was downloaded") - - actual_file = os.path.join(temp_dir, files[0]) - extension = actual_file.split('.')[-1] + actual_file, filename = Downloader._find_and_prepare_file(temp_dir, filename_user) - if not filename_user: - filename = os.path.basename(actual_file) + if Downloader._should_upload_to_server(actual_file, catbox): + return await Downloader._upload_to_server(actual_file, filename, temp_dir) else: - filename = filename_user + '.' + extension - actual_file = os.path.join(temp_dir, filename) - - if not os.path.exists(actual_file): - raise Exception(f"Downloaded file not found: {actual_file}") - - if os.path.getsize(actual_file) > 1024*1024*10 or catbox: - try: - - # Upload to Copyparty - with open(actual_file, 'rb') as f: - files = { - 'f': (filename, f, 'application/octet-stream') - } - headers = { - 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}' - } - params = { - 'j': '1', # Return JSON response - 'ck': 'b2' # Use blake2b checksum - } - - # Ensure the URL ends with a single slash - base_url = "https://catbox.2137697.xyz/sperma/" - - response = requests.post( - base_url, - files=files, - headers=headers, - params=params - ) - - if response.status_code == 200 or response.status_code == 201: - try: - result = response.json() - if 'fileurl' in result: - dl_url = result['fileurl'] - return { - "status": "success", - "type": "url", - "content": dl_url, - "filename": filename, - "temp_dir": temp_dir - } - else: - return { - "status": "error", - "type": "error", - "content": "Invalid response from file server", - "details": str(result), - "temp_dir": temp_dir - } - except ValueError: - return { - "status": "error", - "type": "error", - "content": "Failed to parse server response", - "details": response.text[:200], # First 200 chars of response - "temp_dir": temp_dir - } - else: - error_msg = response.text if response.text else 'Unknown error' - return { - "status": "error", - "type": "error", - "content": f"Server returned {response.status_code}", - "details": error_msg, - "temp_dir": temp_dir - } - - except Exception as upload_error: - return { - "status": "error", - "type": "error", - "content": "Upload failed", - "details": str(upload_error), - "temp_dir": temp_dir - } + 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) + + @staticmethod + def _get_ydl_options(fformat: str, temp_dir: str) -> dict: + ydl_opts = { + 'outtmpl': os.path.join(temp_dir, '%(title)s.%(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'}}, + 'o': '"%(title)s.%(ext)s"' + } + + 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] + + if not filename_user: + filename = os.path.basename(actual_file) + else: + 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 = "https://catbox.2137697.xyz/sperma/" + + 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) else: - # If we have a file to return directly - return { - "status": "success", - "type": "file", - "content": actual_file, - "filename": filename, - "temp_dir": temp_dir - } + 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) - except Exception as e: - error_msg = f"Error in download command: {e}" - logger.error(error_msg, exc_info=True) - return { - "status": "error", - "type": "error", - "content": "Download failed", - "details": str(e), - "temp_dir": 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) -> dict: + return { + "status": "error", + "type": "error", + "content": content, + "details": details, + "temp_dir": temp_dir + } def setup(bot): diff --git a/cogs/jarvis.py b/cogs/jarvis.py index 0096b38..b199c8a 100644 --- a/cogs/jarvis.py +++ b/cogs/jarvis.py @@ -29,126 +29,164 @@ class Jarvis(commands.Cog): if message.mentions: if message.mentions[0].id == self.bot.user.id: - content = await self.get_original(message) - await self.jarvis(message, content) + await self.jarvis(message) elif "jarvis" in message.content.lower(): - content = await self.get_original(message) - await self.jarvis(message, content) + await self.jarvis(message) @staticmethod - async def jarvis(message, content): - url = "https://openrouter.ai/api/v1/chat/completions" + async def jarvis(message): headers = { - - "Authorization": f"Bearer {os.getenv("OPENROUTER_API_KEY")}", + "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", "Content-Type": "application/json" } - # Build messages array - messages = [ - { - "role": "system", - "content": "your task is to tell the program what to do and how to respond after receiving a message.\n" - "message field has to be filled out in polish.\n" - "act like jarvis, be a personal assistant to user, do not use emojis\n" - "do not ask the user if they want to do an action. the user will tell you that" - } - ] + messages = Jarvis._build_messages(message) + 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 channel.send(response_message) + + if action == "download": + await Jarvis._handle_download_action(message, channel) + + @staticmethod + def _build_messages(message): + messages = [{ + "role": "system", + "content": "your task is to tell the program what to do and how to respond after receiving a message.\n" + "message field has to be filled out in polish.\n" + "act like jarvis, be a personal assistant to user, do not use emojis\n" + "do not ask the user if they want to do an action. the user will tell you that" + }] - # Add original message with display name messages.append({ "role": "user", "content": f"[from {message.author.display_name}]: {message.content}" }) - # If it's a reply, add the original message and reply as separate messages - if message.reference and (message.type == discord.MessageType.reply or message.reference.resolved): - if message.reference.resolved: - original_msg = message.reference.resolved - messages.append({ - "role": "user", - "content": f"[original message from {original_msg.author.display_name}]: {original_msg.content}" - }) - messages.append({ - "role": "user", - "content": f"[reply from {message.author.display_name}]: {message.content}" - }) - - payload = { + if Jarvis._is_reply(message): + messages.extend(Jarvis._build_reply_messages(message)) + + return messages + + @staticmethod + def _is_reply(message): + return message.reference and (message.type == discord.MessageType.reply or message.reference.resolved) + + @staticmethod + def _build_reply_messages(message): + original_msg = message.reference.resolved + return [ + { + "role": "user", + "content": f"[original message from {original_msg.author.display_name}]: {original_msg.content}" + }, + { + "role": "user", + "content": f"[reply from {message.author.display_name}]: {message.content}" + } + ] + + @staticmethod + def _build_payload(messages): + return { "model": "arcee-ai/trinity-large-preview:free", "messages": messages, - "response_format": { - "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" - } + "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", }, - "required": ["message", "action"], - "additionalProperties": False, + "action": { + "type": "string", + "description": "the action to do, either download, compress or none" } - } - }, - "plugins": [ - {"id": "response-healing"} - ] + }, + "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) - # Parse the JSON response response_data = response.json() logger.info(response_data) - # Extract the content string - content = response_data['choices'][0]['message']['content'] + return response_data - # Remove the code block markers and parse the inner JSON + @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) - - # Now you can access the message and action - response_message = inner_data['message'] # "Proszę podać plik do kompresji lub link do pobrania." - action = inner_data['action'] # "none OR download OR compress" - channel = message.channel + + response_message = inner_data['message'] + action = inner_data['action'] + logger.info(response_message) logger.info(action) + + return response_message, action - await channel.send(response_message) + @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 - if action == "download": - # Extract the URL from the message using regex - url_match = re.search(r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*', message.content).group(0) - if not url_match: - await channel.send("Nie znaleziono poprawnego linku w odpowiedzi.") - return - - response = await Downloader.download_standalone(url=url_match) - if isinstance(response, dict): - if response["status"] == "success": - 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"]) - else: - logger.error(f"Error: {response['content']}") - if 'details' in response: - logger.error(f"Details: {response['details']}") - shutil.rmtree(response["temp_dir"]) - - elif action == "compress": - pass + 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"]) @staticmethod async def get_original(message): diff --git a/cogs/math.py b/cogs/math.py index 4bd62a0..bf164b3 100644 --- a/cogs/math.py +++ b/cogs/math.py @@ -4,7 +4,7 @@ # https://codeberg.org/salmonik/senator-sperma import logging -import subprocess +import asyncio from discord import ApplicationContext from discord.ext import commands @@ -22,8 +22,9 @@ class Math(commands.Cog): ) async def math(self, ctx: ApplicationContext, equation: str): try: - math = subprocess.check_output(f"qalc -e '{equation}'", shell=True) - await ctx.respond(f"{equation} = {math.decode('utf-8').strip()}") + math = await asyncio.create_subprocess_exec(f"qalc -e '{equation}'") + result = await math.wait() + await ctx.respond(f"{equation} = {result.decode('utf-8').strip()}") logger.info(f"Math command executed: {equation}") except Exception as e: await ctx.respond(e, ephemeral=True) diff --git a/cogs/tts.py b/cogs/tts.py index 3c2f9ce..158006c 100644 --- a/cogs/tts.py +++ b/cogs/tts.py @@ -6,7 +6,7 @@ import os import json import discord -import tempfile +import aiofiles import logging from discord.ext import commands from gtts import gTTS @@ -18,80 +18,102 @@ class TTS(commands.Cog): def __init__(self, bot): self.bot = bot + async def _load_settings(self): + """Load volume settings from settings.json""" + volume = 1.0 + if os.path.exists('settings.json'): + try: + async with aiofiles.open('settings.json') as f: + data = json.load(f) + volume = data.get('volume', 1.0) + except (json.JSONDecodeError, KeyError) as e: + logger.error(f"Error loading settings: {e}") + return volume + + 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) + + async def _get_or_connect_voice_client(self, channel): + """Get existing voice client or connect to new channel""" + voice_client = discord.utils.get(self.bot.voice_clients, guild=channel.guild) + + if not voice_client or not voice_client.is_connected(): + voice_client = await channel.connect() + elif voice_client.channel != channel: + await voice_client.move_to(channel) + + return voice_client + + 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() + + # Check if the user is in a voice channel + 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) + + # Play the TTS audio with volume adjustment + audio_source = discord.FFmpegPCMAudio( + temp_path, + options=f'-filter:a "volume={volume}"' + ) + 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) + + 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 a temporary file for TTS output - with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as temp_file: - temp_path = temp_file.name + async with NamedTemporaryFile(suffix=".mp3", delete=False) as temp_file: + temp_path = temp_file.name + try: # Generate and save TTS to temp file tts = gTTS(text=text, lang='pl') tts.save(temp_path) - # Load settings if available - volume = 1.0 # Default volume - if os.path.exists('settings.json'): - try: - with open('settings.json') as f: - data = json.load(f) - volume = data.get('volume', 1.0) - except (json.JSONDecodeError, KeyError) as e: - logger.error(f"Error loading settings: {e}") + # Load settings + volume = await self._load_settings() + # Handle output based on user choice if output == "file": - 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) - + await self._handle_file_output(ctx, temp_path, text) elif output == "vc": - await ctx.respond(content="\u200b", ephemeral=True) - await ctx.interaction.delete_original_response() - - # Check if the user is in a voice channel - 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: - # Connect to the voice channel if not already connected - voice_client = discord.utils.get(self.bot.voice_clients, guild=channel.guild) - if not voice_client or not voice_client.is_connected(): - voice_client = await channel.connect() - elif voice_client.channel != channel: - await voice_client.move_to(channel) - - # Play the TTS audio with volume adjustment - audio_source = discord.FFmpegPCMAudio( - temp_path, - options=f'-filter:a "volume={volume}"' - ) - 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) + await self._handle_voice_output(ctx, temp_path, text, volume) finally: - # Clean up the 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}") + await self._cleanup_temp_file(temp_path) def setup(bot): bot.add_cog(TTS(bot)) \ No newline at end of file diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 40a618c..bc317a4 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -10,6 +10,7 @@ import os import discord from discord.ext import commands from gtts import gTTS +import aiofiles logger = logging.getLogger(__name__) @@ -20,15 +21,14 @@ class VcLogs(commands.Cog): self.bot = bot self.settings_check() + settings_file = 'settings.json' + @staticmethod def settings_check(): - if not os.path.exists('settings.json'): - with open('settings.json', 'w') as f: + if not os.path.exists(settings_file): + with open(settings_file, 'w') as f: json.dump({'volume': '5'}, f, indent=4) logger.info("settings.json created") - with open('settings.json', 'r') as f: - settings = json.load(f) - volume = settings.get('volume', '5') @staticmethod async def handle_state_change(member, channel, state_type, is_enabled, current_time, log_channel): @@ -62,12 +62,13 @@ class VcLogs(commands.Cog): return tts_text, channel async def play_tts(self, channel, text): + tts_file = "temp_tts.mp3" # Generate TTS audio tts = gTTS(text=text, lang='pl') - tts.save('temp_tts.mp3') + tts.save(tts_file) - if os.path.exists('settings.json'): - with open('settings.json') as f: + if os.path.exists(settings_file): + async with aiofiles.open(settings_file) as f: data = json.load(f) volume = data['volume'] else: @@ -82,23 +83,23 @@ class VcLogs(commands.Cog): # Play the TTS audio if not voice_client.is_playing(): - audio_source = discord.FFmpegPCMAudio('temp_tts.mp3', options=f'-filter:a "volume={volume}"') + audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') voice_client.play(audio_source) while voice_client.is_playing(): await discord.utils.sleep_until(datetime.datetime.now() + datetime.timedelta(seconds=1)) - if os.path.exists('temp_tts.mp3'): - os.remove('temp_tts.mp3') + 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): try: - if not os.path.exists('settings.json'): - with open('settings.json', 'w') as f: + if not os.path.exists(settings_file): + async with aiofiles.open(settings_file, 'w') as f: json.dump({'volume': '5'}, f, indent=4) logger.info("settings.json created") - with open('settings.json', 'w') as f: + async with aiofiles.open(settings_file, 'w') as f: json.dump({"volume": volume}, f, indent=4) await ctx.followup.send(f"Volume has been set to: {volume}") logger.info(f"Volume changed to: {volume}") diff --git a/cogs/voice.py b/cogs/voice.py index 261af49..dd5b74a 100644 --- a/cogs/voice.py +++ b/cogs/voice.py @@ -14,43 +14,69 @@ class Voice(commands.Cog): def __init__(self, bot): self.bot = bot - async def ensure_voice_client(self, ctx): - """Ensure we have a valid voice client connection.""" + async def _validate_user_voice_state(self, ctx): + """Validate that user is in a voice channel.""" if not ctx.author.voice or not ctx.author.voice.channel: await ctx.send("You need to be in a voice channel first!") + return False + return True + + async def _create_voice_client(self, ctx): + """Create a new voice client connection.""" + try: + voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=True) + logger.info(f"Connected to voice channel: {ctx.author.voice.channel}") + return voice_client + except asyncio.TimeoutError: + await ctx.send("Connection to voice channel timed out.") + return None + except discord.ClientException as e: + logger.error(f"Failed to connect to voice channel: {e}") + await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True) + return None + + async def _move_voice_client(self, voice_client, ctx): + """Move existing voice client to user's channel.""" + try: + await voice_client.move_to(ctx.author.voice.channel) + logger.info(f"Moved to voice channel: {ctx.author.voice.channel}") + return voice_client + except asyncio.TimeoutError: + await ctx.send("Connection to voice channel timed out.") + return None + except discord.ClientException as e: + logger.error(f"Failed to move to voice channel: {e}") + await ctx.send("Failed to move to your voice channel. Please try again.", ephemeral=True) + return None + + async def _verify_connection(self, voice_client): + """Verify and repair voice client connection if needed.""" + if not voice_client.is_connected(): + try: + await voice_client.connect(reconnect=True, timeout=10.0) + return voice_client + except Exception as e: + logger.error(f"Failed to reconnect to voice: {e}") + return None + return voice_client + + async def ensure_voice_client(self, ctx): + """Ensure we have a valid voice client connection.""" + if not await self._validate_user_voice_state(ctx): return None voice_client = discord.utils.get(self.bot.voice_clients, guild=ctx.guild) # If no voice client exists, create one if not voice_client or not voice_client.is_connected(): - try: - voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=True) - logger.info(f"Connected to voice channel: {ctx.author.voice.channel}") - except asyncio.TimeoutError: - await ctx.send("Connection to voice channel timed out.") - except discord.ClientException as e: - logger.error(f"Failed to connect to voice channel: {e}") - await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True) + return await self._create_voice_client(ctx) + # If we're in a different channel, move to the user's channel - elif voice_client.channel != ctx.author.voice.channel: - try: - await voice_client.move_to(ctx.author.voice.channel) - logger.info(f"Moved to voice channel: {ctx.author.voice.channel}") - except asyncio.TimeoutError: - await ctx.send("Connection to voice channel timed out.") - except discord.ClientException as e: - logger.error(f"Failed to move to voice channel: {e}") - await ctx.send("Failed to move to your voice channel. Please try again.", ephemeral=True) + if voice_client.channel != ctx.author.voice.channel: + return await self._move_voice_client(voice_client, ctx) # Verify the connection is healthy - if not voice_client.is_connected(): - try: - await voice_client.connect(reconnect=True, timeout=10.0) - except Exception as e: - logger.error(f"Failed to reconnect to voice: {e}") - await ctx.send("Failed to establish voice connection. Please try again.") - return None + return await self._verify_connection(voice_client) async def connect(self, ctx): """Connect to the user's voice channel with improved connection handling.""" diff --git a/cogs/web.py b/cogs/web.py index a9f8fbb..fcec8f9 100644 --- a/cogs/web.py +++ b/cogs/web.py @@ -18,6 +18,49 @@ 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/'): + 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 + except Exception: + pass + return None + + 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')) + + button.callback = button_callback + return button + + def _process_search_results(self, links, query): + """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')) + 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( name="web", description="Search the web using DuckDuckGo" @@ -35,40 +78,22 @@ class Web(commands.Cog): logger.info(f"Query: {query}") - if response.status_code == 200: - soup = BeautifulSoup(response.text, 'html.parser') - links = soup.find_all('a', class_='result__a') - if links: - 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 - if link.get('href').startswith('https://www.youtube.com/'): - logger.info("youtube link") - try: - parsed_url = urllib.parse.urlparse(link.get('href')) - 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}') - title = yt.title - except Exception: - pass - - embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) - button = discord.ui.Button(label=f"Send Result {i}") - - async def button_callback(interaction: discord.Interaction, link=link): - await interaction.response.send_message(link.get('href')) - button.callback = button_callback - view.add_item(button) - await ctx.respond(embed=embed, view=view, ephemeral=True) - logger.info("Search results sent") - else: - await ctx.respond(content="No results found.", ephemeral=True) - logger.warning("No results found") - else: + 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, ephemeral=True) + logger.info("Search results sent") def setup(bot): bot.add_cog(Web(bot)) -- cgit v1.2.3