aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-04-18 00:24:50 +0200
committersalmonik <raisersoft@proton.me>2026-04-18 00:24:50 +0200
commita2b5b10625126b7e98643af9fe40569c9e004047 (patch)
tree2b59a929d068bc28bbc21e8c8d8836e4d29270c0
parentadd mathematical plotting (diff)
downloadsenator-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--.env.example14
-rw-r--r--.gitignore3
-rw-r--r--README.md94
-rw-r--r--TODO.md2
-rw-r--r--cogs/ai.py8
-rw-r--r--cogs/assistant.py (renamed from cogs/jarvis.py)42
-rw-r--r--cogs/compression.py202
-rw-r--r--cogs/conversion.py198
-rw-r--r--cogs/downloader.py8
-rw-r--r--cogs/music/audio_playback.py13
-rw-r--r--cogs/music/media_processing.py16
-rw-r--r--cogs/music/play_commands.py17
-rw-r--r--cogs/plot.py65
-rw-r--r--cogs/qr.py46
-rw-r--r--cogs/say.py2
-rw-r--r--cogs/search.py12
-rw-r--r--cogs/trigger.py64
-rw-r--r--cogs/tts.py21
-rw-r--r--cogs/utils/file_search.py7
-rw-r--r--cogs/utils/settings.py108
-rw-r--r--cogs/utils/temp.py18
-rw-r--r--cogs/vclogs.py30
-rw-r--r--cogs/voice.py39
-rw-r--r--cogs/web.py41
-rw-r--r--main.py43
-rw-r--r--pyrightconfig.json18
-rw-r--r--requirements.txt9
27 files changed, 775 insertions, 365 deletions
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7a52a26
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
1# Required
2TOKEN=
3
4# Optional - Bot Configuration
5STATUS_CHANNEL_ID=
6ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant
7
8# Optional - File Server (for music /search commands)
9COPYPARTY_URL=http://localhost:8080
10COPYPARTY_URL_UPLOAD=http://localhost:8080/uploads
11COPYPARTY_PASSWORD=
12
13# Optional - AI commands
14OPENROUTER_API_KEY=
diff --git a/.gitignore b/.gitignore
index 511ed04..c9cc484 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,5 @@
1.env 1.env
2__pycache__/ 2__pycache__/
3temp/
4cogs/__pycache__
5cogs/music/__pycache__
6data/ 3data/
7.idea/ 4.idea/
8.venv/ 5.venv/
diff --git a/README.md b/README.md
index 7a23f7f..92468a7 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,93 @@
1not modulable (yet) bot made for shits and giggles for me and my friends 1# Senator Sperma
2 2
3use python 3.13.12, since py-cord DOES NOT support 3.14 yet \ No newline at end of file 3A Discord bot with music playback, TTS, web search, and utility commands.
4
5Requires Python 3.13 (py-cord doesn't support 3.14 yet).
6
7## Setup
8
9### 1. Clone & Create Virtual Environment
10
11```bash
12git clone https://codeberg.org/salmonik/senator-sperma
13cd senator-sperma
14python -m venv .venv
15source .venv/bin/activate # Linux/macOS
16# or .venv\Scripts\activate # Windows
17```
18
19### 2. Install Dependencies
20
21```bash
22pip install -r requirements.txt
23```
24
25You also need `ffmpeg` and `zbar` library and installed system-wide
26
27### 3. Create .env File
28
29Create a `.env` file in the project root:
30
31```bash
32cp .env.example .env
33```
34
35Edit `.env` with your credentials:
36
37```env
38# Required
39TOKEN=your_discord_bot_token
40
41# Optional - Bot Configuration
42STATUS_CHANNEL_ID=123456789012345678 # Channel for startup message
43ASSISTANT_KEYWORD=jarvis # Keyword to trigger AI assistant
44
45# Optional - File Server (for music /search commands)
46COPYPARTY_URL=http://localhost:8080
47COPYPARTY_PASSWORD=your_password
48
49# Optional - AI commands
50OPENROUTER_API_KEY=
51```
52
53### 6. Run the Bot
54
55```bash
56python main.py
57```
58
59## Configuration
60
61All settings are optional - defaults are provided if not set.
62
63| Variable | Description | Default |
64|---------|-------------|---------|
65| `TOKEN` | Discord bot token | (required) |
66| `STATUS_CHANNEL_ID` | Channel for startup message | none |
67| `ASSISTANT_KEYWORD` | Keyword to trigger AI assistant | `jarvis` |
68| `COPYPARTY_URL` | File server base URL | `http://localhost:8080` |
69| `COPYPARTY_PASSWORD` | File server password | none |
70| `OPENROUTER_API_KEY` | AI API key (for /senator command) | none |
71
72## Commands
73
74### Music
75- `/play` - Play music from file server or an internet audio stream
76- `/stop` - Stop playback
77- `/join` - Join voice channel
78- `/loop` - Loop current track
79
80### Utility
81- `/search` - Search files on server
82- `/web` - DuckDuckGo search
83- `/download` - Download video/audio
84- `/tts` - Text to speech
85- `/qr_koduj` / `qr_dekoduj` - QR encode/decode
86- `/funkcja` - Math plot
87- `/kompresja` - Compress images
88- `/konwersja` - Convert audio/video
89
90## Dependencies
91
92- `ffmpeg` - System-wide audio processing
93- `py-cord` - Discord library (due to instable nature of discord's voice features, py-cord has to be installed directly from git)
diff --git a/TODO.md b/TODO.md
index c388caf..36584a7 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,4 +2,4 @@
2## userbot features 2## userbot features
3 why not make the bot allow users to benefit from it outside of the server? 3 why not make the bot allow users to benefit from it outside of the server?
4 4
5 for example, let's allow the user to download, search the copyparty instance or compress in DMs/other servers \ No newline at end of file 5 for example, let's allow the user to download, search the copyparty instance or compress in DMs/other servers
diff --git a/cogs/ai.py b/cogs/ai.py
index 14e077d..4a04693 100644
--- a/cogs/ai.py
+++ b/cogs/ai.py
@@ -9,7 +9,7 @@ from discord.ext import commands
9import logging 9import logging
10import requests 10import requests
11from gtts import gTTS 11from gtts import gTTS
12from cogs.music.play_commands import PlayCommands 12from cogs.music import audio_playback
13import io 13import io
14 14
15logger = logging.getLogger(__name__) 15logger = 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
10import json 10import json
11import logging 11import logging
12import shutil 12import shutil
13from dotenv import load_dotenv
14import os 13import os
15from tempfile import NamedTemporaryFile 14from tempfile import NamedTemporaryFile
16from cogs.downloader import Downloader 15from cogs.downloader import Downloader
17 16
18logger = logging.getLogger(__name__) 17logger = logging.getLogger(__name__)
19load_dotenv()
20 18
21class Jarvis(commands.Cog): 19class 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 @@
6import mimetypes 6import mimetypes
7import os 7import os
8import asyncio 8import asyncio
9import aiofiles
10import requests
11import urllib.parse
9from typing import List, Optional 12from typing import List, Optional
10from os import PathLike 13from os import PathLike
11import discord 14import discord
12from discord.ext import commands 15from discord.ext import commands
16from discord import AutocompleteContext
17from cogs.utils import file_search as search_utils
18import shlex
13import logging 19import logging
14 20
15logger = logging.getLogger(__name__) 21logger = logging.getLogger(__name__)
@@ -27,87 +33,142 @@ def filetype(filename):
27 33
28class Compression(commands.Cog): 34class 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
179def setup(bot): 303def 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
12import aiofiles 12import aiofiles
13import mimetypes 13import mimetypes
14import subprocess 14import subprocess
15import urllib.parse
16import shlex
17from typing import Optional
18from discord import AutocompleteContext
19from cogs.utils import file_search as search_utils
15 20
16logger = logging.getLogger(__name__) 21logger = logging.getLogger(__name__)
17 22
@@ -29,28 +34,63 @@ def filetype(filename):
29class Conversion(commands.Cog): 34class 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
147def setup(bot): 243def 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
11import re 11import re
12import yt_dlp 12import yt_dlp
13from discord.ext import commands 13from discord.ext import commands
14from dotenv import load_dotenv
15import aiofiles 14import aiofiles
16 15
17logger = logging.getLogger(__name__) 16logger = logging.getLogger(__name__)
18 17
19# Load environment variables
20load_dotenv()
21
22 18
23class Downloader(commands.Cog): 19class 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
15logger = logging.getLogger(__name__) 15logger = logging.getLogger(__name__)
16 16
17try: 17_plt_module = None
18 import matplotlib 18_plot_available = False
19 19
20 matplotlib.use("Agg") 20
21 import matplotlib.pyplot as plt 21def _ensure_plot():
22 22 global _plt_module, _plot_available
23 PLOT_AVAILABLE = True 23
24except 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
29def parse_expression(expr: str) -> str: 38def 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
127def setup(bot): 126def setup(bot):
128 bot.add_cog(Plot(bot)) 127 bot.add_cog(Plot(bot)) \ No newline at end of file
diff --git a/cogs/qr.py b/cogs/qr.py
index 058dac8..c1fd499 100644
--- a/cogs/qr.py
+++ b/cogs/qr.py
@@ -15,14 +15,26 @@ from discord.ext import commands
15 15
16logger = logging.getLogger(__name__) 16logger = logging.getLogger(__name__)
17 17
18try: 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
23except ImportError: 23def _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
28class QR(commands.Cog): 40class 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
27def setup(bot): 27def 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
329def setup(bot): 329def 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
115def setup(bot): 114def 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
8import aiohttp 8import aiohttp
9import os 9import os
10from typing import List 10from typing import List
11from dotenv import load_dotenv
12from cogs.utils.settings import get_baseurl 11from cogs.utils.settings import get_baseurl
13 12
14load_dotenv()
15
16logger = logging.getLogger(__name__) 13logger = 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
7import logging 7import logging
8import aiofiles 8import aiofiles
9import os 9import os
10from dataclasses import dataclass, field
10 11
11logger = logging.getLogger(__name__) 12logger = logging.getLogger(__name__)
12 13
13_settings = None
14settings_file = "data/settings.json" 14settings_file = "data/settings.json"
15 15
16async def load_settings(): 16
17 global _settings 17@dataclass
18 global settings_file 18class 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
71async 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
47async def save_settings(): 78async 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
53def get_settings(): 83def get_settings() -> Settings | None:
54 return _settings 84 return _settings_instance
55 85
56 86
57def get_volume(): 87def 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
61def get_baseurl(): 91def 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
6import os
7import tempfile
8
9def 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
7from discord.ext import commands 7from discord.ext import commands
8import logging 8import logging
9import asyncio 9import asyncio
10from dataclasses import dataclass, field
11from typing import Any
10 12
11logger = logging.getLogger(__name__) 13logger = logging.getLogger(__name__)
12 14
13_voice_manager = None
14 15
15def 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
19def 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
22class VoiceManager: 28class 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
199def 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
9import requests 9import requests
10from bs4 import BeautifulSoup 10from bs4 import BeautifulSoup
11import urllib.parse 11import urllib.parse
12import pytube 12import yt_dlp
13import logging 13import logging
14 14
15logger = logging.getLogger(__name__) 15logger = logging.getLogger(__name__)
16 16
17
17class Web(commands.Cog): 18class 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
98def setup(bot): 99def setup(bot):
99 bot.add_cog(Web(bot)) 100 bot.add_cog(Web(bot)) \ No newline at end of file
diff --git a/main.py b/main.py
index e0de844..6c8c35b 100644
--- a/main.py
+++ b/main.py
@@ -7,6 +7,7 @@ import asyncio
7import logging 7import logging
8import os 8import os
9import sys 9import sys
10import argparse
10 11
11import discord 12import discord
12from discord.ext import commands 13from discord.ext import commands
@@ -14,13 +15,16 @@ from dotenv import load_dotenv
14 15
15from cogs.utils import settings 16from cogs.utils import settings
16 17
17# Configure logging 18
18logging.basicConfig( 19def setup_logging(debug: bool = False):
19 level=logging.INFO, 20 """Configure logging based on debug flag."""
20 format="%(levelname)s - %(filename)s - %(name)s - %(message)s", 21 level = logging.DEBUG if debug else logging.INFO
21 handlers=[logging.StreamHandler(sys.stdout)], 22 logging.basicConfig(
22) 23 level=level,
23logger = logging.getLogger(__name__) 24 format="%(levelname)s - %(filename)s - %(name)s - %(message)s",
25 handlers=[logging.StreamHandler(sys.stdout)],
26 )
27
24 28
25# Create a new event loop and set it as the current loop 29# Create a new event loop and set it as the current loop
26loop = asyncio.new_event_loop() 30loop = asyncio.new_event_loop()
@@ -32,17 +36,26 @@ bot = commands.Bot(intents=intents, loop=loop)
32 36
33@bot.event 37@bot.event
34async def on_ready(): 38async def on_ready():
39 logger = logging.getLogger(__name__)
35 logger.info("ŁUKASZ SPERMA AKTYWACJA") 40 logger.info("ŁUKASZ SPERMA AKTYWACJA")
36 41
37 channel = bot.get_channel(1081667823799111804) 42 channel_id = os.getenv("STATUS_CHANNEL_ID")
38 43 if channel_id:
39 await channel.send("ŁUKASZ SPERMA AKTYWACJA") 44 channel = bot.get_channel(int(channel_id))
45 if channel:
46 await channel.send("ŁUKASZ SPERMA AKTYWACJA")
40 47
41 await settings.load_settings() 48 await settings.load_settings()
42 49
43 50
44if __name__ == "__main__": 51if __name__ == "__main__":
52 parser = argparse.ArgumentParser(description="Run Senator Sperma bot")
53 parser.add_argument("--debug", action="store_true", help="Enable debug logging")
54 args, unknown = parser.parse_known_args()
55
45 load_dotenv() 56 load_dotenv()
57 setup_logging(args.debug)
58
46 extensions = [ 59 extensions = [
47 "cogs.vclogs", 60 "cogs.vclogs",
48 "cogs.compression", 61 "cogs.compression",
@@ -53,7 +66,7 @@ if __name__ == "__main__":
53 "cogs.math_commands", 66 "cogs.math_commands",
54 "cogs.downloader", 67 "cogs.downloader",
55 "cogs.say", 68 "cogs.say",
56 "cogs.jarvis", 69 "cogs.assistant",
57 "cogs.music.play_commands", 70 "cogs.music.play_commands",
58 "cogs.music.stop", 71 "cogs.music.stop",
59 "cogs.music.join", 72 "cogs.music.join",
@@ -62,14 +75,14 @@ if __name__ == "__main__":
62 "cogs.cron", 75 "cogs.cron",
63 "cogs.qr", 76 "cogs.qr",
64 "cogs.plot", 77 "cogs.plot",
78 "cogs.voice",
65 ] 79 ]
66 80
67 try: 81 try:
68 for i, extension in enumerate(extensions, 1): 82 for i, extension in enumerate(extensions, 1):
69 bot.load_extension(extension) 83 bot.load_extension(extension)
70 logger.info(f"Loaded {i}/{len(extensions)} extensions") 84 logging.getLogger(__name__).info(f"Loaded {i}/{len(extensions)} extensions")
71 85
72 # Run the bot with the token from environment variables
73 token = os.getenv("TOKEN") 86 token = os.getenv("TOKEN")
74 if not token: 87 if not token:
75 raise ValueError( 88 raise ValueError(
@@ -79,5 +92,5 @@ if __name__ == "__main__":
79 bot.run(token) 92 bot.run(token)
80 93
81 except Exception as e: 94 except Exception as e:
82 logger.error(f"Error starting the bot: {e}") 95 logging.getLogger(__name__).error(f"Error starting the bot: {e}")
83 sys.exit(1) 96 sys.exit(1) \ No newline at end of file
diff --git a/pyrightconfig.json b/pyrightconfig.json
new file mode 100644
index 0000000..f0b2a2d
--- /dev/null
+++ b/pyrightconfig.json
@@ -0,0 +1,18 @@
1{
2 "include": ["cogs", "main.py", "cogs/utils"],
3 "exclude": ["pycord", ".venv", "temp", "data"],
4 "typeCheckingMode": "basic",
5 "reportMissingTypeStubs": false,
6 "reportUnknownMemberType": "none",
7 "reportUnknownParameterType": "none",
8 "reportUnknownArgumentType": "none",
9 "reportUnknownVariableType": "none",
10 "reportMissingParameterType": false,
11 "reportAny": false,
12 "reportOptionalMemberAccess": false,
13 "reportOptionalSubscript": false,
14 "reportCallIssue": false,
15 "reportAttributeAccessIssue": false,
16 "reportPossiblyUnboundVariable": false,
17 "reportArgumentType": false,
18}
diff --git a/requirements.txt b/requirements.txt
index 863badd..81f00ef 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,7 +4,9 @@ aiohttp==3.13.3
4aiosignal==1.4.0 4aiosignal==1.4.0
5anyio==4.12.1 5anyio==4.12.1
6attrs==25.4.0 6attrs==25.4.0
7basedpyright==1.39.2
7beautifulsoup4==4.14.3 8beautifulsoup4==4.14.3
9brotli==1.2.0
8bs4==0.0.2 10bs4==0.0.2
9certifi==2026.2.25 11certifi==2026.2.25
10cffi==2.0.0 12cffi==2.0.0
@@ -26,12 +28,14 @@ kiwisolver==1.5.0
26matplotlib==3.10.8 28matplotlib==3.10.8
27multidict==6.7.1 29multidict==6.7.1
28mutagen==1.47.0 30mutagen==1.47.0
31nodejs-wheel-binaries==24.14.1
29numpy==2.4.4 32numpy==2.4.4
30packaging==26.1 33packaging==26.1
31pillow==12.2.0 34pillow==12.2.0
32propcache==0.4.1 35propcache==0.4.1
33py-cord @ file:///home/salmon/Documents/botcode/senator-sperma/pycord 36py-cord @ file:///home/salmon/Documents/botcode/senator-sperma/pycord
34pycparser==3.0 37pycparser==3.0
38pycryptodomex==3.23.0
35PyNaCl==1.6.2 39PyNaCl==1.6.2
36pyparsing==3.3.2 40pyparsing==3.3.2
37python-dateutil==2.9.0.post0 41python-dateutil==2.9.0.post0
@@ -45,7 +49,8 @@ six==1.17.0
45soupsieve==2.8.3 49soupsieve==2.8.3
46typing_extensions==4.15.0 50typing_extensions==4.15.0
47urllib3==2.6.3 51urllib3==2.6.3
52websockets==16.0
48yarl==1.23.0 53yarl==1.23.0
49youtube-search-python==1.6.6 54youtube-search-python==1.6.6
50yt-dlp==2026.3.13 55yt-dlp==2026.3.17
51yt-dlp-ejs==0.7.0 56yt-dlp-ejs==0.8.0