diff options
| author | salmonik <raisersoft@proton.me> | 2026-04-18 00:24:50 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-04-18 00:24:50 +0200 |
| commit | a2b5b10625126b7e98643af9fe40569c9e004047 (patch) | |
| tree | 2b59a929d068bc28bbc21e8c8d8836e4d29270c0 /cogs | |
| parent | add mathematical plotting (diff) | |
| download | senator-sperma-a2b5b10625126b7e98643af9fe40569c9e004047.tar.gz senator-sperma-a2b5b10625126b7e98643af9fe40569c9e004047.zip | |
refactor + preparing bot for public use
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to '')
| -rw-r--r-- | cogs/ai.py | 8 | ||||
| -rw-r--r-- | cogs/assistant.py (renamed from cogs/jarvis.py) | 42 | ||||
| -rw-r--r-- | cogs/compression.py | 202 | ||||
| -rw-r--r-- | cogs/conversion.py | 198 | ||||
| -rw-r--r-- | cogs/downloader.py | 8 | ||||
| -rw-r--r-- | cogs/music/audio_playback.py | 13 | ||||
| -rw-r--r-- | cogs/music/media_processing.py | 16 | ||||
| -rw-r--r-- | cogs/music/play_commands.py | 17 | ||||
| -rw-r--r-- | cogs/plot.py | 65 | ||||
| -rw-r--r-- | cogs/qr.py | 46 | ||||
| -rw-r--r-- | cogs/say.py | 2 | ||||
| -rw-r--r-- | cogs/search.py | 12 | ||||
| -rw-r--r-- | cogs/trigger.py | 64 | ||||
| -rw-r--r-- | cogs/tts.py | 21 | ||||
| -rw-r--r-- | cogs/utils/file_search.py | 7 | ||||
| -rw-r--r-- | cogs/utils/settings.py | 108 | ||||
| -rw-r--r-- | cogs/utils/temp.py | 18 | ||||
| -rw-r--r-- | cogs/vclogs.py | 30 | ||||
| -rw-r--r-- | cogs/voice.py | 39 | ||||
| -rw-r--r-- | cogs/web.py | 41 |
20 files changed, 615 insertions, 342 deletions
| @@ -9,7 +9,7 @@ from discord.ext import commands | |||
| 9 | import logging | 9 | import logging |
| 10 | import requests | 10 | import requests |
| 11 | from gtts import gTTS | 11 | from gtts import gTTS |
| 12 | from cogs.music.play_commands import PlayCommands | 12 | from cogs.music import audio_playback |
| 13 | import io | 13 | import io |
| 14 | 14 | ||
| 15 | logger = logging.getLogger(__name__) | 15 | logger = logging.getLogger(__name__) |
| @@ -30,7 +30,7 @@ class AI(commands.Cog): | |||
| 30 | ): | 30 | ): |
| 31 | await ctx.defer() | 31 | await ctx.defer() |
| 32 | 32 | ||
| 33 | 33 | ||
| 34 | url = "https://openrouter.ai/api/v1/chat/completions" | 34 | url = "https://openrouter.ai/api/v1/chat/completions" |
| 35 | headers = { | 35 | headers = { |
| 36 | "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", | 36 | "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", |
| @@ -65,8 +65,8 @@ class AI(commands.Cog): | |||
| 65 | tts_buffer = io.BytesIO() | 65 | tts_buffer = io.BytesIO() |
| 66 | tts_obj.write_to_fp(tts_buffer) | 66 | tts_obj.write_to_fp(tts_buffer) |
| 67 | tts_buffer.seek(0) | 67 | tts_buffer.seek(0) |
| 68 | await PlayCommands.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) | 68 | await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True) |
| 69 | 69 | ||
| 70 | else: | 70 | else: |
| 71 | await ctx.send_followup(response_text) | 71 | await ctx.send_followup(response_text) |
| 72 | 72 | ||
diff --git a/cogs/jarvis.py b/cogs/assistant.py index 866700e..71f5150 100644 --- a/cogs/jarvis.py +++ b/cogs/assistant.py | |||
| @@ -10,48 +10,48 @@ import requests | |||
| 10 | import json | 10 | import json |
| 11 | import logging | 11 | import logging |
| 12 | import shutil | 12 | import shutil |
| 13 | from dotenv import load_dotenv | ||
| 14 | import os | 13 | import os |
| 15 | from tempfile import NamedTemporaryFile | 14 | from tempfile import NamedTemporaryFile |
| 16 | from cogs.downloader import Downloader | 15 | from cogs.downloader import Downloader |
| 17 | 16 | ||
| 18 | logger = logging.getLogger(__name__) | 17 | logger = logging.getLogger(__name__) |
| 19 | load_dotenv() | ||
| 20 | 18 | ||
| 21 | class Jarvis(commands.Cog): | 19 | class Jarvis(commands.Cog): |
| 22 | def __init__(self, bot): | 20 | def __init__(self, bot): |
| 23 | self.bot = bot | 21 | self.bot = bot |
| 22 | self.keyword = os.getenv("ASSISTANT_KEYWORD", "jarvis").lower() | ||
| 24 | 23 | ||
| 25 | @commands.Cog.listener() | 24 | @commands.Cog.listener() |
| 26 | async def on_message(self, message): | 25 | async def on_message(self, message): |
| 27 | if message.author == self.bot.user: | 26 | if message.author == self.bot.user: |
| 28 | return | 27 | return |
| 29 | 28 | ||
| 30 | logger.info(message.content) | 29 | logger.info(message.content) |
| 30 | |||
| 31 | if message.mentions: | 31 | if message.mentions: |
| 32 | if message.mentions[0].id == self.bot.user.id: | 32 | if message.mentions[0].id == self.bot.user.id: |
| 33 | await self.jarvis(message) | 33 | await self.assistant(message) |
| 34 | 34 | ||
| 35 | elif "jarvis" in message.content.lower(): | 35 | elif self.keyword in message.content.lower(): |
| 36 | await self.jarvis(message) | 36 | await self.assistant(message) |
| 37 | 37 | ||
| 38 | @staticmethod | 38 | @staticmethod |
| 39 | async def jarvis(message): | 39 | async def assistant(message): |
| 40 | headers = { | 40 | headers = { |
| 41 | "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", | 41 | "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}", |
| 42 | "Content-Type": "application/json" | 42 | "Content-Type": "application/json" |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | chain = await Jarvis._build_message_chain(message) | 45 | chain = await Jarvis._build_message_chain(message) |
| 46 | messages = Jarvis._build_messages(message, chain) | 46 | messages = Jarvis._build_messages(message, chain) |
| 47 | payload = Jarvis._build_payload(messages) | 47 | payload = Jarvis._build_payload(messages) |
| 48 | 48 | ||
| 49 | response_data = await Jarvis._make_api_request(payload, headers) | 49 | response_data = await Jarvis._make_api_request(payload, headers) |
| 50 | response_message, action = Jarvis._parse_response(response_data) | 50 | response_message, action = Jarvis._parse_response(response_data) |
| 51 | 51 | ||
| 52 | channel = message.channel | 52 | channel = message.channel |
| 53 | await message.reply(response_message) | 53 | await message.reply(response_message) |
| 54 | 54 | ||
| 55 | if action == "download": | 55 | if action == "download": |
| 56 | await Jarvis._handle_download_action(message, channel) | 56 | await Jarvis._handle_download_action(message, channel) |
| 57 | 57 | ||
| @@ -60,7 +60,7 @@ class Jarvis(commands.Cog): | |||
| 60 | chain = [] | 60 | chain = [] |
| 61 | current = message | 61 | current = message |
| 62 | max_depth = 5 | 62 | max_depth = 5 |
| 63 | 63 | ||
| 64 | while current and len(chain) < max_depth: | 64 | while current and len(chain) < max_depth: |
| 65 | chain.append(current) | 65 | chain.append(current) |
| 66 | if not current.reference: | 66 | if not current.reference: |
| @@ -72,9 +72,9 @@ class Jarvis(commands.Cog): | |||
| 72 | current = await current.channel.fetch_message(current.reference.message_id) | 72 | current = await current.channel.fetch_message(current.reference.message_id) |
| 73 | except discord.NotFound: | 73 | except discord.NotFound: |
| 74 | break | 74 | break |
| 75 | 75 | ||
| 76 | return list(reversed(chain)) | 76 | return list(reversed(chain)) |
| 77 | 77 | ||
| 78 | @staticmethod | 78 | @staticmethod |
| 79 | def _build_messages(message, chain=None): | 79 | def _build_messages(message, chain=None): |
| 80 | messages = [{ | 80 | messages = [{ |
| @@ -85,16 +85,16 @@ class Jarvis(commands.Cog): | |||
| 85 | "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" | 85 | "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" |
| 86 | "nie pytaj sie użytkownika czy chcesz abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" | 86 | "nie pytaj sie użytkownika czy chcesz abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" |
| 87 | }] | 87 | }] |
| 88 | 88 | ||
| 89 | if chain is None: | 89 | if chain is None: |
| 90 | chain = [message] | 90 | chain = [message] |
| 91 | 91 | ||
| 92 | for msg in chain: | 92 | for msg in chain: |
| 93 | messages.append({ | 93 | messages.append({ |
| 94 | "role": "user", | 94 | "role": "user", |
| 95 | "content": f"{msg.author.display_name}: {msg.content}" | 95 | "content": f"{msg.author.display_name}: {msg.content}" |
| 96 | }) | 96 | }) |
| 97 | 97 | ||
| 98 | return messages | 98 | return messages |
| 99 | 99 | ||
| 100 | @staticmethod | 100 | @staticmethod |
| @@ -144,13 +144,13 @@ class Jarvis(commands.Cog): | |||
| 144 | content = response_data['choices'][0]['message']['content'] | 144 | content = response_data['choices'][0]['message']['content'] |
| 145 | inner_json_str = content.strip('```json\n').strip('```').strip() | 145 | inner_json_str = content.strip('```json\n').strip('```').strip() |
| 146 | inner_data = json.loads(inner_json_str) | 146 | inner_data = json.loads(inner_json_str) |
| 147 | 147 | ||
| 148 | response_message = inner_data['message'] | 148 | response_message = inner_data['message'] |
| 149 | action = inner_data['action'] | 149 | action = inner_data['action'] |
| 150 | 150 | ||
| 151 | logger.info(response_message) | 151 | logger.info(response_message) |
| 152 | logger.info(action) | 152 | logger.info(action) |
| 153 | 153 | ||
| 154 | return response_message, action | 154 | return response_message, action |
| 155 | 155 | ||
| 156 | @staticmethod | 156 | @staticmethod |
| @@ -172,7 +172,7 @@ class Jarvis(commands.Cog): | |||
| 172 | async def _process_download_response(response, channel): | 172 | async def _process_download_response(response, channel): |
| 173 | if not isinstance(response, dict): | 173 | if not isinstance(response, dict): |
| 174 | return | 174 | return |
| 175 | 175 | ||
| 176 | if response["status"] == "success": | 176 | if response["status"] == "success": |
| 177 | await Jarvis._handle_successful_download(response, channel) | 177 | await Jarvis._handle_successful_download(response, channel) |
| 178 | else: | 178 | else: |
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 @@ | |||
| 6 | import mimetypes | 6 | import mimetypes |
| 7 | import os | 7 | import os |
| 8 | import asyncio | 8 | import asyncio |
| 9 | import aiofiles | ||
| 10 | import requests | ||
| 11 | import urllib.parse | ||
| 9 | from typing import List, Optional | 12 | from typing import List, Optional |
| 10 | from os import PathLike | 13 | from os import PathLike |
| 11 | import discord | 14 | import discord |
| 12 | from discord.ext import commands | 15 | from discord.ext import commands |
| 16 | from discord import AutocompleteContext | ||
| 17 | from cogs.utils import file_search as search_utils | ||
| 18 | import shlex | ||
| 13 | import logging | 19 | import logging |
| 14 | 20 | ||
| 15 | logger = logging.getLogger(__name__) | 21 | logger = logging.getLogger(__name__) |
| @@ -27,87 +33,142 @@ def filetype(filename): | |||
| 27 | 33 | ||
| 28 | class Compression(commands.Cog): | 34 | class Compression(commands.Cog): |
| 29 | def __init__(self, bot): | 35 | def __init__(self, bot): |
| 36 | from cogs.utils.temp import get_temp_dir | ||
| 37 | from cogs.utils.settings import get_baseurl | ||
| 30 | self.bot = bot | 38 | self.bot = bot |
| 31 | self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") | 39 | self.temp_dir = get_temp_dir() |
| 32 | if not os.path.exists(self.temp_dir): | 40 | self._cached_urls = {} |
| 33 | os.makedirs(self.temp_dir) | 41 | self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() |
| 42 | |||
| 43 | async def _file_autocomplete(self, ctx: AutocompleteContext): | ||
| 44 | query = ctx.value.lower() if ctx.value else "" | ||
| 45 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 46 | |||
| 47 | user_id = ctx.interaction.user.id | ||
| 48 | self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 49 | |||
| 50 | choices = [] | ||
| 51 | for url in results[:25]: | ||
| 52 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 53 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 54 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 55 | return choices | ||
| 56 | |||
| 57 | async def _download_from_url(self, url: str) -> Optional[str]: | ||
| 58 | """Download file from copyparty URL and return local path""" | ||
| 59 | try: | ||
| 60 | response = requests.get(url) | ||
| 61 | if response.status_code != 200: | ||
| 62 | return None | ||
| 63 | |||
| 64 | filename = url.split('/')[-1].split('?')[0] | ||
| 65 | file_path = os.path.join(self.temp_dir, filename) | ||
| 66 | |||
| 67 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 68 | await f.write(response.content) | ||
| 69 | return file_path | ||
| 70 | except Exception as e: | ||
| 71 | logger.error(f"Error downloading from {url}: {e}") | ||
| 72 | return None | ||
| 73 | |||
| 74 | async def _upload_to_copyparty(self, file_path: str) -> Optional[str]: | ||
| 75 | """Upload file to copyparty and return download URL""" | ||
| 76 | try: | ||
| 77 | async with aiofiles.open(file_path, 'rb') as f: | ||
| 78 | file_content = await f.read() | ||
| 79 | |||
| 80 | files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')} | ||
| 81 | headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} | ||
| 82 | params = {'j': '1', 'ck': 'b2'} | ||
| 83 | |||
| 84 | response = requests.post(self._baseurl, files=files, headers=headers, params=params) | ||
| 85 | |||
| 86 | if response.status_code in [200, 201]: | ||
| 87 | result = response.json() | ||
| 88 | return result.get('fileurl') | ||
| 89 | except Exception as e: | ||
| 90 | logger.error(f"Upload error: {e}") | ||
| 91 | return None | ||
| 34 | 92 | ||
| 35 | def _process_video(self, file_path: str, output_path: str) -> str: | 93 | def _process_video(self, file_path: str, output_path: str) -> str: |
| 36 | """Process video compression""" | 94 | """Process video compression""" |
| 37 | 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}" | 95 | 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)}" |
| 38 | return command | 96 | return command |
| 39 | 97 | ||
| 40 | def _process_audio(self, file_path: str, output_path: str) -> str: | 98 | def _process_audio(self, file_path: str, output_path: str) -> str: |
| 41 | """Process audio compression""" | 99 | """Process audio compression""" |
| 42 | if output_path.lower().endswith('.mp3'): | 100 | if output_path.lower().endswith('.mp3'): |
| 43 | output_path = os.path.splitext(output_path)[0] + os.path.splitext(output_path)[1].replace('.mp3', '') | 101 | output_path = os.path.splitext(output_path)[0] + os.path.splitext(output_path)[1].replace('.mp3', '') |
| 44 | if not output_path.lower().endswith('.ogg'): | 102 | if not output_path.lower().endswith('.ogg'): |
| 45 | output_path = os.path.splitext(output_path)[0] + '.ogg' | 103 | output_path = os.path.splitext(output_path)[0] + '.ogg' |
| 46 | command = f"ffmpeg -y -i {file_path} -c:a libopus -b:a 500 -ar 12000 -ac 1 {output_path}" | 104 | command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:a libopus -b:a 500 -ar 12000 -ac 1 {shlex.quote(output_path)}" |
| 47 | return command | 105 | return command |
| 48 | 106 | ||
| 49 | def _process_image(self, file_path: str, output_path: str) -> tuple[str, str]: | 107 | def _process_image(self, file_path: str, output_path: str) -> tuple[str, str]: |
| 50 | """Process image compression""" | 108 | """Process image compression""" |
| 51 | ext = os.path.splitext(file_path)[1].lower() | 109 | ext = os.path.splitext(file_path)[1].lower() |
| 52 | if ext in [".jpg", ".jpeg"]: | 110 | if ext in [".jpg", ".jpeg"]: |
| 53 | command = f"magick {file_path} -strip -resize 30% -quality 5 {output_path}" | 111 | command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" |
| 54 | elif ext == ".gif": | 112 | elif ext == ".gif": |
| 55 | command = f"magick {file_path} -coalesce -resize 30% -colors 32 -strip -layers Optimize {output_path}" | 113 | command = f"magick {shlex.quote(file_path)} -coalesce -resize 30% -colors 32 -strip -layers Optimize {shlex.quote(output_path)}" |
| 56 | else: | 114 | else: |
| 57 | output_path = os.path.splitext(output_path)[0] + ".jpg" | 115 | output_path = os.path.splitext(output_path)[0] + ".jpg" |
| 58 | command = f"magick {file_path} -strip -resize 30% -quality 5 {output_path}" | 116 | command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}" |
| 59 | return command, output_path | 117 | return command, output_path |
| 60 | 118 | ||
| 61 | async def _execute_compression(self, command: str) -> bool: | 119 | async def _execute_compression(self, command: str) -> bool: |
| 62 | """Execute compression command""" | 120 | """Execute compression command""" |
| 63 | proc = await asyncio.create_subprocess_exec(command) | 121 | proc = await asyncio.create_subprocess_exec( |
| 122 | *shlex.split(command), | ||
| 123 | stdout=asyncio.subprocess.PIPE, | ||
| 124 | stderr=asyncio.subprocess.PIPE | ||
| 125 | ) | ||
| 64 | await proc.wait() | 126 | await proc.wait() |
| 65 | return proc.returncode == 0 | 127 | return proc.returncode == 0 |
| 66 | 128 | ||
| 67 | async def _compress_attachment(self, attachment: discord.Attachment) -> Optional[discord.File]: | 129 | async def _compress_attachment(self, attachment: discord.Attachment) -> Optional[discord.File]: |
| 68 | """Compress a single attachment and return the compressed file""" | 130 | """Compress a single attachment and return the compressed file""" |
| 69 | file_path = PathLike[str] | ||
| 70 | try: | 131 | try: |
| 71 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, attachment.filename) | 132 | file_path: str = os.path.join(self.temp_dir, attachment.filename) |
| 72 | output_path = os.path.join(self.temp_dir, f"better_{attachment.filename}") | 133 | output_path = os.path.join(self.temp_dir, f"better_{attachment.filename}") |
| 73 | 134 | ||
| 74 | # Save the original file | 135 | # Save the original file |
| 75 | await attachment.save(file_path) | 136 | await attachment.save(file_path) |
| 76 | 137 | ||
| 77 | # Process the file | 138 | # Process the file |
| 78 | file_type = filetype(file_path) | 139 | file_type = filetype(file_path) |
| 79 | logger.info(f"Compression started for: {file_type}") | 140 | logger.info(f"Compression started for: {file_type}") |
| 80 | 141 | ||
| 81 | command = self._get_compression_command(file_type, file_path, output_path) | 142 | command = self._get_compression_command(file_type, file_path, output_path) |
| 82 | if command is None: | 143 | if command is None: |
| 83 | logger.info(f"Unsupported file type: {file_type}") | 144 | logger.info(f"Unsupported file type: {file_type}") |
| 84 | os.remove(file_path) | 145 | os.remove(file_path) |
| 85 | return None | 146 | return None |
| 86 | 147 | ||
| 87 | # Update output_path if it was modified | 148 | # Update output_path if it was modified |
| 88 | if file_type == "Image": | 149 | if file_type == "Image": |
| 89 | command, output_path = command | 150 | command, output_path = command |
| 90 | elif file_type == "Audio": | 151 | elif file_type == "Audio": |
| 91 | # output_path is already modified in _process_audio | 152 | # output_path is already modified in _process_audio |
| 92 | pass | 153 | pass |
| 93 | 154 | ||
| 94 | # Run the compression | 155 | # Run the compression |
| 95 | success = await self._execute_compression(command) | 156 | success = await self._execute_compression(command) |
| 96 | 157 | ||
| 97 | if success and os.path.exists(output_path): | 158 | if success and os.path.exists(output_path): |
| 98 | logger.info(f"Successfully compressed: {attachment.filename}") | 159 | logger.info(f"Successfully compressed: {attachment.filename}") |
| 99 | return discord.File(output_path, filename=f"compressed_{attachment.filename}") | 160 | return discord.File(output_path, filename=f"compressed_{attachment.filename}") |
| 100 | return None | 161 | return None |
| 101 | 162 | ||
| 102 | except Exception as e: | 163 | except Exception as e: |
| 103 | logger.error(f"Error processing {attachment.filename}: {str(e)}") | 164 | logger.error(f"Error processing {attachment.filename}: {str(e)}") |
| 104 | return None | 165 | return None |
| 105 | 166 | ||
| 106 | finally: | 167 | finally: |
| 107 | # Clean up temporary files | 168 | # Clean up temporary files |
| 108 | if 'file_path' in locals() and os.path.exists(file_path): | 169 | if 'file_path' in locals() and os.path.exists(file_path): |
| 109 | os.remove(file_path) | 170 | os.remove(file_path) |
| 110 | 171 | ||
| 111 | def _get_compression_command(self, file_type: str, file_path: str, output_path: str): | 172 | def _get_compression_command(self, file_type: str, file_path: str, output_path: str): |
| 112 | """Get the appropriate compression command based on file type""" | 173 | """Get the appropriate compression command based on file type""" |
| 113 | if file_type == "Video": | 174 | if file_type == "Video": |
| @@ -136,21 +197,21 @@ class Compression(commands.Cog): | |||
| 136 | if not message.attachments: | 197 | if not message.attachments: |
| 137 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) | 198 | await ctx.respond("Brak załączników w wiadomości", ephemeral=True) |
| 138 | return | 199 | return |
| 139 | 200 | ||
| 140 | await ctx.defer() | 201 | await ctx.defer() |
| 141 | compressed_files = await self._compress_attachments(message.attachments) | 202 | compressed_files = await self._compress_attachments(message.attachments) |
| 142 | 203 | ||
| 143 | # Send all compressed files in one message | 204 | # Send all compressed files in one message |
| 144 | if compressed_files: | 205 | if compressed_files: |
| 145 | await ctx.respond( | 206 | await ctx.respond( |
| 146 | f"Skompresowano {len(compressed_files)} z {len(message.attachments)} plików:", | 207 | f"Skompresowano {len(compressed_files)} z {len(message.attachments)} plików:", |
| 147 | files=compressed_files | 208 | files=compressed_files |
| 148 | ) | 209 | ) |
| 149 | 210 | ||
| 150 | # Clean up compressed files | 211 | # Clean up compressed files |
| 151 | for file in compressed_files: | 212 | for file in compressed_files: |
| 152 | if os.path.exists(file.fp.name): | 213 | if os.path.exists(file.fp.name): # type: ignore[attr-defined] |
| 153 | os.remove(file.fp.name) | 214 | os.remove(file.fp.name) # type: ignore[attr-defined] |
| 154 | else: | 215 | else: |
| 155 | await ctx.respond("Nie udało się skompresować żadnych plików.", ephemeral=True) | 216 | await ctx.respond("Nie udało się skompresować żadnych plików.", ephemeral=True) |
| 156 | 217 | ||
| @@ -160,20 +221,83 @@ class Compression(commands.Cog): | |||
| 160 | ) | 221 | ) |
| 161 | @discord.option( | 222 | @discord.option( |
| 162 | name="plik", | 223 | name="plik", |
| 163 | description="plik do skompresowania", | 224 | description="plik z copyparty", |
| 164 | required=True, | 225 | required=False, |
| 226 | autocomplete=_file_autocomplete | ||
| 227 | ) | ||
| 228 | @discord.option( | ||
| 229 | name="zalacznik", | ||
| 230 | description="plik z discorda", | ||
| 231 | required=False, | ||
| 165 | type=discord.Attachment | 232 | type=discord.Attachment |
| 166 | ) | 233 | ) |
| 167 | async def compression(self, ctx, plik: discord.Attachment): | 234 | @discord.option( |
| 235 | name="serwer?", | ||
| 236 | description="wysłać plik na serwer?", | ||
| 237 | required=False, | ||
| 238 | default=False, | ||
| 239 | type=bool | ||
| 240 | ) | ||
| 241 | async def compression(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server = False): | ||
| 168 | await ctx.defer() | 242 | await ctx.defer() |
| 169 | compressed_file = await self._compress_attachment(plik) | 243 | |
| 170 | 244 | file_path = None | |
| 171 | if compressed_file: | 245 | original_filename = None |
| 172 | await ctx.respond(file=compressed_file) | 246 | |
| 173 | if os.path.exists(compressed_file.fp.name): | 247 | if plik: |
| 174 | os.remove(compressed_file.fp.name) | 248 | url = self._cached_urls.get(ctx.author.id, {}).get(plik) |
| 249 | if not url: | ||
| 250 | await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) | ||
| 251 | return | ||
| 252 | file_path = await self._download_from_url(url) | ||
| 253 | original_filename = plik | ||
| 254 | elif zalacznik: | ||
| 255 | file_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 256 | original_filename = zalacznik.filename | ||
| 257 | await zalacznik.save(file_path) | ||
| 175 | else: | 258 | else: |
| 176 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) | 259 | await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) |
| 260 | return | ||
| 261 | |||
| 262 | if not file_path or not os.path.exists(file_path): | ||
| 263 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 264 | return | ||
| 265 | |||
| 266 | try: | ||
| 267 | file_type = filetype(file_path) | ||
| 268 | output_path = os.path.join(self.temp_dir, f"better_{original_filename}") | ||
| 269 | |||
| 270 | command = self._get_compression_command(file_type, file_path, output_path) | ||
| 271 | if command is None: | ||
| 272 | await ctx.respond("Nieobsługiwany typ pliku", ephemeral=True) | ||
| 273 | return | ||
| 274 | |||
| 275 | if file_type == "Image": | ||
| 276 | command, output_path = command | ||
| 277 | elif file_type == "Audio": | ||
| 278 | pass | ||
| 279 | |||
| 280 | success = await self._execute_compression(command) | ||
| 281 | |||
| 282 | if not success or not os.path.exists(output_path): | ||
| 283 | await ctx.respond("Kompresja nie powiodła się", ephemeral=True) | ||
| 284 | return | ||
| 285 | |||
| 286 | file_size = os.path.getsize(output_path) | ||
| 287 | if file_size > 1024 * 1024 * 10 or server: | ||
| 288 | dl_url = await self._upload_to_copyparty(output_path) | ||
| 289 | if dl_url: | ||
| 290 | await ctx.respond(f"Skompresowano\n{dl_url}") | ||
| 291 | else: | ||
| 292 | await ctx.respond("Nie udało wysłać na serwer", ephemeral=True) | ||
| 293 | else: | ||
| 294 | await ctx.respond(file=discord.File(output_path, filename=f"compressed_{original_filename}")) | ||
| 295 | |||
| 296 | finally: | ||
| 297 | if file_path and os.path.exists(file_path): | ||
| 298 | os.remove(file_path) | ||
| 299 | if 'output_path' in locals() and os.path.exists(output_path): | ||
| 300 | os.remove(output_path) | ||
| 177 | 301 | ||
| 178 | 302 | ||
| 179 | def setup(bot): | 303 | 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 | |||
| 12 | import aiofiles | 12 | import aiofiles |
| 13 | import mimetypes | 13 | import mimetypes |
| 14 | import subprocess | 14 | import subprocess |
| 15 | import urllib.parse | ||
| 16 | import shlex | ||
| 17 | from typing import Optional | ||
| 18 | from discord import AutocompleteContext | ||
| 19 | from cogs.utils import file_search as search_utils | ||
| 15 | 20 | ||
| 16 | logger = logging.getLogger(__name__) | 21 | logger = logging.getLogger(__name__) |
| 17 | 22 | ||
| @@ -29,28 +34,63 @@ def filetype(filename): | |||
| 29 | class Conversion(commands.Cog): | 34 | class Conversion(commands.Cog): |
| 30 | def __init__(self, bot): | 35 | def __init__(self, bot): |
| 31 | self.bot = bot | 36 | self.bot = bot |
| 37 | from cogs.utils.temp import get_temp_dir | ||
| 38 | from cogs.utils.settings import get_baseurl | ||
| 39 | self.temp_dir = get_temp_dir() | ||
| 40 | self._cached_urls = {} | ||
| 41 | self._baseurl = os.getenv("COPYPARTY_URL") or get_baseurl() | ||
| 42 | self._uploadurl = os.getenv("COPYPARTY_URL_UPLOAD") | ||
| 43 | |||
| 44 | async def _file_autocomplete(self, ctx: AutocompleteContext): | ||
| 45 | query = ctx.value.lower() if ctx.value else "" | ||
| 46 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | ||
| 47 | |||
| 48 | user_id = ctx.interaction.user.id | ||
| 49 | self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | ||
| 50 | |||
| 51 | choices = [] | ||
| 52 | for url in results[:25]: | ||
| 53 | filename = urllib.parse.unquote(url.split('/')[-1]) | ||
| 54 | display_name = filename[:99] + "…" if len(filename) > 100 else filename | ||
| 55 | choices.append(discord.OptionChoice(name=display_name, value=filename)) | ||
| 56 | return choices | ||
| 57 | |||
| 58 | async def _download_from_url(self, url: str) -> Optional[str]: | ||
| 59 | """Download file from copyparty URL and return local path""" | ||
| 60 | try: | ||
| 61 | response = requests.get(url) | ||
| 62 | if response.status_code != 200: | ||
| 63 | return None | ||
| 64 | |||
| 65 | filename = url.split('/')[-1].split('?')[0] | ||
| 66 | file_path = os.path.join(self.temp_dir, filename) | ||
| 67 | |||
| 68 | async with aiofiles.open(file_path, 'wb') as f: | ||
| 69 | await f.write(response.content) | ||
| 70 | return file_path | ||
| 71 | except Exception as e: | ||
| 72 | logger.error(f"Error downloading from {url}: {e}") | ||
| 73 | return None | ||
| 32 | 74 | ||
| 33 | async def _setup_file_paths(self, plik, output_format): | 75 | async def _setup_file_paths(self, plik, output_format): |
| 34 | """Setup input and output file paths""" | 76 | """Setup input and output file paths""" |
| 35 | bot_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | 77 | from cogs.utils.temp import get_temp_dir |
| 36 | temp_dir = os.path.join(bot_dir, "temp") | 78 | |
| 37 | if not os.path.exists(temp_dir): | 79 | temp_dir = get_temp_dir() |
| 38 | os.makedirs(temp_dir) | ||
| 39 | |||
| 40 | file_path = os.path.join(temp_dir, plik.filename) | 80 | file_path = os.path.join(temp_dir, plik.filename) |
| 41 | filename_noext = os.path.splitext(plik.filename)[0] | 81 | filename_noext = os.path.splitext(plik.filename)[0] |
| 42 | await plik.save(file_path) | 82 | await plik.save(file_path) |
| 43 | 83 | ||
| 44 | input_path = file_path | 84 | input_path = file_path |
| 45 | output_path = os.path.join(temp_dir, f"{filename_noext}k.{output_format}") | 85 | output_path = os.path.join(temp_dir, f"{filename_noext}.{output_format}") |
| 46 | return input_path, output_path | 86 | return input_path, output_path |
| 47 | 87 | ||
| 48 | def _build_conversion_command(self, file_type, input_path, output_path, args): | 88 | def _build_conversion_command(self, file_type, input_path, output_path, args): |
| 49 | """Build the appropriate conversion command based on file type""" | 89 | """Build the appropriate conversion command based on file type""" |
| 50 | if file_type in ["Video", "Audio"]: | 90 | if file_type in ["Video", "Audio"]: |
| 51 | return f"ffmpeg -i {input_path} {args} {output_path}" | 91 | return f"ffmpeg -i {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" |
| 52 | elif file_type == "Image": | 92 | elif file_type == "Image": |
| 53 | return f"magick {input_path} {args} {output_path}" | 93 | return f"magick {shlex.quote(input_path)} {args} {shlex.quote(output_path)}" |
| 54 | else: | 94 | else: |
| 55 | return None | 95 | return None |
| 56 | 96 | ||
| @@ -58,30 +98,31 @@ class Conversion(commands.Cog): | |||
| 58 | """Upload file to copyparty and return download URL""" | 98 | """Upload file to copyparty and return download URL""" |
| 59 | try: | 99 | try: |
| 60 | async with aiofiles.open(output_path, 'rb') as f: | 100 | async with aiofiles.open(output_path, 'rb') as f: |
| 61 | files = { | 101 | file_content = await f.read() |
| 62 | 'f': (output_path, f, 'application/octet-stream') | 102 | |
| 63 | } | 103 | files = { |
| 64 | headers = { | 104 | 'f': (os.path.basename(output_path), file_content, 'application/octet-stream') |
| 65 | 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}' | 105 | } |
| 66 | } | 106 | headers = { |
| 67 | params = { | 107 | 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}' |
| 68 | 'j': '1', | 108 | } |
| 69 | 'ck': 'b2' | 109 | params = { |
| 70 | } | 110 | 'j': '1', |
| 71 | 111 | 'ck': 'b2' | |
| 72 | base_url = "https://catbox.2137697.xyz/sperma/" | 112 | } |
| 73 | response = requests.post(base_url, files=files, headers=headers, params=params) | 113 | |
| 74 | 114 | response = requests.post(self._uploadurl, files=files, headers=headers, params=params) | |
| 75 | if response.status_code in [200, 201]: | 115 | |
| 76 | result = response.json() | 116 | if response.status_code in [200, 201]: |
| 77 | if 'fileurl' in result: | 117 | result = response.json() |
| 78 | return result['fileurl'] | 118 | if 'fileurl' in result: |
| 79 | else: | 119 | return result['fileurl'] |
| 80 | raise ValueError(f"Server response missing fileurl: {result}") | ||
| 81 | else: | 120 | else: |
| 82 | error_msg = response.text if response.text else 'Unknown error' | 121 | raise ValueError(f"Server response missing fileurl: {result}") |
| 83 | raise requests.RequestException(f"HTTP {response.status_code}: {error_msg}") | 122 | else: |
| 84 | 123 | error_msg = response.text if response.text else 'Unknown error' | |
| 124 | raise requests.RequestException(f"HTTP {response.status_code}: {error_msg}") | ||
| 125 | |||
| 85 | except Exception as e: | 126 | except Exception as e: |
| 86 | logger.error(f"copyparty upload error: {e}", exc_info=True) | 127 | logger.error(f"copyparty upload error: {e}", exc_info=True) |
| 87 | raise | 128 | raise |
| @@ -105,44 +146,99 @@ class Conversion(commands.Cog): | |||
| 105 | 146 | ||
| 106 | @commands.slash_command(name="konwersja", description="konwertuj plik na inny format") | 147 | @commands.slash_command(name="konwersja", description="konwertuj plik na inny format") |
| 107 | @discord.option( | 148 | @discord.option( |
| 108 | "plik", | 149 | name="plik", |
| 109 | description="plik do konwersji", | 150 | description="plik z copyparty", |
| 110 | required=True, | 151 | required=False, |
| 152 | autocomplete=_file_autocomplete | ||
| 153 | ) | ||
| 154 | @discord.option( | ||
| 155 | name="zalacznik", | ||
| 156 | description="plik z discorda", | ||
| 157 | required=False, | ||
| 111 | type=discord.Attachment | 158 | type=discord.Attachment |
| 112 | ) | 159 | ) |
| 113 | @discord.option( | 160 | @discord.option( |
| 114 | "format", | 161 | name="serwer?", |
| 162 | description="wysłać plik na serwer?", | ||
| 163 | required=False, | ||
| 164 | type=bool, | ||
| 165 | default=False | ||
| 166 | ) | ||
| 167 | @discord.option( | ||
| 168 | name="format", | ||
| 115 | description="format konwersji", | 169 | description="format konwersji", |
| 116 | required=True, | 170 | required=True, |
| 117 | type=str | 171 | type=str |
| 118 | ) | 172 | ) |
| 119 | async def convert(self, ctx, plik, output_format, args): | 173 | async def convert(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server: bool = False, output_format: str = None): |
| 120 | await ctx.defer() | 174 | await ctx.defer() |
| 121 | 175 | ||
| 176 | input_path = None | ||
| 177 | original_filename = None | ||
| 178 | |||
| 122 | try: | 179 | try: |
| 123 | input_path, output_path = await self._setup_file_paths(plik, output_format) | 180 | if plik: |
| 181 | url = self._cached_urls.get(ctx.author.id, {}).get(plik) | ||
| 182 | if not url: | ||
| 183 | await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True) | ||
| 184 | return | ||
| 185 | input_path = await self._download_from_url(url) | ||
| 186 | original_filename = plik | ||
| 187 | elif zalacznik: | ||
| 188 | input_path = os.path.join(self.temp_dir, zalacznik.filename) | ||
| 189 | original_filename = zalacznik.filename | ||
| 190 | await zalacznik.save(input_path) | ||
| 191 | else: | ||
| 192 | await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True) | ||
| 193 | return | ||
| 194 | |||
| 195 | if not input_path or not os.path.exists(input_path): | ||
| 196 | await ctx.respond("Nie udało się pobrać pliku", ephemeral=True) | ||
| 197 | return | ||
| 198 | |||
| 199 | output_path = os.path.join(self.temp_dir, f"{os.path.splitext(original_filename)[0]}.{output_format}") | ||
| 200 | |||
| 124 | file_type = filetype(input_path) | 201 | file_type = filetype(input_path) |
| 125 | logger.info(f"Conversion started for: {file_type}") | 202 | logger.info(f"Conversion started for: {file_type}") |
| 126 | 203 | ||
| 127 | command = self._build_conversion_command(file_type, input_path, output_path, args) | 204 | command = self._build_conversion_command(file_type, input_path, output_path, "") |
| 205 | logger.info(f"Running: {command}") | ||
| 128 | if not command: | 206 | if not command: |
| 129 | await ctx.respond("Plik jest nieobsługiwany", ephemeral=True) | 207 | await ctx.respond("Plik jest nieobsługiwany", ephemeral=True) |
| 130 | logger.info("Unsupported file type") | ||
| 131 | return | 208 | return |
| 132 | 209 | ||
| 133 | proc = await asyncio.create_subprocess_exec(*command.split()) | 210 | proc = await asyncio.create_subprocess_exec( |
| 134 | await proc.wait() | 211 | *shlex.split(command), |
| 135 | 212 | stdout=asyncio.subprocess.PIPE, | |
| 136 | await self._handle_file_output(ctx, output_path) | 213 | stderr=asyncio.subprocess.PIPE |
| 214 | ) | ||
| 215 | stdout, stderr = await proc.communicate() | ||
| 216 | |||
| 217 | if proc.returncode != 0: | ||
| 218 | logger.error(f"FFmpeg error: {stderr.decode() if stderr else 'unknown'}") | ||
| 219 | await ctx.respond(f"Błąd konwersji: {stderr.decode() if stderr else 'unknown'}", ephemeral=True) | ||
| 220 | return | ||
| 221 | |||
| 222 | if os.path.getsize(output_path) > 1024 * 1024 * 10 or server: | ||
| 223 | dl_url = await self._upload_to_copyparty(output_path) | ||
| 224 | if dl_url: | ||
| 225 | await ctx.respond(f"Skonwertowano\n{dl_url}") | ||
| 226 | else: | ||
| 227 | await ctx.respond("Nie udało się wysłać na serwer", ephemeral=True) | ||
| 228 | else: | ||
| 229 | await ctx.respond(file=discord.File(output_path, filename=f"{original_filename}")) | ||
| 230 | |||
| 137 | logger.info(f"Conversion completed for: {file_type}") | 231 | logger.info(f"Conversion completed for: {file_type}") |
| 138 | 232 | ||
| 139 | except subprocess.CalledProcessError as e: | 233 | except subprocess.CalledProcessError as e: |
| 140 | await ctx.respond(f"Conversion failed: {str(e)}", ephemeral=True) | 234 | await ctx.respond(f"Conversion failed: {str(e)}", ephemeral=True) |
| 141 | except Exception as e: | 235 | except Exception as e: |
| 142 | await ctx.respond(f"An error occurred: {str(e)}", ephemeral=True) | 236 | await ctx.respond(f"An error occurred: {str(e)}", ephemeral=True) |
| 143 | finally: | 237 | finally: |
| 144 | if 'input_path' in locals(): | 238 | if input_path and os.path.exists(input_path): |
| 145 | self._cleanup_files(input_path, output_path) | 239 | os.remove(input_path) |
| 240 | if 'output_path' in locals() and os.path.exists(output_path): | ||
| 241 | os.remove(output_path) | ||
| 146 | 242 | ||
| 147 | def setup(bot): | 243 | def setup(bot): |
| 148 | bot.add_cog(Conversion(bot)) \ No newline at end of file | 244 | 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 | |||
| 11 | import re | 11 | import re |
| 12 | import yt_dlp | 12 | import yt_dlp |
| 13 | from discord.ext import commands | 13 | from discord.ext import commands |
| 14 | from dotenv import load_dotenv | ||
| 15 | import aiofiles | 14 | import aiofiles |
| 16 | 15 | ||
| 17 | logger = logging.getLogger(__name__) | 16 | logger = logging.getLogger(__name__) |
| 18 | 17 | ||
| 19 | # Load environment variables | ||
| 20 | load_dotenv() | ||
| 21 | |||
| 22 | 18 | ||
| 23 | class Downloader(commands.Cog): | 19 | class Downloader(commands.Cog): |
| 24 | def __init__(self, bot): | 20 | def __init__(self, bot): |
| @@ -70,7 +66,7 @@ class Downloader(commands.Cog): | |||
| 70 | logger.error(f"Details: {response['details']}") | 66 | logger.error(f"Details: {response['details']}") |
| 71 | 67 | ||
| 72 | @staticmethod | 68 | @staticmethod |
| 73 | async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str = None): | 69 | async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): |
| 74 | try: | 70 | try: |
| 75 | logger.info(f"Downloading {url} as {fformat}") | 71 | logger.info(f"Downloading {url} as {fformat}") |
| 76 | 72 | ||
| @@ -88,7 +84,7 @@ class Downloader(commands.Cog): | |||
| 88 | return Downloader._return_file_response(actual_file, filename, temp_dir) | 84 | return Downloader._return_file_response(actual_file, filename, temp_dir) |
| 89 | 85 | ||
| 90 | except Exception as e: | 86 | except Exception as e: |
| 91 | return Downloader._create_error_response("Download failed", str(e), temp_dir if 'temp_dir' in locals() else None) | 87 | return Downloader._create_error_response("Download failed", str(e), None) |
| 92 | 88 | ||
| 93 | @staticmethod | 89 | @staticmethod |
| 94 | def _get_ydl_options(fformat: str, temp_dir: str) -> dict: | 90 | def _get_ydl_options(fformat: str, temp_dir: str) -> dict: |
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: | |||
| 81 | """Prepare audio source for playback. Returns (source, final_display_name).""" | 81 | """Prepare audio source for playback. Returns (source, final_display_name).""" |
| 82 | if is_url: | 82 | if is_url: |
| 83 | # For URLs, use FFmpeg with the direct URL and extract clean filename | 83 | # For URLs, use FFmpeg with the direct URL and extract clean filename |
| 84 | ffmpeg_options = { | 84 | before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" |
| 85 | "options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", | 85 | audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) |
| 86 | "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", | ||
| 87 | } | ||
| 88 | audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options) | ||
| 89 | # Extract clean filename for URL (remove extension and decode URL encoding) | 86 | # Extract clean filename for URL (remove extension and decode URL encoding) |
| 90 | filename = os.path.basename(source.split("?")[0]) # Remove query parameters | 87 | filename = os.path.basename(source.split("?")[0]) # Remove query parameters |
| 91 | filename = urllib.parse.unquote(filename) # Decode URL encoding | 88 | filename = urllib.parse.unquote(filename) # Decode URL encoding |
| @@ -97,7 +94,7 @@ class AudioPlaybackManager: | |||
| 97 | 94 | ||
| 98 | # Try to get metadata for local files | 95 | # Try to get metadata for local files |
| 99 | try: | 96 | try: |
| 100 | metadata = mutagen.File(source) | 97 | metadata = mutagen.open(source) # type: ignore[call-overload] |
| 101 | if metadata: | 98 | if metadata: |
| 102 | title = ( | 99 | title = ( |
| 103 | metadata.get("title", [None])[0] | 100 | metadata.get("title", [None])[0] |
| @@ -152,8 +149,8 @@ class AudioPlaybackManager: | |||
| 152 | ) | 149 | ) |
| 153 | 150 | ||
| 154 | # Stop any currently playing audio | 151 | # Stop any currently playing audio |
| 155 | if vc.is_playing() or vc.is_paused(): | 152 | if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] |
| 156 | vc.stop() | 153 | vc.stop() # type: ignore[union-attr] |
| 157 | 154 | ||
| 158 | # Prepare audio source | 155 | # Prepare audio source |
| 159 | ( | 156 | ( |
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: | |||
| 19 | @staticmethod | 19 | @staticmethod |
| 20 | async def download(ctx, url): | 20 | async def download(ctx, url): |
| 21 | """Download audio from YouTube URL.""" | 21 | """Download audio from YouTube URL.""" |
| 22 | # Create temp directory if it doesn't exist | 22 | from cogs.utils.temp import get_temp_dir |
| 23 | temp_dir = os.path.join("cogs", "temp", "music") | 23 | |
| 24 | os.makedirs(temp_dir, exist_ok=True) | 24 | temp_dir = get_temp_dir("senator_sperma_music") |
| 25 | 25 | ||
| 26 | ytdl_opts = { | 26 | ytdl_opts = { |
| 27 | "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), | 27 | "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), |
| @@ -69,17 +69,13 @@ class AudioCompressor: | |||
| 69 | @staticmethod | 69 | @staticmethod |
| 70 | async def compress(ctx, filename): | 70 | async def compress(ctx, filename): |
| 71 | """Compress audio file to opus format.""" | 71 | """Compress audio file to opus format.""" |
| 72 | from cogs.utils.temp import get_temp_dir | ||
| 73 | |||
| 72 | if not filename: | 74 | if not filename: |
| 73 | await ctx.respond("dzie wideło", ephemeral=True) | 75 | await ctx.respond("dzie wideło", ephemeral=True) |
| 74 | return None | 76 | return None |
| 75 | 77 | ||
| 76 | # Create temp directory if it doesn't exist | 78 | temp_dir = get_temp_dir("senator_sperma_compressed") |
| 77 | temp_dir = os.path.join( | ||
| 78 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))), | ||
| 79 | "temp", | ||
| 80 | "compressed", | ||
| 81 | ) | ||
| 82 | os.makedirs(temp_dir, exist_ok=True) | ||
| 83 | 79 | ||
| 84 | # Create a temporary output filename in the temp directory | 80 | # Create a temporary output filename in the temp directory |
| 85 | base_name = os.path.basename(filename) | 81 | 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): | |||
| 23 | """Discord slash commands for music playback.""" | 23 | """Discord slash commands for music playback.""" |
| 24 | 24 | ||
| 25 | def __init__(self, bot): | 25 | def __init__(self, bot): |
| 26 | from cogs.utils.temp import get_temp_dir | ||
| 27 | |||
| 26 | self.bot = bot | 28 | self.bot = bot |
| 27 | self.temp_dir = os.path.join( | 29 | self.temp_dir = get_temp_dir() |
| 28 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp" | ||
| 29 | ) | ||
| 30 | self.path_mapping = {} # Maps display names to full paths | 30 | self.path_mapping = {} # Maps display names to full paths |
| 31 | 31 | ||
| 32 | @staticmethod | 32 | @staticmethod |
| @@ -104,12 +104,11 @@ class PlayCommands(commands.Cog): | |||
| 104 | 104 | ||
| 105 | display = display.strip() | 105 | display = display.strip() |
| 106 | 106 | ||
| 107 | self.path_mapping[display] = url | 107 | if len(display) > 100: |
| 108 | display = display[:99] + "…" | ||
| 108 | 109 | ||
| 109 | if len(display) > 90: | 110 | self.path_mapping[display] = url |
| 110 | display = display[:87] + "..." | 111 | choices.append(OptionChoice(display, display)) |
| 111 | |||
| 112 | choices.append(OptionChoice(display[:100], url)) | ||
| 113 | 112 | ||
| 114 | if len(choices) >= 25: | 113 | if len(choices) >= 25: |
| 115 | break | 114 | break |
| @@ -167,7 +166,7 @@ class PlayCommands(commands.Cog): | |||
| 167 | self, ctx: discord.ApplicationContext, file: str, compressed: bool | 166 | self, ctx: discord.ApplicationContext, file: str, compressed: bool |
| 168 | ) -> None: | 167 | ) -> None: |
| 169 | """Handle file playback from copyparty URL.""" | 168 | """Handle file playback from copyparty URL.""" |
| 170 | url = file # The option value is now the URL directly | 169 | url = self.path_mapping.get(file, file) # Look up URL from display name |
| 171 | 170 | ||
| 172 | if compressed: | 171 | if compressed: |
| 173 | await self._handle_compressed_file_play(ctx, url) | 172 | 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 | |||
| 14 | 14 | ||
| 15 | logger = logging.getLogger(__name__) | 15 | logger = logging.getLogger(__name__) |
| 16 | 16 | ||
| 17 | try: | 17 | _plt_module = None |
| 18 | import matplotlib | 18 | _plot_available = False |
| 19 | 19 | ||
| 20 | matplotlib.use("Agg") | 20 | |
| 21 | import matplotlib.pyplot as plt | 21 | def _ensure_plot(): |
| 22 | 22 | global _plt_module, _plot_available | |
| 23 | PLOT_AVAILABLE = True | 23 | |
| 24 | except ImportError: | 24 | if _plt_module is not None: |
| 25 | PLOT_AVAILABLE = False | 25 | return _plot_available |
| 26 | logger.warning("matplotlib not available, plot command will be disabled") | 26 | |
| 27 | try: | ||
| 28 | import matplotlib | ||
| 29 | matplotlib.use("Agg") | ||
| 30 | import matplotlib.pyplot as _plt_module | ||
| 31 | _plot_available = True | ||
| 32 | except ImportError: | ||
| 33 | _plot_available = False | ||
| 34 | logger.warning("matplotlib not available, plot command will be disabled") | ||
| 35 | return _plot_available | ||
| 27 | 36 | ||
| 28 | 37 | ||
| 29 | def parse_expression(expr: str) -> str: | 38 | def parse_expression(expr: str) -> str: |
| 30 | """Convert user expression (x^3) to Python (x**3)""" | ||
| 31 | expr = expr.replace("^", "**") | 39 | expr = expr.replace("^", "**") |
| 32 | expr = re.sub(r"(\d)([x])", r"\1*\2", expr) | 40 | expr = re.sub(r"(\d)([x])", r"\1*\2", expr) |
| 33 | expr = re.sub(r"([x])(\d)", r"\1*\2", expr) | 41 | expr = re.sub(r"([x])(\d)", r"\1*\2", expr) |
| @@ -39,7 +47,12 @@ class Plot(commands.Cog): | |||
| 39 | self.bot = bot | 47 | self.bot = bot |
| 40 | 48 | ||
| 41 | def _generate_plot(self, func_expr: str, output_path: str): | 49 | def _generate_plot(self, func_expr: str, output_path: str): |
| 42 | """Generate plot (runs in executor)""" | 50 | global _plt_module |
| 51 | |||
| 52 | if not _ensure_plot(): | ||
| 53 | raise RuntimeError("matplotlib not available") | ||
| 54 | |||
| 55 | plt = _plt_module | ||
| 43 | parsed_expr = parse_expression(func_expr) | 56 | parsed_expr = parse_expression(func_expr) |
| 44 | 57 | ||
| 45 | x = [i * 0.1 for i in range(-100, 101)] | 58 | x = [i * 0.1 for i in range(-100, 101)] |
| @@ -83,30 +96,16 @@ class Plot(commands.Cog): | |||
| 83 | plt.savefig(output_path, dpi=100, bbox_inches="tight") | 96 | plt.savefig(output_path, dpi=100, bbox_inches="tight") |
| 84 | plt.close() | 97 | plt.close() |
| 85 | 98 | ||
| 86 | @commands.slash_command( | 99 | @commands.slash_command(name="funkcja", description="Generuje wykres funkcji matematycznej") |
| 87 | name="funkcja", description="Generuje wykres funkcji matematycznej" | 100 | async def plot(self, ctx, funkcja: str = discord.Option(str, "Funkcja np. x^3, sin(x), x^2 + 2*x - 1", required=True)): |
| 88 | ) | 101 | if not _ensure_plot(): |
| 89 | async def plot( | 102 | await ctx.respond("Generowanie wykresów nie jest dostępne (brak matplotlib)", ephemeral=True) |
| 90 | self, | ||
| 91 | ctx, | ||
| 92 | funkcja: str = discord.Option( | ||
| 93 | str, "Funkcja np. x^3, sin(x), x^2 + 2*x - 1", required=True | ||
| 94 | ), | ||
| 95 | ): | ||
| 96 | if not PLOT_AVAILABLE: | ||
| 97 | await ctx.respond( | ||
| 98 | "Generowanie wykresów nie jest dostępne (brak matplotlib)", | ||
| 99 | ephemeral=True, | ||
| 100 | ) | ||
| 101 | return | 103 | return |
| 102 | 104 | ||
| 103 | await ctx.defer() | 105 | await ctx.defer() |
| 104 | 106 | ||
| 105 | temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "temp") | 107 | from cogs.utils.temp import get_temp_dir |
| 106 | os.makedirs(temp_dir, exist_ok=True) | 108 | temp_path = os.path.join(get_temp_dir(), f"plot_{os.getpid()}_{asyncio.get_event_loop().time()}.png") |
| 107 | temp_path = os.path.join( | ||
| 108 | temp_dir, f"plot_{os.getpid()}_{asyncio.get_event_loop().time()}.png" | ||
| 109 | ) | ||
| 110 | 109 | ||
| 111 | try: | 110 | try: |
| 112 | loop = asyncio.get_event_loop() | 111 | loop = asyncio.get_event_loop() |
| @@ -125,4 +124,4 @@ class Plot(commands.Cog): | |||
| 125 | 124 | ||
| 126 | 125 | ||
| 127 | def setup(bot): | 126 | def setup(bot): |
| 128 | bot.add_cog(Plot(bot)) | 127 | bot.add_cog(Plot(bot)) \ No newline at end of file |
| @@ -15,14 +15,26 @@ from discord.ext import commands | |||
| 15 | 15 | ||
| 16 | logger = logging.getLogger(__name__) | 16 | logger = logging.getLogger(__name__) |
| 17 | 17 | ||
| 18 | try: | 18 | _pil_image = None |
| 19 | from PIL import Image | 19 | _decode_func = None |
| 20 | from pyzbar.pyzbar import decode as decode_qr | 20 | _decode_available = False |
| 21 | 21 | ||
| 22 | DECODE_AVAILABLE = True | 22 | |
| 23 | except ImportError: | 23 | def _ensure_qr(): |
| 24 | DECODE_AVAILABLE = False | 24 | global _pil_image, _decode_func, _decode_available |
| 25 | logger.warning("pyzbar or PIL not available, decode command will be disabled") | 25 | |
| 26 | if _pil_image is not None: | ||
| 27 | return _decode_available | ||
| 28 | |||
| 29 | try: | ||
| 30 | from PIL import Image | ||
| 31 | from pyzbar.pyzbar import decode as _decode_func | ||
| 32 | _pil_image = Image | ||
| 33 | _decode_available = True | ||
| 34 | except ImportError: | ||
| 35 | _decode_available = False | ||
| 36 | logger.warning("pyzbar or PIL not available, decode command will be disabled") | ||
| 37 | return _decode_available | ||
| 26 | 38 | ||
| 27 | 39 | ||
| 28 | class QR(commands.Cog): | 40 | class QR(commands.Cog): |
| @@ -50,10 +62,9 @@ class QR(commands.Cog): | |||
| 50 | ): | 62 | ): |
| 51 | await ctx.defer() | 63 | await ctx.defer() |
| 52 | 64 | ||
| 53 | temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "temp") | 65 | from cogs.utils.temp import get_temp_dir |
| 54 | os.makedirs(temp_dir, exist_ok=True) | ||
| 55 | temp_path = os.path.join( | 66 | temp_path = os.path.join( |
| 56 | temp_dir, f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png" | 67 | get_temp_dir(), f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png" |
| 57 | ) | 68 | ) |
| 58 | 69 | ||
| 59 | try: | 70 | try: |
| @@ -75,7 +86,7 @@ class QR(commands.Cog): | |||
| 75 | """Generate QR code (runs in executor)""" | 86 | """Generate QR code (runs in executor)""" |
| 76 | qr = qrcode.QRCode( | 87 | qr = qrcode.QRCode( |
| 77 | version=1, | 88 | version=1, |
| 78 | error_correction=qrcode.constants.ERROR_CORRECT_L, | 89 | error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore[attr-defined] |
| 79 | box_size=10, | 90 | box_size=10, |
| 80 | border=4, | 91 | border=4, |
| 81 | ) | 92 | ) |
| @@ -92,7 +103,7 @@ class QR(commands.Cog): | |||
| 92 | discord.Attachment, "Obrazek z kodem QR", required=True | 103 | discord.Attachment, "Obrazek z kodem QR", required=True |
| 93 | ), | 104 | ), |
| 94 | ): | 105 | ): |
| 95 | if not DECODE_AVAILABLE: | 106 | if not _ensure_qr(): |
| 96 | await ctx.respond( | 107 | await ctx.respond( |
| 97 | "Dekodowanie nie jest dostępne (brak biblioteki pyzbar)", ephemeral=True | 108 | "Dekodowanie nie jest dostępne (brak biblioteki pyzbar)", ephemeral=True |
| 98 | ) | 109 | ) |
| @@ -126,11 +137,12 @@ class QR(commands.Cog): | |||
| 126 | 137 | ||
| 127 | def _decode_qr(self, image_path): | 138 | def _decode_qr(self, image_path): |
| 128 | """Decode QR code from image (runs in executor)""" | 139 | """Decode QR code from image (runs in executor)""" |
| 129 | try: | 140 | if not _ensure_qr(): |
| 130 | from PIL import Image | 141 | return None |
| 131 | 142 | ||
| 132 | img = Image.open(image_path) | 143 | try: |
| 133 | decoded_list = decode_qr(img) | 144 | img = _pil_image.open(image_path) |
| 145 | decoded_list = _decode_func(img) | ||
| 134 | if decoded_list: | 146 | if decoded_list: |
| 135 | return decoded_list[0].data.decode("utf-8") | 147 | return decoded_list[0].data.decode("utf-8") |
| 136 | except Exception as e: | 148 | 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): | |||
| 25 | await ctx.send(content=text) | 25 | await ctx.send(content=text) |
| 26 | 26 | ||
| 27 | def setup(bot): | 27 | def setup(bot): |
| 28 | bot.add_cog(Say(bot)) \ No newline at end of file | 28 | 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): | |||
| 22 | """Autocomplete function for file search.""" | 22 | """Autocomplete function for file search.""" |
| 23 | query = ctx.value.lower() if ctx.value else "" | 23 | query = ctx.value.lower() if ctx.value else "" |
| 24 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) | 24 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) |
| 25 | 25 | ||
| 26 | user_id = ctx.interaction.user.id | 26 | user_id = ctx.interaction.user.id |
| 27 | self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} | 27 | self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results} |
| 28 | 28 | ||
| 29 | choices = [] | 29 | choices = [] |
| 30 | for url in results[:25]: | 30 | for url in results[:25]: |
| 31 | filename = urllib.parse.unquote(url.split('/')[-1]) | 31 | filename = urllib.parse.unquote(url.split('/')[-1]) |
| @@ -47,20 +47,20 @@ class Search(commands.Cog): | |||
| 47 | if not filename: | 47 | if not filename: |
| 48 | await ctx.reply("Please select a file from the autocomplete options.") | 48 | await ctx.reply("Please select a file from the autocomplete options.") |
| 49 | return | 49 | return |
| 50 | 50 | ||
| 51 | await ctx.defer() | 51 | await ctx.defer() |
| 52 | logger.info(f"Search command called by {ctx.author} for file: {filename}") | 52 | logger.info(f"Search command called by {ctx.author} for file: {filename}") |
| 53 | 53 | ||
| 54 | cached = self._cached_results.get(ctx.author.id) | 54 | cached = self._cached_results.get(ctx.author.id) |
| 55 | if not cached: | 55 | if not cached: |
| 56 | await ctx.send_followup("Search results expired. Please search again.") | 56 | await ctx.send_followup("Search results expired. Please search again.") |
| 57 | return | 57 | return |
| 58 | 58 | ||
| 59 | matching_url = cached.get(filename) | 59 | matching_url = cached.get(filename) |
| 60 | if not matching_url: | 60 | if not matching_url: |
| 61 | await ctx.send_followup(f"File '{filename}' not found.") | 61 | await ctx.send_followup(f"File '{filename}' not found.") |
| 62 | return | 62 | return |
| 63 | 63 | ||
| 64 | try: | 64 | try: |
| 65 | await ctx.send_followup(matching_url) | 65 | await ctx.send_followup(matching_url) |
| 66 | logger.info(f"Successfully sent URL: {matching_url}") | 66 | 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): | |||
| 24 | def __init__(self, bot): | 24 | def __init__(self, bot): |
| 25 | self.bot = bot | 25 | self.bot = bot |
| 26 | self.db_check() | 26 | self.db_check() |
| 27 | 27 | ||
| 28 | trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") | 28 | trigger_group = discord.SlashCommandGroup("trigger", "trigger commands") |
| 29 | 29 | ||
| 30 | @staticmethod | 30 | @staticmethod |
| 31 | def db_check(): | 31 | def db_check(): |
| 32 | if not os.path.exists('data'): | 32 | if not os.path.exists('data'): |
| 33 | os.makedirs('data') | 33 | os.makedirs('data') |
| 34 | 34 | ||
| 35 | db_exists = os.path.exists(DB_PATH) | 35 | db_exists = os.path.exists(DB_PATH) |
| 36 | if not db_exists: | 36 | if not db_exists: |
| 37 | logger.warning("Triggers database not found. Creating new database.") | 37 | logger.warning("Triggers database not found. Creating new database.") |
| 38 | 38 | ||
| 39 | try: | 39 | try: |
| 40 | conn = sqlite3.connect(DB_PATH) | 40 | conn = sqlite3.connect(DB_PATH) |
| 41 | c = conn.cursor() | 41 | c = conn.cursor() |
| 42 | 42 | ||
| 43 | # Enable foreign key constraints | 43 | # Enable foreign key constraints |
| 44 | c.execute('PRAGMA foreign_keys = ON') | 44 | c.execute('PRAGMA foreign_keys = ON') |
| 45 | 45 | ||
| 46 | # Create triggers table | 46 | # Create triggers table |
| 47 | c.execute(''' | 47 | c.execute(''' |
| 48 | CREATE TABLE IF NOT EXISTS triggers ( | 48 | CREATE TABLE IF NOT EXISTS triggers ( |
| @@ -51,7 +51,7 @@ class Trigger(commands.Cog): | |||
| 51 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | 51 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| 52 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | 52 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| 53 | )''') | 53 | )''') |
| 54 | 54 | ||
| 55 | # Create trigger_keywords table with foreign key constraint | 55 | # Create trigger_keywords table with foreign key constraint |
| 56 | c.execute(''' | 56 | c.execute(''' |
| 57 | CREATE TABLE IF NOT EXISTS trigger_keywords ( | 57 | CREATE TABLE IF NOT EXISTS trigger_keywords ( |
| @@ -62,7 +62,7 @@ class Trigger(commands.Cog): | |||
| 62 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, | 62 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE, |
| 63 | UNIQUE(trigger_id, keyword) | 63 | UNIQUE(trigger_id, keyword) |
| 64 | )''') | 64 | )''') |
| 65 | 65 | ||
| 66 | # Create trigger_responses table with foreign key constraint | 66 | # Create trigger_responses table with foreign key constraint |
| 67 | c.execute(''' | 67 | c.execute(''' |
| 68 | CREATE TABLE IF NOT EXISTS trigger_responses ( | 68 | CREATE TABLE IF NOT EXISTS trigger_responses ( |
| @@ -72,29 +72,29 @@ class Trigger(commands.Cog): | |||
| 72 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | 72 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| 73 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE | 73 | FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE |
| 74 | )''') | 74 | )''') |
| 75 | 75 | ||
| 76 | # Create indexes for better query performance | 76 | # Create indexes for better query performance |
| 77 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)') | 77 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)') |
| 78 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)') | 78 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)') |
| 79 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)') | 79 | c.execute('CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)') |
| 80 | 80 | ||
| 81 | # Run integrity check | 81 | # Run integrity check |
| 82 | integrity_check = c.execute('PRAGMA integrity_check').fetchone() | 82 | integrity_check = c.execute('PRAGMA integrity_check').fetchone() |
| 83 | if integrity_check and integrity_check[0] == 'ok': | 83 | if integrity_check and integrity_check[0] == 'ok': |
| 84 | logger.info("Triggers database integrity check passed") | 84 | logger.info("Triggers database integrity check passed") |
| 85 | else: | 85 | else: |
| 86 | logger.warning(f"Triggers database integrity check: {integrity_check}") | 86 | logger.warning(f"Triggers database integrity check: {integrity_check}") |
| 87 | 87 | ||
| 88 | conn.commit() | 88 | conn.commit() |
| 89 | 89 | ||
| 90 | except sqlite3.Error as e: | 90 | except sqlite3.Error as e: |
| 91 | logger.error(f"Triggers database error: {e}") | 91 | logger.error(f"Triggers database error: {e}") |
| 92 | raise | 92 | raise |
| 93 | 93 | ||
| 94 | finally: | 94 | finally: |
| 95 | if 'conn' in locals(): | 95 | if 'conn' in locals(): |
| 96 | conn.close() | 96 | conn.close() |
| 97 | 97 | ||
| 98 | if not db_exists: | 98 | if not db_exists: |
| 99 | logger.info("Triggers database initialized successfully") | 99 | logger.info("Triggers database initialized successfully") |
| 100 | 100 | ||
| @@ -102,7 +102,7 @@ class Trigger(commands.Cog): | |||
| 102 | async def on_message(self, message): | 102 | async def on_message(self, message): |
| 103 | if message.author == self.bot.user: | 103 | if message.author == self.bot.user: |
| 104 | return | 104 | return |
| 105 | 105 | ||
| 106 | await self.trigger(message) | 106 | await self.trigger(message) |
| 107 | 107 | ||
| 108 | @staticmethod | 108 | @staticmethod |
| @@ -128,13 +128,13 @@ class Trigger(commands.Cog): | |||
| 128 | c = conn.cursor() | 128 | c = conn.cursor() |
| 129 | c.execute("SELECT name FROM triggers") | 129 | c.execute("SELECT name FROM triggers") |
| 130 | return [row[0] for row in c.fetchall()] | 130 | return [row[0] for row in c.fetchall()] |
| 131 | 131 | ||
| 132 | @trigger_group.command( | 132 | @trigger_group.command( |
| 133 | description="Create a new trigger", | 133 | description="Create a new trigger", |
| 134 | ) | 134 | ) |
| 135 | async def create( | 135 | async def create( |
| 136 | self, | 136 | self, |
| 137 | ctx: discord.ApplicationContext, | 137 | ctx: discord.ApplicationContext, |
| 138 | name = discord.Option( | 138 | name = discord.Option( |
| 139 | str, | 139 | str, |
| 140 | "the name of the trigger", | 140 | "the name of the trigger", |
| @@ -146,9 +146,9 @@ class Trigger(commands.Cog): | |||
| 146 | c = conn.cursor() | 146 | c = conn.cursor() |
| 147 | c.execute("INSERT OR IGNORE INTO triggers (name) VALUES (?)", (name,)) | 147 | c.execute("INSERT OR IGNORE INTO triggers (name) VALUES (?)", (name,)) |
| 148 | conn.commit() | 148 | conn.commit() |
| 149 | 149 | ||
| 150 | await ctx.respond("trigger added") | 150 | await ctx.respond("trigger added") |
| 151 | 151 | ||
| 152 | @trigger_group.command( | 152 | @trigger_group.command( |
| 153 | description="Add a trigger or response to the name", | 153 | description="Add a trigger or response to the name", |
| 154 | ) | 154 | ) |
| @@ -192,10 +192,10 @@ class Trigger(commands.Cog): | |||
| 192 | if resp: | 192 | if resp: |
| 193 | c.execute("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)", (trigger_id, resp)) | 193 | c.execute("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)", (trigger_id, resp)) |
| 194 | conn.commit() | 194 | conn.commit() |
| 195 | 195 | ||
| 196 | await ctx.respond("trigger updated") | 196 | await ctx.respond("trigger updated") |
| 197 | logger.info(f"Trigger updated: {name}") | 197 | logger.info(f"Trigger updated: {name}") |
| 198 | 198 | ||
| 199 | @trigger_group.command( | 199 | @trigger_group.command( |
| 200 | description="Remove a trigger or response from the name", | 200 | description="Remove a trigger or response from the name", |
| 201 | ) | 201 | ) |
| @@ -233,7 +233,7 @@ class Trigger(commands.Cog): | |||
| 233 | if response: | 233 | if response: |
| 234 | c.execute("DELETE FROM trigger_responses WHERE trigger_id=? AND response_text=?", (trigger_id, response)) | 234 | c.execute("DELETE FROM trigger_responses WHERE trigger_id=? AND response_text=?", (trigger_id, response)) |
| 235 | conn.commit() | 235 | conn.commit() |
| 236 | 236 | ||
| 237 | await ctx.respond("trigger removed") | 237 | await ctx.respond("trigger removed") |
| 238 | logger.info(f"Trigger removed: {name}") | 238 | logger.info(f"Trigger removed: {name}") |
| 239 | 239 | ||
| @@ -241,8 +241,8 @@ class Trigger(commands.Cog): | |||
| 241 | description="Delete the whole trigger", | 241 | description="Delete the whole trigger", |
| 242 | ) | 242 | ) |
| 243 | async def delete( | 243 | async def delete( |
| 244 | self, | 244 | self, |
| 245 | ctx: discord.ApplicationContext, | 245 | ctx: discord.ApplicationContext, |
| 246 | name = discord.Option( | 246 | name = discord.Option( |
| 247 | str, | 247 | str, |
| 248 | "the name of the trigger", | 248 | "the name of the trigger", |
| @@ -262,23 +262,23 @@ class Trigger(commands.Cog): | |||
| 262 | c.execute("DELETE FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) | 262 | c.execute("DELETE FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) |
| 263 | c.execute("DELETE FROM triggers WHERE id=?", (trigger_id,)) | 263 | c.execute("DELETE FROM triggers WHERE id=?", (trigger_id,)) |
| 264 | conn.commit() | 264 | conn.commit() |
| 265 | 265 | ||
| 266 | await ctx.respond("trigger deleted") | 266 | await ctx.respond("trigger deleted") |
| 267 | logger.info(f"Trigger deleted: {name}") | 267 | logger.info(f"Trigger deleted: {name}") |
| 268 | 268 | ||
| 269 | 269 | ||
| 270 | @trigger_group.command( | 270 | @trigger_group.command( |
| 271 | description="List all triggers", | 271 | description="List all triggers", |
| 272 | ) | 272 | ) |
| 273 | async def list( | 273 | async def list( |
| 274 | self, | 274 | self, |
| 275 | ctx: discord.ApplicationContext, | 275 | ctx: discord.ApplicationContext, |
| 276 | name = discord.Option( | 276 | name = discord.Option( |
| 277 | str, | 277 | str, |
| 278 | "the name of the trigger", | 278 | "the name of the trigger", |
| 279 | autocomplete=discord.utils.basic_autocomplete(get_type), | 279 | autocomplete=discord.utils.basic_autocomplete(get_type), |
| 280 | required=True | 280 | required=True |
| 281 | ) | 281 | ) |
| 282 | ): | 282 | ): |
| 283 | with get_connection() as conn: | 283 | with get_connection() as conn: |
| 284 | c = conn.cursor() | 284 | c = conn.cursor() |
| @@ -315,7 +315,7 @@ class Trigger(commands.Cog): | |||
| 315 | description="how to use trigger group?" | 315 | description="how to use trigger group?" |
| 316 | ) | 316 | ) |
| 317 | async def help( | 317 | async def help( |
| 318 | self, | 318 | self, |
| 319 | ctx: discord.ApplicationContext, | 319 | ctx: discord.ApplicationContext, |
| 320 | ): | 320 | ): |
| 321 | await ctx.respond("Trigger create - creates a group for triggers/responses\n" | 321 | await ctx.respond("Trigger create - creates a group for triggers/responses\n" |
| @@ -324,7 +324,7 @@ class Trigger(commands.Cog): | |||
| 324 | "Trigger delete - deletes the whole group\n" | 324 | "Trigger delete - deletes the whole group\n" |
| 325 | "Trigger list - lists triggers/responses of a group\n" | 325 | "Trigger list - lists triggers/responses of a group\n" |
| 326 | "Trigger help - display this message\n") | 326 | "Trigger help - display this message\n") |
| 327 | 327 | ||
| 328 | 328 | ||
| 329 | def setup(bot): | 329 | def setup(bot): |
| 330 | bot.add_cog(Trigger(bot)) | 330 | 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): | |||
| 46 | if not (ctx.author.voice and ctx.author.voice.channel): | 46 | if not (ctx.author.voice and ctx.author.voice.channel): |
| 47 | logger.info("User is not in a voice channel") | 47 | logger.info("User is not in a voice channel") |
| 48 | return | 48 | return |
| 49 | 49 | ||
| 50 | channel = ctx.author.voice.channel | 50 | channel = ctx.author.voice.channel |
| 51 | try: | 51 | try: |
| 52 | voice_client = await self._get_or_connect_voice_client(channel) | 52 | voice_client = await self._get_or_connect_voice_client(channel) |
| 53 | 53 | ||
| 54 | if not voice_client: | 54 | if not voice_client: |
| 55 | await ctx.respond("Failed to connect to voice channel", ephemeral=True) | 55 | await ctx.respond("Failed to connect to voice channel", ephemeral=True) |
| 56 | return | 56 | return |
| 57 | 57 | ||
| 58 | # Play the TTS audio with volume adjustment | 58 | # Play the TTS audio with volume adjustment |
| 59 | audio_source = discord.FFmpegPCMAudio( | 59 | audio_source = discord.FFmpegPCMAudio( |
| 60 | temp_path, | 60 | temp_path, |
| @@ -62,11 +62,11 @@ class TTS(commands.Cog): | |||
| 62 | ) | 62 | ) |
| 63 | voice_client.play(audio_source) | 63 | voice_client.play(audio_source) |
| 64 | logger.info(f"Playing TTS: {text}") | 64 | logger.info(f"Playing TTS: {text}") |
| 65 | 65 | ||
| 66 | # Wait for the audio to finish playing | 66 | # Wait for the audio to finish playing |
| 67 | while voice_client.is_playing(): | 67 | while voice_client.is_playing(): |
| 68 | await asyncio.sleep(0.1) | 68 | await asyncio.sleep(0.1) |
| 69 | 69 | ||
| 70 | except Exception as e: | 70 | except Exception as e: |
| 71 | logger.error(f"Error in voice channel handling: {e}") | 71 | logger.error(f"Error in voice channel handling: {e}") |
| 72 | await ctx.respond("eror :c", ephemeral=True) | 72 | await ctx.respond("eror :c", ephemeral=True) |
| @@ -85,11 +85,10 @@ class TTS(commands.Cog): | |||
| 85 | output: str = discord.Option(str, "Wybierz sposób odtwarzania", choices=["file", "vc"], default="vc") | 85 | output: str = discord.Option(str, "Wybierz sposób odtwarzania", choices=["file", "vc"], default="vc") |
| 86 | ): | 86 | ): |
| 87 | await ctx.defer() | 87 | await ctx.defer() |
| 88 | 88 | ||
| 89 | # Create temporary file in temp folder | 89 | # Create temporary file in temp folder |
| 90 | temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp') | 90 | from cogs.utils.temp import get_temp_dir |
| 91 | os.makedirs(temp_dir, exist_ok=True) | 91 | temp_path = os.path.join(get_temp_dir(), f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") |
| 92 | temp_path = os.path.join(temp_dir, f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") | ||
| 93 | 92 | ||
| 94 | try: | 93 | try: |
| 95 | # Generate and save TTS to temp file | 94 | # Generate and save TTS to temp file |
| @@ -105,7 +104,7 @@ class TTS(commands.Cog): | |||
| 105 | await self._handle_file_output(ctx, temp_path, text) | 104 | await self._handle_file_output(ctx, temp_path, text) |
| 106 | elif output == "vc": | 105 | elif output == "vc": |
| 107 | await self._handle_voice_output(ctx, temp_path, text, volume) | 106 | await self._handle_voice_output(ctx, temp_path, text, volume) |
| 108 | 107 | ||
| 109 | except Exception as e: | 108 | except Exception as e: |
| 110 | logger.error(f"TTS generation error: {e}") | 109 | logger.error(f"TTS generation error: {e}") |
| 111 | await ctx.respond("Error generating TTS", ephemeral=True) | 110 | await ctx.respond("Error generating TTS", ephemeral=True) |
| @@ -113,4 +112,4 @@ class TTS(commands.Cog): | |||
| 113 | await self._cleanup_temp_file(temp_path) | 112 | await self._cleanup_temp_file(temp_path) |
| 114 | 113 | ||
| 115 | def setup(bot): | 114 | def setup(bot): |
| 116 | bot.add_cog(TTS(bot)) \ No newline at end of file | 115 | 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 | |||
| 8 | import aiohttp | 8 | import aiohttp |
| 9 | import os | 9 | import os |
| 10 | from typing import List | 10 | from typing import List |
| 11 | from dotenv import load_dotenv | ||
| 12 | from cogs.utils.settings import get_baseurl | 11 | from cogs.utils.settings import get_baseurl |
| 13 | 12 | ||
| 14 | load_dotenv() | ||
| 15 | |||
| 16 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
| 17 | 14 | ||
| 18 | 15 | ||
| @@ -20,7 +17,7 @@ class UniversalSearcher: | |||
| 20 | """Handles searching for files via copyparty API.""" | 17 | """Handles searching for files via copyparty API.""" |
| 21 | 18 | ||
| 22 | @staticmethod | 19 | @staticmethod |
| 23 | async def search_copyparty(query: str, max_results: int = 25, file_extensions: List[str] = None): | 20 | async def search_copyparty(query: str, max_results: int = 25, file_extensions: List[str] | None = None): |
| 24 | """Search for files using copyparty API (jPOST method). | 21 | """Search for files using copyparty API (jPOST method). |
| 25 | 22 | ||
| 26 | Args: | 23 | Args: |
| @@ -36,7 +33,7 @@ class UniversalSearcher: | |||
| 36 | 33 | ||
| 37 | query = query.strip() | 34 | query = query.strip() |
| 38 | logger.info(f"Starting copyparty search for query: '{query}'") | 35 | logger.info(f"Starting copyparty search for query: '{query}'") |
| 39 | 36 | ||
| 40 | # Build the query with optional extension filtering | 37 | # Build the query with optional extension filtering |
| 41 | name_query = f'name like "*{query}*"' | 38 | name_query = f'name like "*{query}*"' |
| 42 | if file_extensions: | 39 | 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 | |||
| 7 | import logging | 7 | import logging |
| 8 | import aiofiles | 8 | import aiofiles |
| 9 | import os | 9 | import os |
| 10 | from dataclasses import dataclass, field | ||
| 10 | 11 | ||
| 11 | logger = logging.getLogger(__name__) | 12 | logger = logging.getLogger(__name__) |
| 12 | 13 | ||
| 13 | _settings = None | ||
| 14 | settings_file = "data/settings.json" | 14 | settings_file = "data/settings.json" |
| 15 | 15 | ||
| 16 | async def load_settings(): | 16 | |
| 17 | global _settings | 17 | @dataclass |
| 18 | global settings_file | 18 | class Settings: |
| 19 | 19 | volume: float = 1.0 | |
| 20 | default_settings = { | 20 | baseurl: str = "http://localhost:8080" |
| 21 | 'volume': 1.0, | 21 | _data: dict = field(default_factory=dict, init=False) |
| 22 | 'baseurl': 'http://localhost:8080' | 22 | |
| 23 | } | 23 | async def load(self) -> "Settings": |
| 24 | 24 | default_settings = { | |
| 25 | try: | 25 | "volume": 1.0, |
| 26 | if os.path.exists(settings_file): | 26 | "baseurl": "http://localhost:8080", |
| 27 | async with aiofiles.open(settings_file, 'r') as f: | 27 | } |
| 28 | content = await f.read() | 28 | |
| 29 | if content.strip(): | 29 | try: |
| 30 | _settings = json.loads(content) | 30 | if os.path.exists(settings_file): |
| 31 | logger.info("Settings loaded into memory") | 31 | async with aiofiles.open(settings_file, "r") as f: |
| 32 | else: | 32 | content = await f.read() |
| 33 | _settings = default_settings | 33 | if content.strip(): |
| 34 | await save_settings() | 34 | self._data = json.loads(content) |
| 35 | logger.warning("Empty settings.json, reset to defaults") | 35 | self._sync_from_data() |
| 36 | else: | 36 | logger.info("Settings loaded into memory") |
| 37 | _settings = default_settings | 37 | else: |
| 38 | await save_settings() | 38 | self._data = default_settings.copy() |
| 39 | logger.info("Created default settings.json") | 39 | self._sync_from_data() |
| 40 | except (json.JSONDecodeError, Exception) as e: | 40 | await self.save() |
| 41 | logger.error(f"Error loading settings: {e}") | 41 | logger.warning("Empty settings.json, reset to defaults") |
| 42 | _settings = default_settings | 42 | else: |
| 43 | 43 | self._data = default_settings.copy() | |
| 44 | return _settings | 44 | self._sync_from_data() |
| 45 | await self.save() | ||
| 46 | logger.info("Created default settings.json") | ||
| 47 | except (json.JSONDecodeError, Exception) as e: | ||
| 48 | logger.error(f"Error loading settings: {e}") | ||
| 49 | self._data = default_settings.copy() | ||
| 50 | self._sync_from_data() | ||
| 51 | |||
| 52 | return self | ||
| 53 | |||
| 54 | def _sync_from_data(self): | ||
| 55 | self.volume = self._data.get("volume", 1.0) | ||
| 56 | self.baseurl = self._data.get("baseurl", "http://localhost:8080") | ||
| 57 | |||
| 58 | def _sync_to_data(self): | ||
| 59 | self._data["volume"] = self.volume | ||
| 60 | self._data["baseurl"] = self.baseurl | ||
| 61 | |||
| 62 | async def save(self): | ||
| 63 | self._sync_to_data() | ||
| 64 | async with aiofiles.open(settings_file, "w") as f: | ||
| 65 | await f.write(json.dumps(self._data, indent=2)) | ||
| 66 | |||
| 67 | |||
| 68 | _settings_instance: Settings | None = None | ||
| 69 | |||
| 70 | |||
| 71 | async def load_settings() -> Settings: | ||
| 72 | global _settings_instance | ||
| 73 | _settings_instance = Settings() | ||
| 74 | await _settings_instance.load() | ||
| 75 | return _settings_instance | ||
| 45 | 76 | ||
| 46 | 77 | ||
| 47 | async def save_settings(): | 78 | async def save_settings(): |
| 48 | if _settings: | 79 | if _settings_instance: |
| 49 | async with aiofiles.open(settings_file, 'w') as f: | 80 | await _settings_instance.save() |
| 50 | await f.write(json.dumps(_settings, indent=2)) | ||
| 51 | 81 | ||
| 52 | 82 | ||
| 53 | def get_settings(): | 83 | def get_settings() -> Settings | None: |
| 54 | return _settings | 84 | return _settings_instance |
| 55 | 85 | ||
| 56 | 86 | ||
| 57 | def get_volume(): | 87 | def get_volume() -> float: |
| 58 | return _settings.get('volume', 1.0) if _settings else 1.0 | 88 | return _settings_instance.volume if _settings_instance else 1.0 |
| 59 | 89 | ||
| 60 | 90 | ||
| 61 | def get_baseurl(): | 91 | def get_baseurl() -> str: |
| 62 | return _settings.get('baseurl', 'http://localhost:8080').rstrip('/') if _settings else 'http://localhost:8080' \ No newline at end of file | 92 | 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 @@ | |||
| 1 | # This file is part of Senator Sperma | ||
| 2 | # Licensed under the GPL-3.0 License | ||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | ||
| 4 | # https://codeberg.org/salmonik/senator-sperma | ||
| 5 | |||
| 6 | import os | ||
| 7 | import tempfile | ||
| 8 | |||
| 9 | def get_temp_dir(subdir: str = "senator_sperma") -> str: | ||
| 10 | """Get or create a temp subdirectory. | ||
| 11 | |||
| 12 | Uses system temp directory (/tmp on Linux, %TEMP% on Windows). | ||
| 13 | OS handles cleanup automatically. | ||
| 14 | """ | ||
| 15 | base_temp = tempfile.gettempdir() | ||
| 16 | temp_path = os.path.join(base_temp, subdir) | ||
| 17 | os.makedirs(temp_path, exist_ok=True) | ||
| 18 | return temp_path \ 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): | |||
| 69 | return tts_text, channel | 69 | return tts_text, channel |
| 70 | 70 | ||
| 71 | async def play_tts(self, channel, text): | 71 | async def play_tts(self, channel, text): |
| 72 | from cogs.utils.temp import get_temp_dir | ||
| 73 | |||
| 72 | voice_mgr = self.get_voice_manager() | 74 | voice_mgr = self.get_voice_manager() |
| 73 | 75 | temp_dir = get_temp_dir() | |
| 74 | temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp') | ||
| 75 | os.makedirs(temp_dir, exist_ok=True) | ||
| 76 | tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.mp3") | 76 | tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.mp3") |
| 77 | tts = gTTS(text=text, lang='pl') | 77 | tts = gTTS(text=text, lang='pl') |
| 78 | logger.info("saving file") | 78 | logger.info("saving file") |
| 79 | 79 | ||
| 80 | loop = asyncio.get_event_loop() | 80 | loop = asyncio.get_event_loop() |
| 81 | await loop.run_in_executor(None, tts.save, tts_file) | 81 | await loop.run_in_executor(None, tts.save, tts_file) |
| 82 | 82 | ||
| @@ -85,21 +85,21 @@ class VcLogs(commands.Cog): | |||
| 85 | voice_client = voice_status[0] if voice_status else None | 85 | voice_client = voice_status[0] if voice_status else None |
| 86 | is_connected = voice_status[1] if voice_status else False | 86 | is_connected = voice_status[1] if voice_status else False |
| 87 | is_playing = voice_status[2] if voice_status else False | 87 | is_playing = voice_status[2] if voice_status else False |
| 88 | 88 | ||
| 89 | if not is_connected: | 89 | if not is_connected: |
| 90 | voice_client = await voice_mgr.join_channel(channel) | 90 | voice_client = await voice_mgr.join_channel(channel) |
| 91 | voice_status = await voice_mgr.get_voice_status(channel.guild) | 91 | voice_status = await voice_mgr.get_voice_status(channel.guild) |
| 92 | voice_client = voice_status[0] if voice_status else None | 92 | voice_client = voice_status[0] if voice_status else None |
| 93 | is_connected = voice_status[1] if voice_status else False | 93 | is_connected = voice_status[1] if voice_status else False |
| 94 | is_playing = voice_status[2] if voice_status else False | 94 | is_playing = voice_status[2] if voice_status else False |
| 95 | 95 | ||
| 96 | if not is_connected or voice_client is None: | 96 | if not is_connected or voice_client is None: |
| 97 | logger.error("Failed to join voice channel") | 97 | logger.error("Failed to join voice channel") |
| 98 | return | 98 | return |
| 99 | 99 | ||
| 100 | volume = get_volume() | 100 | volume = get_volume() |
| 101 | logger.info(f"connected: {voice_client}") | 101 | logger.info(f"connected: {voice_client}") |
| 102 | 102 | ||
| 103 | if voice_client and not is_playing: | 103 | if voice_client and not is_playing: |
| 104 | logger.info("preparing to play audio") | 104 | logger.info("preparing to play audio") |
| 105 | audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') | 105 | audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') |
| @@ -118,7 +118,7 @@ class VcLogs(commands.Cog): | |||
| 118 | async def volume(self, ctx: discord.ApplicationContext, volume: int): | 118 | async def volume(self, ctx: discord.ApplicationContext, volume: int): |
| 119 | from cogs.utils.settings import get_settings | 119 | from cogs.utils.settings import get_settings |
| 120 | settings = get_settings() | 120 | settings = get_settings() |
| 121 | settings['volume'] = volume | 121 | settings.volume = volume |
| 122 | await save_settings() | 122 | await save_settings() |
| 123 | await ctx.followup.send(f"Volume has been set to: {volume}") | 123 | await ctx.followup.send(f"Volume has been set to: {volume}") |
| 124 | logger.info(f"Volume changed to: {volume}") | 124 | logger.info(f"Volume changed to: {volume}") |
| @@ -159,17 +159,17 @@ class VcLogs(commands.Cog): | |||
| 159 | elif before.channel is not None and after.channel is None: | 159 | elif before.channel is not None and after.channel is None: |
| 160 | if user_id in activity_data: | 160 | if user_id in activity_data: |
| 161 | data = activity_data[user_id] | 161 | data = activity_data[user_id] |
| 162 | 162 | ||
| 163 | if data.get('is_afk') and data.get('afk_start'): | 163 | if data.get('is_afk') and data.get('afk_start'): |
| 164 | afk_duration = current_ts - data['afk_start'] | 164 | afk_duration = current_ts - data['afk_start'] |
| 165 | data['total_afk'] += afk_duration | 165 | data['total_afk'] += afk_duration |
| 166 | elif data.get('active_start'): | 166 | elif data.get('active_start'): |
| 167 | active_since_start = current_ts - data['active_start'] | 167 | active_since_start = current_ts - data['active_start'] |
| 168 | data['total_active'] += active_since_start | 168 | data['total_active'] += active_since_start |
| 169 | 169 | ||
| 170 | active_str = format_duration(data['total_active']) | 170 | active_str = format_duration(data['total_active']) |
| 171 | afk_str = format_duration(data['total_afk']) | 171 | afk_str = format_duration(data['total_afk']) |
| 172 | 172 | ||
| 173 | message = ( | 173 | message = ( |
| 174 | f"[{current_time}] {member.name} rozłączył się z {before.channel.name}\n" | 174 | f"[{current_time}] {member.name} rozłączył się z {before.channel.name}\n" |
| 175 | f"Aktywny: {active_str} | AFK: {afk_str}" | 175 | f"Aktywny: {active_str} | AFK: {afk_str}" |
| @@ -178,7 +178,7 @@ class VcLogs(commands.Cog): | |||
| 178 | tts_channel = before.channel | 178 | tts_channel = before.channel |
| 179 | await log_channel.send(message) | 179 | await log_channel.send(message) |
| 180 | logger.info(f"User left voice channel: {message}") | 180 | logger.info(f"User left voice channel: {message}") |
| 181 | 181 | ||
| 182 | del activity_data[user_id] | 182 | del activity_data[user_id] |
| 183 | save_activity_data(activity_data) | 183 | save_activity_data(activity_data) |
| 184 | else: | 184 | else: |
| @@ -190,7 +190,7 @@ class VcLogs(commands.Cog): | |||
| 190 | 190 | ||
| 191 | elif user_id in activity_data: | 191 | elif user_id in activity_data: |
| 192 | data = activity_data[user_id] | 192 | data = activity_data[user_id] |
| 193 | 193 | ||
| 194 | if data.get('is_afk') and not (after.self_mute or after.self_deaf): | 194 | if data.get('is_afk') and not (after.self_mute or after.self_deaf): |
| 195 | afk_duration = current_ts - data['afk_start'] | 195 | afk_duration = current_ts - data['afk_start'] |
| 196 | data['total_afk'] += afk_duration | 196 | data['total_afk'] += afk_duration |
| @@ -198,7 +198,7 @@ class VcLogs(commands.Cog): | |||
| 198 | data['is_afk'] = False | 198 | data['is_afk'] = False |
| 199 | data['active_start'] = current_ts | 199 | data['active_start'] = current_ts |
| 200 | save_activity_data(activity_data) | 200 | save_activity_data(activity_data) |
| 201 | 201 | ||
| 202 | afk_str = format_duration(afk_duration) | 202 | afk_str = format_duration(afk_duration) |
| 203 | 203 | ||
| 204 | if after.self_mute: | 204 | if after.self_mute: |
| @@ -219,7 +219,7 @@ class VcLogs(commands.Cog): | |||
| 219 | data['is_afk'] = True | 219 | data['is_afk'] = True |
| 220 | data['active_start'] = None | 220 | data['active_start'] = None |
| 221 | save_activity_data(activity_data) | 221 | save_activity_data(activity_data) |
| 222 | 222 | ||
| 223 | if after.self_mute: | 223 | if after.self_mute: |
| 224 | activity = "wyciszył" | 224 | activity = "wyciszył" |
| 225 | else: | 225 | 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 | |||
| 7 | from discord.ext import commands | 7 | from discord.ext import commands |
| 8 | import logging | 8 | import logging |
| 9 | import asyncio | 9 | import asyncio |
| 10 | from dataclasses import dataclass, field | ||
| 11 | from typing import Any | ||
| 10 | 12 | ||
| 11 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
| 12 | 14 | ||
| 13 | _voice_manager = None | ||
| 14 | 15 | ||
| 15 | def get_voice_manager(bot): | 16 | _voice_managers: dict[int, "VoiceManager"] = {} |
| 16 | global _voice_manager | ||
| 17 | if _voice_manager is None: | ||
| 18 | _voice_manager = VoiceManager(bot) | ||
| 19 | return _voice_manager | ||
| 20 | 17 | ||
| 21 | 18 | ||
| 19 | def get_voice_manager(bot) -> "VoiceManager": | ||
| 20 | """Get or create VoiceManager for a specific bot instance.""" | ||
| 21 | bot_id = bot.user.id if bot.user else id(bot) | ||
| 22 | if bot_id not in _voice_managers: | ||
| 23 | _voice_managers[bot_id] = VoiceManager(bot) | ||
| 24 | return _voice_managers[bot_id] | ||
| 25 | |||
| 26 | |||
| 27 | @dataclass | ||
| 22 | class VoiceManager: | 28 | class VoiceManager: |
| 23 | def __init__(self, bot): | 29 | bot: Any # type: ignore[no-any-import] |
| 24 | self.bot = bot | 30 | _voice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) |
| 25 | self._voice_lock = asyncio.Lock() | ||
| 26 | 31 | ||
| 27 | async def join_channel(self, channel): | 32 | async def join_channel(self, channel): |
| 28 | """Join a voice channel by channel object.""" | 33 | """Join a voice channel by channel object.""" |
| @@ -71,17 +76,17 @@ class VoiceManager: | |||
| 71 | 76 | ||
| 72 | async def get_voice_status(self, guild): | 77 | async def get_voice_status(self, guild): |
| 73 | """Get voice client status including connection and playback state. | 78 | """Get voice client status including connection and playback state. |
| 74 | 79 | ||
| 75 | Returns: | 80 | Returns: |
| 76 | tuple: (voice_client, is_connected, is_playing) or (None, False, False) if no client | 81 | tuple: (voice_client, is_connected, is_playing) or (None, False, False) if no client |
| 77 | """ | 82 | """ |
| 78 | voice_client = await self.get_voice_client(guild) | 83 | voice_client = await self.get_voice_client(guild) |
| 79 | if voice_client is None: | 84 | if voice_client is None: |
| 80 | return None, False, False | 85 | return None, False, False |
| 81 | 86 | ||
| 82 | is_connected = voice_client.is_connected() | 87 | is_connected = voice_client.is_connected() |
| 83 | is_playing = voice_client.is_playing() if is_connected else False | 88 | is_playing = voice_client.is_playing() if is_connected else False |
| 84 | 89 | ||
| 85 | return voice_client, is_connected, is_playing | 90 | return voice_client, is_connected, is_playing |
| 86 | 91 | ||
| 87 | async def play_audio(self, channel, audio_source, volume=1.0): | 92 | async def play_audio(self, channel, audio_source, volume=1.0): |
| @@ -178,14 +183,18 @@ class Voice(commands.Cog): | |||
| 178 | if not await self._validate_user_voice_state(ctx): | 183 | if not await self._validate_user_voice_state(ctx): |
| 179 | return None | 184 | return None |
| 180 | return await self._create_voice_client(ctx) | 185 | return await self._create_voice_client(ctx) |
| 181 | 186 | ||
| 182 | if voice_client.channel != ctx.author.voice.channel: | 187 | if voice_client.channel != ctx.author.voice.channel: |
| 183 | return await self._move_voice_client(voice_client, ctx) | 188 | return await self._move_voice_client(voice_client, ctx) |
| 184 | 189 | ||
| 185 | return await self._verify_connection(voice_client) | 190 | return await self._verify_connection(voice_client) |
| 186 | 191 | ||
| 187 | async def connect(self, ctx): | 192 | async def connect(self, ctx): |
| 188 | voice_client = await self.ensure_voice_client(ctx) | 193 | voice_client = await self.ensure_voice_client(ctx) |
| 189 | if not voice_client: | 194 | if not voice_client: |
| 190 | return None | 195 | return None |
| 191 | return voice_client \ No newline at end of file | 196 | return voice_client |
| 197 | |||
| 198 | |||
| 199 | def setup(bot): | ||
| 200 | 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 | |||
| 9 | import requests | 9 | import requests |
| 10 | from bs4 import BeautifulSoup | 10 | from bs4 import BeautifulSoup |
| 11 | import urllib.parse | 11 | import urllib.parse |
| 12 | import pytube | 12 | import yt_dlp |
| 13 | import logging | 13 | import logging |
| 14 | 14 | ||
| 15 | logger = logging.getLogger(__name__) | 15 | logger = logging.getLogger(__name__) |
| 16 | 16 | ||
| 17 | |||
| 17 | class Web(commands.Cog): | 18 | class Web(commands.Cog): |
| 18 | def __init__(self, bot): | 19 | def __init__(self, bot): |
| 19 | self.bot = bot | 20 | self.bot = bot |
| 20 | 21 | ||
| 21 | def _extract_youtube_title(self, url): | 22 | def _extract_youtube_title(self, url): |
| 22 | """Extract YouTube video title if URL is a YouTube link.""" | 23 | """Extract YouTube video title if URL is a YouTube link.""" |
| 23 | if not url.startswith('https://www.youtube.com/'): | 24 | if "youtube.com/watch" not in url: |
| 24 | return None | 25 | return None |
| 25 | 26 | ||
| 26 | logger.info("youtube link") | 27 | logger.info("youtube link") |
| 27 | try: | 28 | try: |
| 28 | parsed_url = urllib.parse.urlparse(url) | 29 | ydl_opts = {"quiet": True, "nowarnings": True} |
| 29 | video_id = urllib.parse.parse_qs(parsed_url.query).get('v', [None])[0] | 30 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: |
| 30 | if video_id: | 31 | info = ydl.extract_info(url, download=False) |
| 31 | yt = pytube.YouTube(f'https://www.youtube.com/watch?v={video_id}') | 32 | return info.get("title") if info else None |
| 32 | return yt.title | ||
| 33 | except Exception: | 33 | except Exception: |
| 34 | pass | 34 | pass |
| 35 | return None | 35 | return None |
| @@ -37,10 +37,10 @@ class Web(commands.Cog): | |||
| 37 | def _create_button(self, index, link): | 37 | def _create_button(self, index, link): |
| 38 | """Create a button for sending search result.""" | 38 | """Create a button for sending search result.""" |
| 39 | button = discord.ui.Button(label=f"Send Result {index}") | 39 | button = discord.ui.Button(label=f"Send Result {index}") |
| 40 | 40 | ||
| 41 | async def button_callback(interaction: discord.Interaction): | 41 | async def button_callback(interaction: discord.Interaction): |
| 42 | await interaction.response.send_message(link.get('href')) | 42 | await interaction.response.send_message(link.get("href")) |
| 43 | 43 | ||
| 44 | button.callback = button_callback | 44 | button.callback = button_callback |
| 45 | return button | 45 | return button |
| 46 | 46 | ||
| @@ -48,17 +48,17 @@ class Web(commands.Cog): | |||
| 48 | """Process search results and create embed with buttons.""" | 48 | """Process search results and create embed with buttons.""" |
| 49 | embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) | 49 | embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF) |
| 50 | view = discord.ui.View(disable_on_timeout=True) | 50 | view = discord.ui.View(disable_on_timeout=True) |
| 51 | 51 | ||
| 52 | for i, link in enumerate(links[:5], start=1): | 52 | for i, link in enumerate(links[:5], start=1): |
| 53 | title = link.text | 53 | title = link.text |
| 54 | youtube_title = self._extract_youtube_title(link.get('href')) | 54 | youtube_title = self._extract_youtube_title(link.get("href")) |
| 55 | if youtube_title: | 55 | if youtube_title: |
| 56 | title = youtube_title | 56 | title = youtube_title |
| 57 | 57 | ||
| 58 | embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) | 58 | embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False) |
| 59 | button = self._create_button(i, link) | 59 | button = self._create_button(i, link) |
| 60 | view.add_item(button) | 60 | view.add_item(button) |
| 61 | 61 | ||
| 62 | return embed, view | 62 | return embed, view |
| 63 | 63 | ||
| 64 | @commands.slash_command( | 64 | @commands.slash_command( |
| @@ -71,7 +71,7 @@ class Web(commands.Cog): | |||
| 71 | query: str | 71 | query: str |
| 72 | ): | 72 | ): |
| 73 | await ctx.defer(ephemeral=True) | 73 | await ctx.defer(ephemeral=True) |
| 74 | headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'} | 74 | headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"} |
| 75 | encoded_query = urllib.parse.quote_plus(query) | 75 | encoded_query = urllib.parse.quote_plus(query) |
| 76 | url = f"https://duckduckgo.com/html/?q={encoded_query}" | 76 | url = f"https://duckduckgo.com/html/?q={encoded_query}" |
| 77 | response = requests.get(url, headers=headers) | 77 | response = requests.get(url, headers=headers) |
| @@ -83,9 +83,9 @@ class Web(commands.Cog): | |||
| 83 | logger.error(f"Error: {response.status_code}") | 83 | logger.error(f"Error: {response.status_code}") |
| 84 | return | 84 | return |
| 85 | 85 | ||
| 86 | soup = BeautifulSoup(response.text, 'html.parser') | 86 | soup = BeautifulSoup(response.text, "html.parser") |
| 87 | links = soup.find_all('a', class_='result__a') | 87 | links = soup.find_all("a", class_="result__a") |
| 88 | 88 | ||
| 89 | if not links: | 89 | if not links: |
| 90 | await ctx.respond(content="No results found.", ephemeral=True) | 90 | await ctx.respond(content="No results found.", ephemeral=True) |
| 91 | logger.warning("No results found") | 91 | logger.warning("No results found") |
| @@ -95,5 +95,6 @@ class Web(commands.Cog): | |||
| 95 | await ctx.respond(embed=embed, view=view, ephemeral=True) | 95 | await ctx.respond(embed=embed, view=view, ephemeral=True) |
| 96 | logger.info("Search results sent") | 96 | logger.info("Search results sent") |
| 97 | 97 | ||
| 98 | |||
| 98 | def setup(bot): | 99 | def setup(bot): |
| 99 | bot.add_cog(Web(bot)) | 100 | bot.add_cog(Web(bot)) \ No newline at end of file |
