aboutsummaryrefslogtreecommitdiffstats
path: root/cogs
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-07-08 02:13:35 +0200
committersalmonik <raisersoft@proton.me>2026-07-08 02:13:35 +0200
commitcd8ed8bc5793985df2c1692072a932ceee517b95 (patch)
tree42d18a4140259b5c68b4dd05b7fcb398f78b8d7f /cogs
parentMerge pull request 'Voice related fixes, alongside simplifying assistant' (#5... (diff)
downloadsenator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.tar.gz
senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.zip
OPEN THE RUST GATES
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to '')
-rw-r--r--cogs/ai.py78
-rw-r--r--cogs/assistant.py130
-rw-r--r--cogs/compression.py306
-rw-r--r--cogs/conversion.py245
-rw-r--r--cogs/cron.py403
-rw-r--r--cogs/downloader.py234
-rw-r--r--cogs/math_commands.py83
-rw-r--r--cogs/music/join.py29
-rw-r--r--cogs/music/loop.py40
-rw-r--r--cogs/music/media_processing.py135
-rw-r--r--cogs/music/play_commands.py320
-rw-r--r--cogs/music/stop.py29
-rw-r--r--cogs/qr.py154
-rw-r--r--cogs/say.py28
-rw-r--r--cogs/search.py73
-rw-r--r--cogs/trigger.py260
-rw-r--r--cogs/tts.py111
-rw-r--r--cogs/uploader.py156
-rw-r--r--cogs/utils/database.py126
-rw-r--r--cogs/utils/file_search.py167
-rw-r--r--cogs/utils/settings.py114
-rw-r--r--cogs/utils/temp.py18
-rw-r--r--cogs/utils/url_helper.py21
-rw-r--r--cogs/vclogs.py316
-rw-r--r--cogs/voice.py444
-rw-r--r--cogs/web.py90
26 files changed, 0 insertions, 4110 deletions
diff --git a/cogs/ai.py b/cogs/ai.py
deleted file mode 100644
index ef2ba7c..0000000
--- a/cogs/ai.py
+++ /dev/null
@@ -1,78 +0,0 @@
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 discord
8from discord.ext import commands
9import logging
10import httpx
11from gtts import gTTS
12import io
13
14logger = logging.getLogger(__name__)
15
16class AI(commands.Cog):
17 def __init__(self, bot):
18 self.bot = bot
19
20 @commands.slash_command(
21 name="senator",
22 description="SEENATOR",
23 )
24 async def senator(
25 self,
26 ctx: discord.ApplicationContext,
27 text: str = discord.Option(str, "message", required=True),
28 tts: str = discord.Option(bool, "face?", default=False)
29 ):
30 await ctx.defer()
31
32
33 url = "https://openrouter.ai/api/v1/chat/completions"
34 headers = {
35 "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
36 "Content-Type": "application/json",
37 }
38 payload = {
39 "model": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
40 "messages": [
41 {
42 "role": "system",
43 "content": "You are Senator Steaven Sperma Armstrong from Metal Gear Rising Vengeance. \
44 You are racist and you hate other minorities. Respond in plain text, no markdown tricks. \
45 If the message is in Polish, respond in Polish. Respond in 100 words."
46 },
47 {
48 "role": "user",
49 "content": text
50 }
51 ],
52
53 }
54 async with httpx.AsyncClient() as client:
55 response = await client.post(url=url, headers=headers, json=payload)
56 response_data = response.json()
57 logger.info(response_data)
58 if not response_data["choices"]:
59 await ctx.send_followup("bot siem zesroł")
60 else:
61 response_text = response_data["choices"][0]["message"]["content"]
62 if tts:
63 tts_obj = gTTS(text=response_text, lang='pl')
64 tts_buffer = io.BytesIO()
65 tts_obj.write_to_fp(tts_buffer)
66 tts_buffer.seek(0)
67 from cogs.voice import get_voice_manager
68 vm = get_voice_manager(self.bot)
69 channel = ctx.author.voice.channel
70 await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False)
71
72 else:
73 await ctx.send_followup(response_text)
74
75
76
77def setup(bot):
78 bot.add_cog(AI(bot))
diff --git a/cogs/assistant.py b/cogs/assistant.py
deleted file mode 100644
index fed7825..0000000
--- a/cogs/assistant.py
+++ /dev/null
@@ -1,130 +0,0 @@
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 discord
7from discord.ext import commands
8import httpx
9import logging
10import os
11
12logger = logging.getLogger(__name__)
13
14class Jarvis(commands.Cog):
15 def __init__(self, bot):
16 self.bot = bot
17 self.keyword = os.getenv("ASSISTANT_KEYWORD", "jarvis").lower()
18
19 @commands.Cog.listener()
20 async def on_message(self, message):
21 if message.author == self.bot.user:
22 return
23
24 logger.info(message.content)
25
26 if message.mentions:
27 if message.mentions[0].id == self.bot.user.id:
28 await self.assistant(message)
29
30 elif self.keyword in message.content.lower():
31 await self.assistant(message)
32
33 @staticmethod
34 async def assistant(message):
35 headers = {
36 "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
37 "Content-Type": "application/json"
38 }
39
40 chain = await Jarvis._build_message_chain(message)
41 messages = Jarvis._build_messages(message, chain)
42 payload = Jarvis._build_payload(messages)
43
44 response_data = await Jarvis._safe_api_call(payload, headers)
45 if response_data is None:
46 await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.")
47 return
48
49 response_message = Jarvis._parse_response(response_data)
50
51 channel = message.channel
52 await message.reply(response_message)
53
54 @staticmethod
55 async def _build_message_chain(message):
56 chain = []
57 current = message
58 max_depth = 5
59
60 while current and len(chain) < max_depth:
61 chain.append(current)
62 if not current.reference:
63 break
64 try:
65 if current.reference.resolved:
66 current = current.reference.resolved
67 else:
68 current = await current.channel.fetch_message(current.reference.message_id)
69 except discord.NotFound:
70 break
71
72 return list(reversed(chain))
73
74 @staticmethod
75 def _build_messages(message, chain=None):
76 messages = [{
77 "role": "system",
78 "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis."
79 "Twoim zadaniem jest asystowanie użytkownika"
80 "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon"
81 "nie pytaj sie użytkownika czy chce abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał"
82 "Nie pozostawiaj wiadomości pustej oraz nie dawaj swojej wiadomości w pole myślenia"
83 }]
84
85 if chain is None:
86 chain = [message]
87
88 for msg in chain:
89 messages.append({
90 "role": "user",
91 "content": f"USER: {msg.author.display_name}\nMESSAGE: {msg.content}"
92 })
93
94 return messages
95
96 @staticmethod
97 def _build_payload(messages):
98 return {
99 "model": os.getenv("OPENROUTER_MODEL"),
100 "messages": messages,
101 "plugins": [{"id": "response-healing"}]
102 }
103
104 @staticmethod
105 async def _make_api_request(payload, headers):
106 async with httpx.AsyncClient(timeout=30.0) as client:
107 url = "https://openrouter.ai/api/v1/chat/completions"
108 response = await client.post(url=url, headers=headers, json=payload)
109 response_data = response.json()
110 logger.info(response_data)
111 return response_data
112
113 @staticmethod
114 async def _safe_api_call(payload, headers):
115 try:
116 return await Jarvis._make_api_request(payload, headers)
117 except httpx.HTTPError as e:
118 logger.error(f"API request failed: {e}")
119 return None
120
121 @staticmethod
122 def _parse_response(response_data):
123 content = response_data['choices'][0]['message']['content']
124
125 logger.info(content)
126
127 return content
128
129def setup(bot):
130 bot.add_cog(Jarvis(bot))
diff --git a/cogs/compression.py b/cogs/compression.py
deleted file mode 100644
index 71348dc..0000000
--- a/cogs/compression.py
+++ /dev/null
@@ -1,306 +0,0 @@
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 mimetypes
7import os
8import asyncio
9import aiofiles
10import requests
11import urllib.parse
12from typing import List, Optional
13from os import PathLike
14import discord
15from discord.ext import commands
16from discord import AutocompleteContext
17from cogs.utils import file_search as search_utils
18import shlex
19import logging
20
21logger = logging.getLogger(__name__)
22
23def filetype(filename):
24 mime_type, _ = mimetypes.guess_type(filename)
25 if mime_type:
26 if mime_type.startswith('video/'):
27 return "Video"
28 elif mime_type.startswith('audio/'):
29 return "Audio"
30 elif mime_type.startswith('image/'):
31 return "Image"
32 return "Unknown"
33
34class Compression(commands.Cog):
35 def __init__(self, bot):
36 from cogs.utils.temp import get_temp_dir
37 from cogs.utils.settings import get_baseurl
38 self.bot = bot
39 self.temp_dir = get_temp_dir()
40 self._cached_urls = {}
41 self._baseurl = 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 filename = urllib.parse.unquote(filename)
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
74
75 async def _upload_to_copyparty(self, file_path: str) -> Optional[str]:
76 """Upload file to copyparty and return download URL"""
77 try:
78 async with aiofiles.open(file_path, 'rb') as f:
79 file_content = await f.read()
80
81 files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')}
82 headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'}
83 params = {'j': '1', 'ck': 'b2'}
84
85 response = requests.post(self._baseurl, files=files, headers=headers, params=params)
86
87 if response.status_code in [200, 201]:
88 result = response.json()
89 return result.get('fileurl')
90 except Exception as e:
91 logger.error(f"Upload error: {e}")
92 return None
93
94 def _process_video(self, file_path: str, output_path: str) -> str:
95 """Process video compression"""
96 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)}"
97 return command
98
99 def _process_audio(self, file_path: str, output_path: str) -> str:
100 """Process audio compression"""
101 if output_path.lower().endswith('.mp3'):
102 output_path = os.path.splitext(output_path)[0] + os.path.splitext(output_path)[1].replace('.mp3', '')
103 if not output_path.lower().endswith('.ogg'):
104 output_path = os.path.splitext(output_path)[0] + '.ogg'
105 command = f"ffmpeg -y -i {shlex.quote(file_path)} -c:a libopus -b:a 500 -ar 12000 -ac 1 {shlex.quote(output_path)}"
106 return command
107
108 def _process_image(self, file_path: str, output_path: str) -> tuple[str, str]:
109 """Process image compression"""
110 ext = os.path.splitext(file_path)[1].lower()
111 if ext in [".jpg", ".jpeg"]:
112 command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}"
113 elif ext == ".gif":
114 command = f"magick {shlex.quote(file_path)} -coalesce -resize 30% -colors 32 -strip -layers Optimize {shlex.quote(output_path)}"
115 else:
116 output_path = os.path.splitext(output_path)[0] + ".jpg"
117 command = f"magick {shlex.quote(file_path)} -strip -resize 30% -quality 5 {shlex.quote(output_path)}"
118 return command, output_path
119
120 async def _execute_compression(self, command: str) -> bool:
121 """Execute compression command"""
122 proc = await asyncio.create_subprocess_exec(
123 *shlex.split(command),
124 stdout=asyncio.subprocess.PIPE,
125 stderr=asyncio.subprocess.PIPE
126 )
127 await proc.wait()
128 return proc.returncode == 0
129
130 async def _compress_attachment(self, attachment: discord.Attachment) -> Optional[discord.File]:
131 """Compress a single attachment and return the compressed file"""
132 try:
133 file_path: str = os.path.join(self.temp_dir, attachment.filename)
134 output_path = os.path.join(self.temp_dir, f"better_{attachment.filename}")
135
136 # Save the original file
137 await attachment.save(file_path)
138
139 # Process the file
140 file_type = filetype(file_path)
141 logger.info(f"Compression started for: {file_type}")
142
143 command = self._get_compression_command(file_type, file_path, output_path)
144 if command is None:
145 logger.info(f"Unsupported file type: {file_type}")
146 os.remove(file_path)
147 return None
148
149 # Update output_path if it was modified
150 if file_type == "Image":
151 command, output_path = command
152 elif file_type == "Audio":
153 # output_path is already modified in _process_audio
154 pass
155
156 # Run the compression
157 success = await self._execute_compression(command)
158
159 if success and os.path.exists(output_path):
160 logger.info(f"Successfully compressed: {attachment.filename}")
161 return discord.File(output_path, filename=f"compressed_{attachment.filename}")
162 return None
163
164 except Exception as e:
165 logger.error(f"Error processing {attachment.filename}: {str(e)}")
166 return None
167
168 finally:
169 # Clean up temporary files
170 if 'file_path' in locals() and os.path.exists(file_path):
171 os.remove(file_path)
172
173 def _get_compression_command(self, file_type: str, file_path: str, output_path: str):
174 """Get the appropriate compression command based on file type"""
175 if file_type == "Video":
176 return self._process_video(file_path, output_path)
177 elif file_type == "Audio":
178 return self._process_audio(file_path, output_path)
179 elif file_type == "Image":
180 return self._process_image(file_path, output_path)
181 else:
182 return None
183
184 async def _compress_attachments(self, attachments: List[discord.Attachment]) -> List[discord.File]:
185 """Compress multiple attachments and return list of compressed files"""
186 compressed_files = []
187 for attachment in attachments:
188 compressed_file = await self._compress_attachment(attachment)
189 if compressed_file:
190 compressed_files.append(compressed_file)
191 return compressed_files
192
193 @commands.message_command(
194 name="kompresja",
195 description="skompresuj wideło albo dźwienk :)",
196 )
197 async def message_compression(self, ctx, message: discord.Message):
198 if not message.attachments:
199 await ctx.respond("Brak załączników w wiadomości", ephemeral=True)
200 return
201
202 await ctx.defer()
203 compressed_files = await self._compress_attachments(message.attachments)
204
205 # Send all compressed files in one message
206 if compressed_files:
207 await ctx.respond(
208 f"Skompresowano {len(compressed_files)} z {len(message.attachments)} plików:",
209 files=compressed_files
210 )
211
212 # Clean up compressed files
213 for file in compressed_files:
214 if os.path.exists(file.fp.name): # type: ignore[attr-defined]
215 os.remove(file.fp.name) # type: ignore[attr-defined]
216 else:
217 await ctx.respond("Nie udało się skompresować żadnych plików.", ephemeral=True)
218
219 @commands.slash_command(
220 name="kompresja",
221 description="skompresuj wideło albo dźwienk :)",
222 )
223 @discord.option(
224 name="plik",
225 description="plik z copyparty",
226 required=False,
227 autocomplete=_file_autocomplete
228 )
229 @discord.option(
230 name="zalacznik",
231 description="plik z discorda",
232 required=False,
233 type=discord.Attachment
234 )
235 @discord.option(
236 name="serwer?",
237 description="wysłać plik na serwer?",
238 required=False,
239 default=False,
240 type=bool
241 )
242 async def compression(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server = False):
243 await ctx.defer()
244
245 file_path = None
246 original_filename = None
247
248 if plik:
249 url = self._cached_urls.get(ctx.author.id, {}).get(plik)
250 if not url:
251 await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True)
252 return
253 file_path = await self._download_from_url(url)
254 original_filename = plik
255 elif zalacznik:
256 file_path = os.path.join(self.temp_dir, zalacznik.filename)
257 original_filename = zalacznik.filename
258 await zalacznik.save(file_path)
259 else:
260 await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True)
261 return
262
263 if not file_path or not os.path.exists(file_path):
264 await ctx.respond("Nie udało się pobrać pliku", ephemeral=True)
265 return
266
267 try:
268 file_type = filetype(file_path)
269 output_path = os.path.join(self.temp_dir, f"better_{original_filename}")
270
271 command = self._get_compression_command(file_type, file_path, output_path)
272 if command is None:
273 await ctx.respond("Nieobsługiwany typ pliku", ephemeral=True)
274 return
275
276 if file_type == "Image":
277 command, output_path = command
278 elif file_type == "Audio":
279 # Audio output path remains unchanged - already processed in _process_audio
280 pass
281
282 success = await self._execute_compression(command)
283
284 if not success or not os.path.exists(output_path):
285 await ctx.respond("Kompresja nie powiodła się", ephemeral=True)
286 return
287
288 file_size = os.path.getsize(output_path)
289 if file_size > 1024 * 1024 * 10 or server:
290 dl_url = await self._upload_to_copyparty(output_path)
291 if dl_url:
292 await ctx.respond(f"Skompresowano\n{dl_url}")
293 else:
294 await ctx.respond("Nie udało wysłać na serwer", ephemeral=True)
295 else:
296 await ctx.respond(file=discord.File(output_path, filename=f"compressed_{original_filename}"))
297
298 finally:
299 if file_path and os.path.exists(file_path):
300 os.remove(file_path)
301 if 'output_path' in locals() and os.path.exists(output_path):
302 os.remove(output_path)
303
304
305def setup(bot):
306 bot.add_cog(Compression(bot))
diff --git a/cogs/conversion.py b/cogs/conversion.py
deleted file mode 100644
index f9e4c84..0000000
--- a/cogs/conversion.py
+++ /dev/null
@@ -1,245 +0,0 @@
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 discord
7import requests
8from discord.ext import commands
9import logging
10import os
11import asyncio
12import aiofiles
13import mimetypes
14import subprocess
15import urllib.parse
16import shlex
17from typing import Optional
18from discord import AutocompleteContext
19from cogs.utils import file_search as search_utils
20
21logger = logging.getLogger(__name__)
22
23def filetype(filename):
24 mime_type, _ = mimetypes.guess_type(filename)
25 if mime_type:
26 if mime_type.startswith('video/'):
27 return "Video"
28 elif mime_type.startswith('audio/'):
29 return "Audio"
30 elif mime_type.startswith('image/'):
31 return "Image"
32 return "Unknown"
33
34class Conversion(commands.Cog):
35 def __init__(self, bot):
36 self.bot = bot
37 from cogs.utils.temp import get_temp_dir
38 from cogs.utils.settings import get_baseurl, get_uploadurl
39 self.temp_dir = get_temp_dir()
40 self._cached_urls = {}
41 self._baseurl = get_baseurl()
42 self._uploadurl = get_uploadurl()
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 filename = urllib.parse.unquote(filename)
67 file_path = os.path.join(self.temp_dir, filename)
68
69 async with aiofiles.open(file_path, 'wb') as f:
70 await f.write(response.content)
71 return file_path
72 except Exception as e:
73 logger.error(f"Error downloading from {url}: {e}")
74 return None
75
76 async def _setup_file_paths(self, plik, output_format):
77 """Setup input and output file paths"""
78 from cogs.utils.temp import get_temp_dir
79
80 temp_dir = get_temp_dir()
81 file_path = os.path.join(temp_dir, plik.filename)
82 filename_noext = os.path.splitext(plik.filename)[0]
83 await plik.save(file_path)
84
85 input_path = file_path
86 output_path = os.path.join(temp_dir, f"{filename_noext}.{output_format}")
87 return input_path, output_path
88
89 def _build_conversion_command(self, file_type, input_path, output_path, args):
90 """Build the appropriate conversion command based on file type"""
91 if file_type in ["Video", "Audio"]:
92 return f"ffmpeg -i {shlex.quote(input_path)} {args} {shlex.quote(output_path)}"
93 elif file_type == "Image":
94 return f"magick {shlex.quote(input_path)} {args} {shlex.quote(output_path)}"
95 else:
96 return None
97
98 async def _upload_to_copyparty(self, output_path):
99 """Upload file to copyparty and return download URL"""
100 try:
101 async with aiofiles.open(output_path, 'rb') as f:
102 file_content = await f.read()
103
104 files = {
105 'f': (os.path.basename(output_path), file_content, 'application/octet-stream')
106 }
107 headers = {
108 'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'
109 }
110 params = {
111 'j': '1',
112 'ck': 'b2'
113 }
114
115 response = requests.post(self._uploadurl, files=files, headers=headers, params=params)
116
117 if response.status_code in [200, 201]:
118 result = response.json()
119 if 'fileurl' in result:
120 return result['fileurl']
121 else:
122 raise ValueError(f"Server response missing fileurl: {result}")
123 else:
124 error_msg = response.text if response.text else 'Unknown error'
125 raise requests.RequestException(f"HTTP {response.status_code}: {error_msg}")
126
127 except Exception as e:
128 logger.error(f"copyparty upload error: {e}", exc_info=True)
129 raise
130
131 async def _handle_file_output(self, ctx, output_path):
132 """Handle file output based on size"""
133 if os.path.getsize(output_path) > 1024*1024*10:
134 try:
135 dl_url = await self._upload_to_copyparty(output_path)
136 await ctx.followup.send(f"wysyłon\n{dl_url}\n")
137 except Exception as upload_error:
138 await ctx.followup.send(f"copyparty mnie nienawidzi :cccc\n {str(upload_error)}")
139 else:
140 await ctx.respond(file=discord.File(output_path))
141
142 def _cleanup_files(self, *file_paths):
143 """Clean up temporary files"""
144 for path in file_paths:
145 if os.path.exists(path):
146 os.remove(path)
147
148 @commands.slash_command(name="konwersja", description="konwertuj plik na inny format")
149 @discord.option(
150 name="plik",
151 description="plik z copyparty",
152 required=False,
153 autocomplete=_file_autocomplete
154 )
155 @discord.option(
156 name="zalacznik",
157 description="plik z discorda",
158 required=False,
159 type=discord.Attachment
160 )
161 @discord.option(
162 name="serwer?",
163 description="wysłać plik na serwer?",
164 required=False,
165 type=bool,
166 default=False
167 )
168 @discord.option(
169 name="format",
170 description="format konwersji",
171 required=True,
172 type=str
173 )
174 async def convert(self, ctx, plik: str = None, zalacznik: discord.Attachment = None, server: bool = False, output_format: str = None):
175 await ctx.defer()
176
177 input_path = None
178 original_filename = None
179
180 try:
181 if plik:
182 url = self._cached_urls.get(ctx.author.id, {}).get(plik)
183 if not url:
184 await ctx.respond("Wybierz plik z podpowiedzi", ephemeral=True)
185 return
186 input_path = await self._download_from_url(url)
187 original_filename = plik
188 elif zalacznik:
189 input_path = os.path.join(self.temp_dir, zalacznik.filename)
190 original_filename = zalacznik.filename
191 await zalacznik.save(input_path)
192 else:
193 await ctx.respond("Podaj plik z copyparty lub załącznik", ephemeral=True)
194 return
195
196 if not input_path or not os.path.exists(input_path):
197 await ctx.respond("Nie udało się pobrać pliku", ephemeral=True)
198 return
199
200 output_path = os.path.join(self.temp_dir, f"{os.path.splitext(original_filename)[0]}.{output_format}")
201
202 file_type = filetype(input_path)
203 logger.info(f"Conversion started for: {file_type}")
204
205 command = self._build_conversion_command(file_type, input_path, output_path, "")
206 logger.info(f"Running: {command}")
207 if not command:
208 await ctx.respond("Plik jest nieobsługiwany", ephemeral=True)
209 return
210
211 proc = await asyncio.create_subprocess_exec(
212 *shlex.split(command),
213 stdout=asyncio.subprocess.PIPE,
214 stderr=asyncio.subprocess.PIPE
215 )
216 stdout, stderr = await proc.communicate()
217
218 if proc.returncode != 0:
219 logger.error(f"FFmpeg error: {stderr.decode() if stderr else 'unknown'}")
220 await ctx.respond(f"Błąd konwersji: {stderr.decode() if stderr else 'unknown'}", ephemeral=True)
221 return
222
223 if os.path.getsize(output_path) > 1024 * 1024 * 10 or server:
224 dl_url = await self._upload_to_copyparty(output_path)
225 if dl_url:
226 await ctx.respond(f"Skonwertowano\n{dl_url}")
227 else:
228 await ctx.respond("Nie udało się wysłać na serwer", ephemeral=True)
229 else:
230 await ctx.respond(file=discord.File(output_path, filename=f"{original_filename}"))
231
232 logger.info(f"Conversion completed for: {file_type}")
233
234 except subprocess.CalledProcessError as e:
235 await ctx.respond(f"Conversion failed: {str(e)}", ephemeral=True)
236 except Exception as e:
237 await ctx.respond(f"An error occurred: {str(e)}", ephemeral=True)
238 finally:
239 if input_path and os.path.exists(input_path):
240 os.remove(input_path)
241 if 'output_path' in locals() and os.path.exists(output_path):
242 os.remove(output_path)
243
244def setup(bot):
245 bot.add_cog(Conversion(bot))
diff --git a/cogs/cron.py b/cogs/cron.py
deleted file mode 100644
index ee88085..0000000
--- a/cogs/cron.py
+++ /dev/null
@@ -1,403 +0,0 @@
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 asyncio
7import logging
8from datetime import datetime
9from venv import logger
10
11import discord
12from croniter import croniter
13from discord.ext import commands
14
15from cogs.utils import file_search as search_utils
16from cogs.voice import get_voice_manager
17from cogs.utils.database import get_connection, init_database
18
19logger = logging.getLogger(__name__)
20
21init_database()
22
23
24class Cron(commands.Cog):
25 def __init__(self, bot):
26 self.bot = bot
27 self.scheduled_tasks = []
28 self.load_tasks()
29 self.task = bot.loop.create_task(self.schedule_checker())
30
31 def load_tasks(self):
32 try:
33 with get_connection() as conn:
34 c = conn.cursor()
35 c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs")
36 rows = c.fetchall()
37
38 self.scheduled_tasks = [
39 {
40 "id": row[0],
41 "cron_expression": row[1],
42 "type": row[2],
43 "channel_id": row[3],
44 "active": bool(row[4]),
45 "audio_filename": row[5],
46 "message": row[6],
47 }
48 for row in rows
49 ]
50 logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database")
51 except Exception as e:
52 logger.error(f"Error loading cron jobs: {e}")
53 self.scheduled_tasks = []
54
55 def save_tasks(self):
56 try:
57 with get_connection() as conn:
58 c = conn.cursor()
59 c.execute("PRAGMA foreign_keys = ON")
60
61 c.execute("DELETE FROM cron_jobs")
62
63 for task in self.scheduled_tasks:
64 c.execute(
65 """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message)
66 VALUES (?, ?, ?, ?, ?, ?, ?)""",
67 (
68 task.get("id"),
69 task["cron_expression"],
70 task["type"],
71 task["channel_id"],
72 int(task.get("active", True)),
73 task.get("audio_filename"),
74 task.get("message"),
75 ),
76 )
77 conn.commit()
78 except Exception as e:
79 logger.error(f"Error saving cron jobs: {e}")
80
81 async def schedule_checker(self):
82 """Background task to check and execute scheduled jobs"""
83 await self.bot.wait_until_ready()
84 while not self.bot.is_closed():
85 try:
86 now = datetime.now()
87 for task in self.scheduled_tasks:
88 if not task.get("active", True):
89 continue
90 if self.should_run_task(task, now):
91 await self.execute_task(task)
92 await asyncio.sleep(30)
93 except Exception as e:
94 logger.error(f"Error in schedule checker: {e}")
95 await asyncio.sleep(30)
96
97 def should_run_task(self, task, now):
98 """Check if a task should run now based on its cron expression"""
99 try:
100 cron_expr = task["cron_expression"]
101 cron = croniter(cron_expr, now)
102 prev_time = cron.get_prev(datetime)
103 time_diff = abs((now - prev_time).total_seconds())
104 result = time_diff <= 30
105 logger.debug(
106 f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}"
107 )
108 return result
109 except Exception as e:
110 logger.error(
111 f"Error checking cron expression {task.get('cron_expression')}: {e}"
112 )
113 return False
114
115 async def execute_task(self, task):
116 """Execute a scheduled task"""
117 try:
118 task_type = task["type"]
119 channel_id = task["channel_id"]
120
121 channel = self.bot.get_channel(channel_id)
122 if not channel:
123 logger.warning(f"Channel {channel_id} not found for task ID: {task.get('id')}")
124 return
125
126 logger.info(f"Found channel {channel} (type: {type(channel).__name__})")
127
128 perms = channel.permissions_for(channel.guild.me)
129 logger.debug(
130 f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}"
131 )
132
133 if task_type in ("zagraj_audio", "audio"):
134 await self.play_audio_task(task, channel)
135 elif task_type in ("wyślij_wiadomość", "send_message", "wiadomość"):
136 await self.send_message_task(task, channel)
137
138 logger.info(f"Executed task {task['id']} of type {task_type}")
139 except Exception as e:
140 logger.error(f"Error executing task {task.get('id')}: {e}")
141
142 async def play_audio_task(self, task, channel):
143 """Play audio task implementation using voice manager"""
144 audio_url = task["audio_filename"]
145
146 if isinstance(channel, discord.VoiceChannel):
147 voice_mgr = get_voice_manager(self.bot)
148
149 try:
150 audio_source = discord.FFmpegPCMAudio(audio_url)
151 success = await voice_mgr.play_source(channel, audio_source)
152
153 if success:
154 logger.info(f"Finished playing audio: {audio_url}")
155 else:
156 raise Exception("Playback failed")
157
158 await voice_mgr.disconnect(channel.guild)
159 except Exception as e:
160 logger.error(f"Error playing audio: {e}")
161 try:
162 await voice_mgr.disconnect(channel.guild)
163 except Exception:
164 pass
165 if channel.guild.system_channel:
166 await channel.guild.system_channel.send(
167 f"Błąd podczas odgrywania audio: {audio_url}"
168 )
169 else:
170 if hasattr(channel, "send"):
171 await channel.send("Odtwarzanie audio wymaga kanału głosowego")
172
173 async def send_message_task(self, task, channel):
174 """Send message task implementation"""
175 message = task["message"]
176 logger.info(f"Message to send: '{message}' | Channel: {channel.id}")
177 await channel.send(message)
178 logger.debug("Message sent successfully")
179
180 async def audio_autocomplete(self, ctx: discord.AutocompleteContext):
181 """Autocomplete for audio files from file_search"""
182 query = ctx.value.lower() if ctx.value else ""
183 if not query:
184 return ""
185
186 audio_extensions = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "mp4", "mkv"]
187 results = await search_utils.UniversalSearcher.search_copyparty(
188 query, max_results=25, file_extensions=audio_extensions
189 )
190
191 choices = []
192 for url in results[:25]:
193 filename = url.split("/")[-1]
194 display_name = filename[:98] + "…" if len(filename) > 100 else filename
195 choices.append(discord.OptionChoice(name=display_name, value=url))
196
197 return (
198 choices
199 if choices
200 else [discord.OptionChoice(name="Brak wyników", value="")]
201 )
202
203 def _get_baseurl(self):
204 """Get base URL from settings"""
205 try:
206 from cogs.utils.settings import get_baseurl
207
208 return get_baseurl()
209 except Exception:
210 return ""
211
212 trigger_group = discord.SlashCommandGroup("cron", "komendy cron")
213
214 @trigger_group.command(
215 name="dodaj_audio", description="Dodaj nowe zadanie cron audio"
216 )
217 async def cron_add_audio(
218 self,
219 ctx,
220 cron_expression: str = discord.Option(
221 str,
222 "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)",
223 required=True,
224 ),
225 channel: discord.VoiceChannel = discord.Option(
226 discord.VoiceChannel,
227 "Kanał głosowy na którym będzie wykonywana akcja",
228 required=True,
229 ),
230 audio_filename: str = discord.Option(
231 str,
232 "Plik audio z serwera copyparty",
233 required=True,
234 autocomplete=audio_autocomplete,
235 ),
236 ):
237 try:
238 croniter(cron_expression)
239 except Exception:
240 await ctx.respond(
241 "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc",
242 ephemeral=True,
243 )
244 return
245
246 task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1
247
248 task = {
249 "id": task_id,
250 "cron_expression": cron_expression,
251 "type": "audio",
252 "channel_id": channel.id,
253 "active": True,
254 }
255
256 task["audio_filename"] = audio_filename
257
258 self.scheduled_tasks.append(task)
259 self.save_tasks()
260
261 await ctx.respond(
262 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>"
263 )
264
265 @trigger_group.command(
266 name="dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość"
267 )
268 async def cron_add_message(
269 self,
270 ctx,
271 cron_expression: str = discord.Option(
272 str,
273 "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)",
274 required=True,
275 ),
276 channel: discord.TextChannel = discord.Option(
277 discord.TextChannel, "Kanał na którym będzie wykonana akcja", required=True
278 ),
279 message: str = discord.Option(str, "Wiadomość do wysłania", required=True),
280 ):
281 """Add a new cron job"""
282
283 try:
284 croniter(cron_expression)
285 except Exception:
286 await ctx.respond(
287 "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc",
288 ephemeral=True,
289 )
290 return
291
292 task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1
293
294 task = {
295 "id": task_id,
296 "cron_expression": cron_expression,
297 "type": "wiadomość",
298 "channel_id": channel.id,
299 "active": True,
300 "message": message,
301 }
302
303 self.scheduled_tasks.append(task)
304 self.save_tasks()
305
306 await ctx.respond(
307 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>"
308 )
309
310 @trigger_group.command(
311 name="pokaż", description="Pokaż wszystkie zadania cron"
312 )
313 async def cron_list(self, ctx):
314 """List all cron jobs"""
315 if not self.scheduled_tasks:
316 await ctx.respond("Brak zadań cron")
317 return
318
319 embed = discord.Embed(title="Zaplanowane prace cron", color=0x00FF00)
320
321 for task in self.scheduled_tasks:
322 channel = self.bot.get_channel(task["channel_id"])
323 channel_name = f"#{channel}"
324 status = "AKTYWNA" if task.get("active", True) else "PAUZA"
325
326 if task["type"] in ("zagraj_audio", "audio"):
327 action_desc = f"Zagraj audio: `{task.get('audio_filename', 'N/A')}`"
328 else:
329 msg = task.get("message", "")
330 action_desc = (
331 f"Wyślij wiadomość: `{msg[:50]}{'...' if len(msg) > 50 else ''}`"
332 )
333
334 embed.add_field(
335 name=f"Zadanie #{task['id']} ({status})",
336 value=f"**Czas:** `{task['cron_expression']}`\n**Akcja:** {action_desc}\n**Kanał:** {channel_name}",
337 inline=False,
338 )
339
340 await ctx.respond(embed=embed)
341
342 @trigger_group.command(name="usuń", description="Usuń zadanie cron z ID")
343 async def cron_remove(
344 self,
345 ctx,
346 job_id: int = discord.Option(
347 int, "ID zadania cron do usunięcia", required=True
348 ),
349 ):
350 """Remove a cron job by ID"""
351 for i, task in enumerate(self.scheduled_tasks):
352 if task["id"] == job_id:
353 self.scheduled_tasks.pop(i)
354 self.save_tasks()
355 await ctx.respond(f"Usunięto zadanie cron #{job_id}")
356 return
357
358 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
359
360 @trigger_group.command(name="pauza", description="Zapauzuj zadanie cron z ID")
361 async def cron_pause(
362 self,
363 ctx,
364 job_id: int = discord.Option(
365 int, "ID zadania cron do zatrzymania", required=True
366 ),
367 ):
368 """Pause a cron job by ID"""
369 for task in self.scheduled_tasks:
370 if task["id"] == job_id:
371 task["active"] = False
372 self.save_tasks()
373 await ctx.respond(f"Zapauzowano zadanie cron #{job_id}")
374 return
375
376 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
377
378 @trigger_group.command(name="wznów", description="Wznów zadanie cron z ID")
379 async def cron_resume(
380 self,
381 ctx,
382 job_id: int = discord.Option(
383 int, "ID zadania cron do wznowienia", required=True
384 ),
385 ):
386 """Resume a paused cron job by ID"""
387 for task in self.scheduled_tasks:
388 if task["id"] == job_id:
389 task["active"] = True
390 self.save_tasks()
391 await ctx.respond(f"Wznowiono zadanie cron z ID #{job_id}")
392 return
393
394 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
395
396 def cog_unload(self):
397 """Clean up when cog is unloaded"""
398 if hasattr(self, "task"):
399 self.task.cancel()
400
401
402def setup(bot):
403 bot.add_cog(Cron(bot))
diff --git a/cogs/downloader.py b/cogs/downloader.py
deleted file mode 100644
index 900bafb..0000000
--- a/cogs/downloader.py
+++ /dev/null
@@ -1,234 +0,0 @@
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
8import logging
9import requests
10import discord
11import re
12import yt_dlp
13from discord.ext import commands
14import aiofiles
15from cogs.utils.url_helper import clean_url_for_ytdlp
16
17logger = logging.getLogger(__name__)
18
19
20class Downloader(commands.Cog):
21 def __init__(self, bot):
22 self.bot = bot
23
24 URL_PATTERN = re.compile(
25 r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*'
26 )
27
28 @commands.message_command(
29 name="download",
30 description="Download and convert video/audio from internet"
31 )
32 async def message_download(self, ctx, message: discord.Message):
33 # check if message has a valid url
34 url = [m.group(0) for m in self.URL_PATTERN.finditer(message.content)]
35 if url:
36 for url in url:
37 await self.download(ctx=ctx, url=url, fformat="mp4")
38 else:
39 await ctx.respond("Brak załączników w wiadomości", ephemeral=True)
40
41 @commands.slash_command(
42 name="download",
43 description="Download and convert video/audio from internet"
44 )
45 async def download(
46 self,
47 ctx: discord.ApplicationContext,
48 url: str = discord.Option(str, description="Link media", required=True),
49 catbox: bool = discord.Option(bool, description="wysłać na serwer??", required=False, default=False),
50 fformat: str = discord.Option(str, description="jaki format? (mp4, mp3, wav, etc.)", required=False, default="mp4"),
51 filename_user: str = discord.Option(str, description="jaka nazwa pliku?", required=False)
52 ):
53 """Download and convert media from a URL to the specified format"""
54 await ctx.defer()
55 # Call the function and get the response
56 response = await self.download_standalone(url=url, catbox=catbox, fformat=fformat, filename_user=filename_user)
57 if isinstance(response, dict):
58 if response["status"] == "success":
59 if response["type"] == "url":
60 await ctx.respond(f"pobioron i wysyłon: {response['content']}")
61 elif response["type"] == "file":
62 file_data = response["content"]
63 await ctx.respond(file=discord.File(file_data))
64 else:
65 error_msg = response["content"]
66 if 'details' in response:
67 error_msg += f"\n{response['details']}"
68 await ctx.respond(f"Błąd: {error_msg}", ephemeral=True)
69 logger.error(f"Error: {response['content']}")
70 if 'details' in response:
71 logger.error(f"Details: {response['details']}")
72
73 @staticmethod
74 async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None):
75 try:
76 url = clean_url_for_ytdlp(url)
77 logger.info(f"Downloading {url} as {fformat}")
78
79 with tempfile.TemporaryDirectory(delete=False) as temp_dir:
80 ydl_opts = Downloader._get_ydl_options(filename_user, fformat, temp_dir)
81
82 with yt_dlp.YoutubeDL(ydl_opts) as ydl:
83 ydl.download([url])
84
85 actual_file, filename = Downloader._find_and_prepare_file(temp_dir, filename_user)
86
87 if Downloader._should_upload_to_server(actual_file, catbox):
88 return await Downloader._upload_to_server(actual_file, filename, temp_dir)
89 else:
90 return Downloader._return_file_response(actual_file, filename, temp_dir)
91
92 except Exception as e:
93 return Downloader._create_error_response("Download failed", str(e), None)
94
95 @staticmethod
96 def _sanitize_filename(name: str) -> str:
97 invalid = '<>:"/\\|?*'
98 for c in invalid:
99 name = name.replace(c, '')
100 return name.strip()
101
102 @staticmethod
103 def _get_ydl_options(filename: str = None, fformat: str = None, temp_dir: str = None) -> dict:
104 if filename:
105 filename = Downloader._sanitize_filename(filename)[:100]
106 ydl_opts = {
107 'outtmpl': os.path.join(temp_dir, filename if filename is not None else '%(title).100s.%(ext)s'),
108 'format': 'bestaudio/best' if fformat in ['mp3', 'wav', 'm4a'] else 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
109 'merge_output_format': 'mp4',
110 'postprocessors': [],
111 'remote-components': 'ejs:github',
112 'js-runtimes': {'deno': {'path': '/usr/bin/deno'}},
113 }
114
115 if fformat in ['mp3', 'wav', 'm4a']:
116 ydl_opts['postprocessors'].append({
117 'key': 'FFmpegExtractAudio',
118 'preferredcodec': fformat,
119 'preferredquality': '192',
120 })
121 elif fformat != 'mp4':
122 ydl_opts['postprocessors'].append({
123 'key': 'FFmpegVideoConvertor',
124 'preferedformat': fformat,
125 })
126
127 return ydl_opts
128
129 @staticmethod
130 def _find_and_prepare_file(temp_dir: str, filename_user: str) -> tuple:
131 files = [f for f in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, f))]
132 if not files:
133 raise FileNotFoundError("No file was downloaded")
134
135 actual_file = os.path.join(temp_dir, files[0])
136 extension = actual_file.split('.')[-1]
137 extension = Downloader._sanitize_filename(extension)
138
139 if not filename_user:
140 filename = os.path.basename(actual_file)
141 filename = Downloader._sanitize_filename(filename)
142 if len(filename) > 100:
143 filename = filename[:100]
144 else:
145 filename_user = Downloader._sanitize_filename(filename_user)[:100]
146 filename = filename_user + '.' + extension
147 actual_file = os.path.join(temp_dir, filename)
148
149 if not os.path.exists(actual_file):
150 raise FileNotFoundError(f"Downloaded file not found: {actual_file}")
151
152 return actual_file, filename
153
154 @staticmethod
155 def _should_upload_to_server(actual_file: str, catbox: bool) -> bool:
156 return os.path.getsize(actual_file) > 1024*1024*10 or catbox
157
158 @staticmethod
159 async def _upload_to_server(actual_file: str, filename: str, temp_dir: str) -> dict:
160 try:
161 async with aiofiles.open(actual_file, 'rb') as f:
162 content = await f.read()
163 files = {'f': (filename, content, 'application/octet-stream')}
164 headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'}
165 params = {'j': '1', 'ck': 'b2'}
166 base_url = os.getenv("COPYPARTY_URL_UPLOAD")
167
168 response = requests.post(base_url, files=files, headers=headers, params=params)
169
170 if response.status_code in [200, 201]:
171 return Downloader._handle_upload_success(response, filename, temp_dir)
172 elif response.status_code == 413:
173 return Downloader._create_error_response(
174 "File too large for upload",
175 "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)",
176 temp_dir
177 )
178 else:
179 return Downloader._create_error_response(
180 f"Server returned {response.status_code}",
181 response.text or 'Unknown error',
182 temp_dir
183 )
184 except Exception as upload_error:
185 return Downloader._create_error_response("Upload failed", str(upload_error), temp_dir)
186
187 @staticmethod
188 def _handle_upload_success(response: requests.Response, filename: str, temp_dir: str) -> dict:
189 try:
190 result = response.json()
191 if 'fileurl' in result:
192 return {
193 "status": "success",
194 "type": "url",
195 "content": result['fileurl'],
196 "filename": filename,
197 "temp_dir": temp_dir
198 }
199 else:
200 return Downloader._create_error_response(
201 "Invalid response from file server",
202 str(result),
203 temp_dir
204 )
205 except ValueError:
206 return Downloader._create_error_response(
207 "Failed to parse server response",
208 response.text[:200],
209 temp_dir
210 )
211
212 @staticmethod
213 def _return_file_response(actual_file: str, filename: str, temp_dir: str) -> dict:
214 return {
215 "status": "success",
216 "type": "file",
217 "content": actual_file,
218 "filename": filename,
219 "temp_dir": temp_dir
220 }
221
222 @staticmethod
223 def _create_error_response(content: str, details: str, temp_dir: str | None) -> dict:
224 return {
225 "status": "error",
226 "type": "error",
227 "content": content,
228 "details": details,
229 "temp_dir": temp_dir
230 }
231
232
233def setup(bot):
234 bot.add_cog(Downloader(bot))
diff --git a/cogs/math_commands.py b/cogs/math_commands.py
deleted file mode 100644
index e30c92b..0000000
--- a/cogs/math_commands.py
+++ /dev/null
@@ -1,83 +0,0 @@
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 logging
7import asyncio
8import re
9
10from discord import ApplicationContext
11from discord.ext import commands
12
13logger = logging.getLogger(__name__)
14
15MATH_SANITIZE_PATTERN = re.compile(r'^[\d\s\+\-\*\/\^\%\(\)\.\,\=\\<\>\!\|\&\~\?\:\'\"\[\]\{\}a-zA-Z]+$')
16
17
18def sanitize_math_input(equation: str) -> str | None:
19 if not equation or len(equation) > 1000:
20 return None
21 equation = equation.strip()
22 if not equation:
23 return None
24 if not MATH_SANITIZE_PATTERN.match(equation):
25 return None
26 for char in equation:
27 if ord(char) < 32 or ord(char) > 126:
28 return None
29 if equation.count('(') != equation.count(')'):
30 return None
31 if equation.count('[') != equation.count(']'):
32 return None
33 if equation.count('{') != equation.count('}'):
34 return None
35 dangerous_patterns = [
36 r'eval', r'exec', r'__import__', r'os\.', r'sys\.',
37 r'subprocess', r'open(', r'file', r'shutil',
38 r'lambda:', r'compile', r'dir(', r'vars(',
39 r'\|\|', r'&&', r';', r'`', r'\$', r'\\n', r'\\r',
40 r'\x', r'\0', r'\a', r'\b', r'\f', r'\v',
41 ]
42 for pattern in dangerous_patterns:
43 if re.search(pattern, equation, re.IGNORECASE):
44 return None
45 return equation
46
47
48class Math(commands.Cog):
49 def __init__(self, bot):
50 self.bot = bot
51
52 @commands.slash_command(
53 name="math",
54 description="wrug szyskih matfisuf"
55 )
56 async def math(self, ctx: ApplicationContext, equation: str):
57 sanitized = sanitize_math_input(equation)
58 if sanitized is None:
59 await ctx.respond("Invalid equation. Only basic math expressions allowed.", ephemeral=True)
60 logger.warning(f"Math command rejected invalid input: {equation}")
61 return
62 try:
63 math_proc = await asyncio.create_subprocess_exec(
64 "qalc", "-e", sanitized,
65 stdout=asyncio.subprocess.PIPE,
66 stderr=asyncio.subprocess.PIPE
67 )
68 stdout, stderr = await math_proc.communicate()
69 if math_proc.returncode != 0:
70 error_output = stderr.decode('utf-8', errors='replace').strip()
71 await ctx.respond(f"Calculation error: {error_output}", ephemeral=True)
72 logger.error(f"qalc error: {error_output}")
73 return
74 result = stdout.decode('utf-8', errors='replace').strip()
75 await ctx.respond(f"{sanitized} = {result}")
76 logger.info(f"Math command executed: {sanitized}")
77 except Exception as e:
78 await ctx.respond(f"Error: {str(e)}", ephemeral=True)
79 logger.error(f"Error in math command: {e}", exc_info=True)
80
81
82def setup(bot):
83 bot.add_cog(Math(bot))
diff --git a/cogs/music/join.py b/cogs/music/join.py
deleted file mode 100644
index d68ca79..0000000
--- a/cogs/music/join.py
+++ /dev/null
@@ -1,29 +0,0 @@
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 logging
7
8from discord.ext import commands
9from cogs.voice import get_voice_manager
10
11logger = logging.getLogger(__name__)
12
13
14class Join(commands.Cog):
15 def __init__(self, bot):
16 self.bot = bot
17
18 @commands.slash_command(name="join", description="dołącza do kanalu")
19 async def join(self, ctx):
20 voice_mgr = get_voice_manager(self.bot)
21 channel = ctx.author.voice.channel
22 await voice_mgr.connect(channel)
23 await ctx.respond(content="\u200b", ephemeral=True)
24 await ctx.interaction.delete_original_response()
25 logger.info("Bot connected to voice channel")
26
27
28def setup(bot):
29 bot.add_cog(Join(bot))
diff --git a/cogs/music/loop.py b/cogs/music/loop.py
deleted file mode 100644
index 6e2c838..0000000
--- a/cogs/music/loop.py
+++ /dev/null
@@ -1,40 +0,0 @@
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 logging
7
8from discord.ext import commands
9
10logger = logging.getLogger(__name__)
11
12
13class Loop(commands.Cog):
14 def __init__(self, bot):
15 self.bot = bot
16 self.looping = {}
17
18 def is_looping(self, guild_id):
19 return self.looping.get(guild_id, False)
20
21 def set_looping(self, guild_id, value):
22 self.looping[guild_id] = value
23
24 @commands.slash_command(
25 name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji"
26 )
27 async def loop(self, ctx):
28 guild_id = ctx.guild.id
29 looping = not self.is_looping(guild_id)
30 self.set_looping(guild_id, looping)
31 await ctx.respond(
32 f"looping jest teraz {'włączony' if looping else 'wyłączony'}."
33 )
34 logger.info(
35 f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}"
36 )
37
38
39def setup(bot):
40 bot.add_cog(Loop(bot))
diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py
deleted file mode 100644
index 91bd406..0000000
--- a/cogs/music/media_processing.py
+++ /dev/null
@@ -1,135 +0,0 @@
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 asyncio
7import logging
8import os
9
10import yt_dlp
11from youtubesearchpython import VideosSearch
12from cogs.utils.url_helper import clean_url_for_ytdlp
13
14logger = logging.getLogger(__name__)
15
16
17class MediaDownloader:
18 """Handles downloading media from various sources."""
19
20 @staticmethod
21 async def download(ctx, url):
22 """Download audio from YouTube URL."""
23 from cogs.utils.temp import get_temp_dir
24
25 temp_dir = get_temp_dir("senator_sperma_music")
26
27 ytdl_opts = {
28 "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"),
29 "postprocessors": [
30 {
31 "key": "FFmpegExtractAudio",
32 "preferredcodec": "mp3",
33 "preferredquality": "192",
34 }
35 ],
36 "force_ipv4": True,
37 }
38
39 try:
40 url = clean_url_for_ytdlp(url)
41 with yt_dlp.YoutubeDL(ytdl_opts) as ydl:
42 info_dict = ydl.extract_info(url, download=True)
43 # Get the base filename without extension
44 base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0]
45 # Add .mp3 extension since we're extracting audio
46 filename = f"{base_filename}.mp3"
47 logger.info(f"Downloaded file: {filename}")
48 return filename
49 except Exception as e:
50 logger.error(f"Error downloading video: {e}")
51 await ctx.respond("eror pobirajync wideło", ephemeral=True)
52
53 @staticmethod
54 async def get_url(ctx, query):
55 """Search YouTube and get URL for a query."""
56 search = VideosSearch(query, limit=1)
57 if search.result()["result"]:
58 title = search.result()["result"][0]["title"]
59 url = search.result()["result"][0]["link"]
60 logger.info(f"Found video: {title}")
61 return url, title
62 else:
63 logger.warning("No results found for the query.")
64 await ctx.respond("nic ni ma", ephemeral=True)
65 return None
66
67
68class AudioCompressor:
69 """Handles audio compression using FFmpeg."""
70
71 @staticmethod
72 async def compress(ctx, filename):
73 """Compress audio file to opus format."""
74 from cogs.utils.temp import get_temp_dir
75
76 if not filename:
77 await ctx.respond("dzie wideło", ephemeral=True)
78 return None
79
80 temp_dir = get_temp_dir("senator_sperma_compressed")
81
82 # Create a temporary output filename in the temp directory
83 base_name = os.path.basename(filename)
84 output_filename = os.path.join(
85 temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus"
86 )
87
88 # Ensure the output directory exists
89 os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True)
90
91 # Use proper quoting for filenames with spaces
92 ffmpeg_command = [
93 "ffmpeg",
94 "-y", # Overwrite output file if it exists
95 "-i",
96 filename,
97 "-ac",
98 "1", # Mono audio
99 "-b:a",
100 "500", # 500k bitrate
101 "-acodec",
102 "libopus",
103 output_filename,
104 ]
105
106 try:
107 logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}")
108 process = await asyncio.create_subprocess_exec(
109 *ffmpeg_command,
110 stdout=asyncio.subprocess.PIPE,
111 stderr=asyncio.subprocess.PIPE,
112 )
113
114 _, stderr = await process.communicate()
115
116 if process.returncode != 0:
117 error_msg = stderr.decode()
118 logger.error(f"FFmpeg error ({process.returncode}): {error_msg}")
119 await ctx.respond("eror kompresujync pilk", ephemeral=True)
120 return None
121
122 if not os.path.exists(output_filename):
123 logger.error(f"Output file was not created: {output_filename}")
124 await ctx.respond(
125 "eror kompresujync pilk - output file not created", ephemeral=True
126 )
127 return None
128
129 logger.info(f"Successfully compressed file: {output_filename}")
130 return output_filename
131
132 except Exception as e:
133 logger.error(f"Error compressing file: {str(e)}", exc_info=True)
134 await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True)
135 return None
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
deleted file mode 100644
index c6a55b0..0000000
--- a/cogs/music/play_commands.py
+++ /dev/null
@@ -1,320 +0,0 @@
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 logging
7import os
8import urllib.parse
9import asyncio
10from os import PathLike
11from typing import Awaitable, Callable, List, Tuple
12import re
13import requests
14from yt_dlp import YoutubeDL
15
16import discord
17from discord.commands import OptionChoice
18from discord.ext import commands
19
20from cogs.music import media_processing
21from cogs.utils.file_search import UniversalSearcher
22from cogs.utils.settings import get_uploadurl
23from cogs.utils.url_helper import clean_url_for_ytdlp
24from cogs.voice import get_voice_manager
25
26logger = logging.getLogger(__name__)
27
28EXTERNAL_URL_PATTERN = re.compile(
29 r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))"
30)
31
32
33class PlayCommands(commands.Cog):
34 """Discord slash commands for music playback."""
35
36 def __init__(self, bot):
37 from cogs.utils.temp import get_temp_dir
38
39 self.bot = bot
40 self.temp_dir = get_temp_dir()
41 self.path_mapping = {} # Maps display names to full paths
42
43 @staticmethod
44 async def _create_search_embed(
45 candidates: List[str],
46 ) -> Tuple[discord.Embed, discord.ui.View]:
47 """Create an embed and view for search results."""
48 embed = discord.Embed(
49 title="Wyniki wyszukiwania",
50 description="Wybierz plik do odtworzenia:",
51 color=discord.Color.blue(),
52 )
53
54 view = discord.ui.View(disable_on_timeout=True)
55
56 for i, candidate in enumerate(candidates, start=1):
57 filename = candidate.split("/")[-1].split("?")[0]
58 display_name = os.path.splitext(filename)[0]
59 embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False)
60
61 return embed, view
62
63 async def _handle_search_results(
64 self,
65 ctx: discord.ApplicationContext,
66 candidates: List[str],
67 play_callback: Callable[[discord.Interaction, str], Awaitable[None]],
68 ) -> None:
69 """Handle the display of search results and button interactions."""
70 if not candidates:
71 await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True)
72 return
73
74 embed, view = await self._create_search_embed(candidates)
75
76 # Add buttons for each candidate
77 for i, candidate in enumerate(candidates, start=1):
78 button = discord.ui.Button(label=f"Play {i}")
79
80 async def button_callback(
81 interaction: discord.Interaction, candidate_path=candidate
82 ):
83 await interaction.response.defer(ephemeral=True)
84 await ctx.interaction.delete_original_response()
85 try:
86 await play_callback(interaction, candidate_path)
87 except Exception as e:
88 logger.error(f"Error in play callback: {str(e)}", exc_info=True)
89 await interaction.followup.send(
90 f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True
91 )
92
93 button.callback = button_callback
94 view.add_item(button)
95
96 await ctx.respond(embed=embed, view=view, ephemeral=True)
97
98 async def _search_autocomplete(
99 self, ctx: discord.AutocompleteContext
100 ) -> List[OptionChoice]:
101 """Autocomplete handler for search queries."""
102 if ctx.value.lower().startswith(("http://", "https://")):
103 return []
104
105 query = ctx.value.lower()
106 try:
107 results = await UniversalSearcher.search_copyparty_autocomplete_audio(
108 query, max_results=25
109 )
110 choices = []
111 self.path_mapping = {} # Reset the mapping for each new query
112
113 for url in results:
114 filename = url.split("/")[-1]
115 filename = filename.split("?")[0] # Remove query params if any
116 filename = urllib.parse.unquote(filename) # Decode URL encoding
117 display = os.path.splitext(filename)[0] # Remove extension
118
119 display = display.strip()
120
121 if len(display) > 100:
122 display = display[:99] + "…"
123
124 self.path_mapping[display] = url
125 choices.append(OptionChoice(display, display))
126
127 if len(choices) >= 25:
128 break
129
130 return choices
131
132 except Exception as e:
133 logger.error(f"Error in autocomplete: {str(e)}", exc_info=True)
134 return []
135
136 async def _handle_compressed_file_play(
137 self, ctx: discord.ApplicationContext, url: str
138 ) -> None:
139 """Download from copyparty, compress, then play locally."""
140 import aiohttp
141
142 local_path = None
143 compressed_path = None
144 try:
145 async with aiohttp.ClientSession() as session:
146 async with session.get(url) as response:
147 if response.status != 200:
148 await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True)
149 return
150
151 filename = url.split("/")[-1].split("?")[0]
152 filename = urllib.parse.unquote(filename)
153 local_path = os.path.join(self.temp_dir, filename)
154 os.makedirs(self.temp_dir, exist_ok=True)
155
156 loop = asyncio.get_event_loop()
157 with open(local_path, "wb") as f:
158 async for chunk in response.content.iter_chunked(8192):
159 await loop.run_in_executor(None, f.write, chunk)
160
161 compressed_path = await media_processing.AudioCompressor.compress(
162 ctx, local_path
163 )
164 if not compressed_path:
165 return
166
167 await get_voice_manager(self.bot).play(
168 ctx, compressed_path, delete_after_play=True
169 )
170 except Exception as e:
171 logger.error(f"Error in compressed playback: {str(e)}", exc_info=True)
172 await ctx.respond(f"Błąd: {str(e)}", ephemeral=True)
173 finally:
174 if local_path and os.path.exists(local_path):
175 try:
176 os.remove(local_path)
177 except Exception:
178 pass
179
180 async def _handle_external_url_play(
181 self, ctx: discord.ApplicationContext, url: str
182 ) -> None:
183 """Download from external URL, upload to copyparty, then play."""
184 local_path = None
185 try:
186 ytdl_opts = {
187 "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"),
188 "postprocessors": [
189 {
190 "key": "FFmpegExtractAudio",
191 "preferredcodec": "mp3",
192 "preferredquality": "192",
193 }
194 ],
195 "force_ipv4": True,
196 }
197
198 loop = asyncio.get_event_loop()
199 url = clean_url_for_ytdlp(url)
200 with YoutubeDL(ytdl_opts) as ydl:
201 info_dict = await loop.run_in_executor(
202 None, lambda: ydl.extract_info(url, download=True)
203 )
204 title = info_dict.get("title", "nieznany")
205 base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0]
206 local_path = f"{base_filename}.mp3"
207
208 if not os.path.exists(local_path):
209 await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True)
210 return
211
212 upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path))
213
214 await get_voice_manager(self.bot).play(
215 ctx, local_path, delete_after_play=True
216 )
217
218 try:
219 await asyncio.wait_for(upload_task, timeout=30.0)
220 except asyncio.TimeoutError:
221 logger.warning("Upload timed out")
222 except Exception as e:
223 logger.error(f"Error downloading external URL: {str(e)}", exc_info=True)
224 if local_path and os.path.exists(local_path):
225 try:
226 os.remove(local_path)
227 except Exception:
228 pass
229 await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True)
230
231 async def _upload_to_copyparty_async(self, file_path: str) -> None:
232 """Upload file to copyparty asynchronously (fire and forget)."""
233 try:
234 logger.info(f"Starting async upload: {file_path}")
235 baseurl = get_uploadurl()
236 password = os.getenv("COPYPARTY_PASSWORD", "")
237
238 logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}")
239
240 if baseurl == "http://localhost:3939":
241 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default")
242
243 with open(file_path, "rb") as f:
244 content = f.read()
245
246 files = {"f": (os.path.basename(file_path), content, "application/octet-stream")}
247 headers = {"Cookie": f"cppwd={password}"}
248 params = {"j": "1", "ck": "b2"}
249
250 logger.info(f"Posting to {baseurl}")
251 response = requests.post(baseurl, files=files, headers=headers, params=params)
252 text = response.text
253 logger.info(f"Upload response: {response.status_code} - {text[:200] if text else 'empty'}")
254
255 if response.status_code in [200, 201]:
256 result = response.json()
257 logger.info(f"Uploaded to copyparty: {result.get('fileurl')}")
258 except Exception as e:
259 logger.error(f"Async upload failed: {e}", exc_info=True)
260
261 async def _handle_local_file_play(
262 self, ctx: discord.ApplicationContext, file: str, compressed: bool
263 ) -> None:
264 """Handle file playback from copyparty URL."""
265 url = self.path_mapping.get(file, file) # Look up URL from display name
266
267 if compressed:
268 await self._handle_compressed_file_play(ctx, url)
269 else:
270 await get_voice_manager(self.bot).play(ctx, url)
271
272 @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!")
273 async def play(
274 self,
275 ctx: discord.ApplicationContext,
276 melodia: str = discord.Option(
277 str,
278 "odtwórz plik z serwera bądź strumienia",
279 autocomplete=_search_autocomplete,
280 required=False,
281 ),
282 file: discord.Attachment = discord.Option(
283 discord.Attachment, "plik do zagrania", required=False
284 ),
285 compressed: bool = discord.Option(
286 bool, "Czy skompresować dźwięk?", default=False
287 ),
288 ):
289 """Odtwarza wybrany plik dźwiękowy lub stream internetowy."""
290 await ctx.defer()
291
292 if melodia and file:
293 await ctx.respond(
294 "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True
295 )
296 return
297
298 if not melodia and not file:
299 await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True)
300 return
301
302 if file:
303 file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename)
304 await file.save(file_path)
305 await get_voice_manager(self.bot).play(ctx, file_path)
306 else:
307 try:
308 if EXTERNAL_URL_PATTERN.match(melodia.lower()):
309 await self._handle_external_url_play(ctx, melodia)
310 else:
311 await self._handle_local_file_play(ctx, melodia, compressed)
312 except Exception as e:
313 logger.error(f"Error playing: {str(e)}", exc_info=True)
314 await ctx.respond(
315 f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True
316 )
317
318
319def setup(bot):
320 bot.add_cog(PlayCommands(bot))
diff --git a/cogs/music/stop.py b/cogs/music/stop.py
deleted file mode 100644
index 9c8d5cf..0000000
--- a/cogs/music/stop.py
+++ /dev/null
@@ -1,29 +0,0 @@
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
6from discord.ext import commands
7
8from cogs.voice import get_voice_manager
9
10import logging
11
12logger = logging.getLogger(__name__)
13
14
15class Stop(commands.Cog):
16 def __init__(self, bot):
17 self.bot = bot
18
19 @commands.slash_command(name="stop", description="stopuj bota")
20 async def stop(self, ctx):
21 await ctx.respond(content="\u200b", ephemeral=True)
22 await ctx.interaction.delete_original_response()
23 voice_mgr = get_voice_manager(self.bot)
24 await voice_mgr.disconnect(ctx.guild)
25 logger.info("Bot disconnected from voice channel")
26
27
28def setup(bot):
29 bot.add_cog(Stop(bot))
diff --git a/cogs/qr.py b/cogs/qr.py
deleted file mode 100644
index 32c574e..0000000
--- a/cogs/qr.py
+++ /dev/null
@@ -1,154 +0,0 @@
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 asyncio
7import logging
8import os
9import tempfile
10
11import aiohttp
12import discord
13import qrcode
14from discord.ext import commands
15
16logger = logging.getLogger(__name__)
17
18_pil_image = None
19_decode_func = None
20_decode_available = False
21
22
23def _ensure_qr():
24 global _pil_image, _decode_func, _decode_available
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
38
39
40class QR(commands.Cog):
41 def __init__(self, bot):
42 self.bot = bot
43
44 async def _download_image(self, url):
45 """Download image from URL to temporary file"""
46 async with aiohttp.ClientSession() as session:
47 async with session.get(url) as response:
48 if response.status == 200:
49 content = await response.read()
50 with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
51 await asyncio.get_event_loop().run_in_executor(None, f.write, content)
52 return f.name
53 return None
54
55 @commands.slash_command(
56 name="qr_generuj", description="Generuje kod QR z podanego tekstu"
57 )
58 async def qr(
59 self,
60 ctx,
61 text: str = discord.Option(str, "Tekst do zakodowania", required=True),
62 ):
63 await ctx.defer()
64
65 from cogs.utils.temp import get_temp_dir
66 temp_path = os.path.join(
67 get_temp_dir(), f"qr_{os.getpid()}_{asyncio.get_event_loop().time()}.png"
68 )
69
70 try:
71 loop = asyncio.get_event_loop()
72 await loop.run_in_executor(None, self._generate_qr, text, temp_path)
73 await ctx.respond(file=discord.File(temp_path, filename="qr.png"))
74 logger.info(f"Generated QR for text: {text}")
75 except Exception as e:
76 logger.error(f"QR generation error: {e}")
77 await ctx.respond("Error generating QR code", ephemeral=True)
78 finally:
79 try:
80 if os.path.exists(temp_path):
81 os.unlink(temp_path)
82 except Exception as e:
83 logger.error(f"Error cleaning up temp file: {e}")
84
85 def _generate_qr(self, text, output_path):
86 """Generate QR code (runs in executor)"""
87 qr = qrcode.QRCode(
88 version=1,
89 error_correction=qrcode.constants.ERROR_CORRECT_L, # type: ignore[attr-defined]
90 box_size=10,
91 border=4,
92 )
93 qr.add_data(text)
94 qr.make(fit=True)
95 img = qr.make_image(fill_color="black", back_color="white")
96 img.save(output_path)
97
98 @commands.slash_command(name="qr_dekoduj", description="Dekoduje kod QR z obrazka")
99 async def decode(
100 self,
101 ctx,
102 image: discord.Attachment = discord.Option(
103 discord.Attachment, "Obrazek z kodem QR", required=True
104 ),
105 ):
106 if not _ensure_qr():
107 await ctx.respond(
108 "Dekodowanie nie jest dostępne (brak biblioteki pyzbar)", ephemeral=True
109 )
110 return
111
112 await ctx.defer()
113
114 try:
115 temp_path = await self._download_image(image.url)
116 if not temp_path:
117 await ctx.respond("Nie udało się pobrać obrazka", ephemeral=True)
118 return
119
120 try:
121 loop = asyncio.get_event_loop()
122 decoded = await loop.run_in_executor(None, self._decode_qr, temp_path)
123
124 if decoded:
125 await ctx.respond(f"```\n{decoded}\n```")
126 logger.info(f"Decoded QR: {decoded}")
127 else:
128 await ctx.respond(
129 "Nie znaleziono kodu QR na obrazku", ephemeral=True
130 )
131 finally:
132 if os.path.exists(temp_path):
133 os.unlink(temp_path)
134 except Exception as e:
135 logger.error(f"QR decode error: {e}")
136 await ctx.respond("Error dekodowania QR", ephemeral=True)
137
138 def _decode_qr(self, image_path):
139 """Decode QR code from image (runs in executor)"""
140 if not _ensure_qr() or _decode_func is None:
141 return None
142
143 try:
144 img = _pil_image.open(image_path)
145 decoded_list = _decode_func(img)
146 if decoded_list:
147 return decoded_list[0].data.decode("utf-8")
148 except Exception as e:
149 logger.error(f"Error decoding QR: {e}")
150 return None
151
152
153def setup(bot):
154 bot.add_cog(QR(bot))
diff --git a/cogs/say.py b/cogs/say.py
deleted file mode 100644
index 29d95fe..0000000
--- a/cogs/say.py
+++ /dev/null
@@ -1,28 +0,0 @@
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
6from discord.ext import commands
7import discord
8import logging
9
10logger = logging.getLogger(__name__)
11
12class Say(commands.Cog):
13 def __init__(self, bot):
14 self.bot = bot
15
16 @discord.slash_command(name="say", description="senator, gadaj")
17 async def say(
18 self,
19 ctx: discord.ApplicationContext,
20 text: str = discord.Option(str, "Tekst do przeczytania", required=True)
21 ):
22 await ctx.defer()
23 await ctx.respond(content="\u200b", ephemeral=True)
24 await ctx.interaction.delete_original_response()
25 await ctx.send(content=text)
26
27def setup(bot):
28 bot.add_cog(Say(bot))
diff --git a/cogs/search.py b/cogs/search.py
deleted file mode 100644
index a124cbf..0000000
--- a/cogs/search.py
+++ /dev/null
@@ -1,73 +0,0 @@
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 urllib.parse
7
8import discord
9from discord.ext import commands
10import logging
11from discord import AutocompleteContext
12from cogs.utils import file_search as search_utils
13
14logger = logging.getLogger(__name__)
15
16class Search(commands.Cog):
17 def __init__(self, bot):
18 self.bot = bot
19 self._cached_results = {}
20
21 async def file_autocomplete(self, ctx: AutocompleteContext):
22 """Autocomplete function for file search."""
23 query = ctx.value.lower() if ctx.value else ""
24 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25)
25
26 user_id = ctx.interaction.user.id
27 self._cached_results[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results}
28
29 choices = []
30 for url in results[:25]:
31 filename = urllib.parse.unquote(url.split('/')[-1])
32 display_name = filename[:99] + "…" if len(filename) > 100 else filename
33 choices.append(discord.OptionChoice(name=display_name, value=filename))
34 return choices
35
36 @commands.slash_command(
37 name="search",
38 description="wyszukaj pliku"
39 )
40 @discord.option(
41 name="filename",
42 description="Search for a file to send",
43 autocomplete=file_autocomplete
44 )
45 async def search(self, ctx, filename: str):
46 """Send a file via URL from autocomplete selection."""
47 if not filename:
48 await ctx.reply("Please select a file from the autocomplete options.")
49 return
50
51 await ctx.defer()
52 logger.info(f"Search command called by {ctx.author} for file: {filename}")
53
54 cached = self._cached_results.get(ctx.author.id)
55 if not cached:
56 await ctx.send_followup("Search results expired. Please search again.")
57 return
58
59 matching_url = cached.get(filename)
60 if not matching_url:
61 await ctx.send_followup(f"File '{filename}' not found.")
62 return
63
64 try:
65 await ctx.send_followup(matching_url)
66 logger.info(f"Successfully sent URL: {matching_url}")
67 del self._cached_results[ctx.author.id]
68 except Exception as e:
69 logger.error(f"Error sending URL {matching_url}: {str(e)}")
70 await ctx.send_followup(str(e))
71
72def setup(bot):
73 bot.add_cog(Search(bot))
diff --git a/cogs/trigger.py b/cogs/trigger.py
deleted file mode 100644
index ce80d43..0000000
--- a/cogs/trigger.py
+++ /dev/null
@@ -1,260 +0,0 @@
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 discord
8from discord.ext import commands
9import random
10import logging
11
12from cogs.utils.database import get_connection, init_database
13
14logger = logging.getLogger(__name__)
15
16init_database()
17
18_SELECT_KEYWORD_SQL = "SELECT keyword FROM trigger_keywords WHERE trigger_id=?"
19_SELECT_RESPONSE_SQL = "SELECT response_text FROM trigger_responses WHERE trigger_id=?"
20_SELECT_TRIGGER_ID_SQL = "SELECT id FROM triggers WHERE name=?"
21_TRIGGER_NOT_FOUND_MSG = "Trigger not found."
22_TRIGGER_NAME_OPTION = "the name of the trigger"
23
24
25class Trigger(commands.Cog):
26 def __init__(self, bot):
27 self.bot = bot
28
29 trigger_group = discord.SlashCommandGroup("trigger", "trigger commands")
30
31 @commands.Cog.listener()
32 async def on_message(self, message):
33 if message.author == self.bot.user:
34 return
35
36 await self.trigger(message)
37
38 @staticmethod
39 async def trigger(message):
40 content = message.content.lower()
41 with get_connection() as conn:
42 c = conn.cursor()
43 c.execute("SELECT id, name FROM triggers")
44 for trigger_id, name in c.fetchall():
45 c.execute(_SELECT_KEYWORD_SQL, (trigger_id,))
46 keywords = [row[0] for row in c.fetchall()]
47 if any(keyword in content for keyword in keywords):
48 c.execute(_SELECT_RESPONSE_SQL, (trigger_id,))
49 responses = [row[0] for row in c.fetchall()]
50 if responses:
51 await message.channel.send(random.choice(responses).replace('\\n', '\n'))
52 logger.info(f"{message.author} triggered {name}")
53 break
54
55 @staticmethod
56 def get_type(name):
57 with get_connection() as conn:
58 c = conn.cursor()
59 c.execute("SELECT name FROM triggers")
60 return [row[0] for row in c.fetchall()]
61
62 @trigger_group.command(
63 description="Create a new trigger",
64 )
65 async def create(
66 self,
67 ctx: discord.ApplicationContext,
68 name = discord.Option(
69 str,
70 _TRIGGER_NAME_OPTION,
71 autocomplete= discord.utils.basic_autocomplete(get_type),
72 required=True
73 )
74 ):
75 with get_connection() as conn:
76 c = conn.cursor()
77 c.execute("INSERT OR IGNORE INTO triggers (name) VALUES (?)", (name,))
78 conn.commit()
79
80 await ctx.respond("trigger added")
81
82 @trigger_group.command(
83 description="Add a trigger or response to the name",
84 )
85 async def add(
86 self,
87 ctx: discord.ApplicationContext,
88 name = discord.Option(
89 str,
90 _TRIGGER_NAME_OPTION,
91 autocomplete= discord.utils.basic_autocomplete(get_type),
92 required=True
93 ),
94 trigger = discord.Option(
95 str,
96 "the trigger to add to the name",
97 required=False
98 ),
99 response = discord.Option(
100 str,
101 "the response to add to the name",
102 required=False
103 )
104 ):
105 with get_connection() as conn:
106 c = conn.cursor()
107 c.execute(_SELECT_TRIGGER_ID_SQL, (name,))
108 row = c.fetchone()
109 if not row:
110 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
111 logger.warning(f"Trigger not found: {name}")
112 return
113 trigger_id = row[0]
114 if trigger:
115 for keyword in trigger.split('\\'):
116 keyword = keyword.strip()
117 if keyword:
118 c.execute("INSERT INTO trigger_keywords (trigger_id, keyword) VALUES (?, ?)", (trigger_id, keyword))
119 if response:
120 for resp in response.split('\\'):
121 resp = resp.strip()
122 if resp:
123 c.execute("INSERT INTO trigger_responses (trigger_id, response_text) VALUES (?, ?)", (trigger_id, resp))
124 conn.commit()
125
126 await ctx.respond("trigger updated")
127 logger.info(f"Trigger updated: {name}")
128
129 @trigger_group.command(
130 description="Remove a trigger or response from the name",
131 )
132 async def remove(
133 self,
134 ctx: discord.ApplicationContext,
135 name = discord.Option(
136 str,
137 _TRIGGER_NAME_OPTION,
138 autocomplete= discord.utils.basic_autocomplete(get_type),
139 required=True
140 ),
141 trigger = discord.Option(
142 str,
143 "the trigger to remove from the name",
144 required=False
145 ),
146 response = discord.Option(
147 str,
148 "the response to remove from the name",
149 required=False
150 )
151 ):
152 with get_connection() as conn:
153 c = conn.cursor()
154 c.execute(_SELECT_TRIGGER_ID_SQL, (name,))
155 row = c.fetchone()
156 if not row:
157 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
158 logger.warning(f"Trigger not found: {name}")
159 return
160 trigger_id = row[0]
161 if trigger:
162 c.execute("DELETE FROM trigger_keywords WHERE trigger_id=? AND keyword=?", (trigger_id, trigger))
163 if response:
164 c.execute("DELETE FROM trigger_responses WHERE trigger_id=? AND response_text=?", (trigger_id, response))
165 conn.commit()
166
167 await ctx.respond("trigger removed")
168 logger.info(f"Trigger removed: {name}")
169
170 @trigger_group.command(
171 description="Delete the whole trigger",
172 )
173 async def delete(
174 self,
175 ctx: discord.ApplicationContext,
176 name = discord.Option(
177 str,
178 _TRIGGER_NAME_OPTION,
179 autocomplete= discord.utils.basic_autocomplete(get_type),
180 required=True
181 )
182 ):
183 with get_connection() as conn:
184 c = conn.cursor()
185 c.execute(_SELECT_TRIGGER_ID_SQL, (name,))
186 row = c.fetchone()
187 if not row:
188 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
189 return
190 trigger_id = row[0]
191 c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,))
192 c.execute("DELETE FROM trigger_responses WHERE trigger_id=?", (trigger_id,))
193 c.execute("DELETE FROM triggers WHERE id=?", (trigger_id,))
194 conn.commit()
195
196 await ctx.respond("trigger deleted")
197 logger.info(f"Trigger deleted: {name}")
198
199
200 @trigger_group.command(
201 description="List all triggers",
202 )
203 async def list(
204 self,
205 ctx: discord.ApplicationContext,
206 name = discord.Option(
207 str,
208 _TRIGGER_NAME_OPTION,
209 autocomplete=discord.utils.basic_autocomplete(get_type),
210 required=True
211 )
212 ):
213 with get_connection() as conn:
214 c = conn.cursor()
215 if name:
216 c.execute("SELECT id FROM triggers WHERE name=?", (name,))
217 row = c.fetchone()
218 if not row:
219 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
220 logger.warning(f"Trigger not found: {name}")
221 return
222 trigger_id = row[0]
223 c.execute(_SELECT_KEYWORD_SQL, (trigger_id,))
224 triggers = [row[0] for row in c.fetchall()]
225 c.execute(_SELECT_RESPONSE_SQL, (trigger_id,))
226 responses = [row[0] for row in c.fetchall()]
227 trigger_list = "\n".join(triggers)
228 response_list = "\n".join(responses)
229 message = f"**Trigger: {name}**\n**Triggers:**\n{trigger_list}\n**Responses:**\n{response_list}"
230 await ctx.respond(message)
231 logger.info(f"Trigger listed: {name}")
232 else:
233 c.execute("SELECT id, name FROM triggers")
234 messages = []
235 for trigger_id, name in c.fetchall():
236 c.execute(_SELECT_KEYWORD_SQL, (trigger_id,))
237 triggers = ", ".join([row[0] for row in c.fetchall()])
238 c.execute(_SELECT_RESPONSE_SQL, (trigger_id,))
239 responses = ", ".join([row[0] for row in c.fetchall()])
240 messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}")
241 await ctx.respond("\n\n".join(messages))
242 logger.info("Triggers listed")
243
244 @trigger_group.command(
245 description="how to use trigger group?"
246 )
247 async def help(
248 self,
249 ctx: discord.ApplicationContext,
250 ):
251 await ctx.respond("Trigger create - creates a group for triggers/responses\n"
252 "Trigger add - adds triggers/responses to a group, \\ is the delimeter\n"
253 "Trigger remove - removes triggers/responses from a group, \\ is the delimeter\n"
254 "Trigger delete - deletes the whole group\n"
255 "Trigger list - lists triggers/responses of a group\n"
256 "Trigger help - display this message\n")
257
258
259def setup(bot):
260 bot.add_cog(Trigger(bot))
diff --git a/cogs/tts.py b/cogs/tts.py
deleted file mode 100644
index ca81d3a..0000000
--- a/cogs/tts.py
+++ /dev/null
@@ -1,111 +0,0 @@
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 json
8import discord
9import logging
10import tempfile
11from discord.ext import commands
12from gtts import gTTS
13import asyncio
14from cogs.utils.settings import get_volume
15
16logger = logging.getLogger(__name__)
17
18class TTS(commands.Cog):
19 def __init__(self, bot):
20 self.bot = bot
21
22 async def _handle_file_output(self, ctx, temp_path, text):
23 """Handle TTS file output"""
24 try:
25 await ctx.respond(file=discord.File(temp_path, filename='tts.mp3'))
26 logger.info(f"Sent TTS for text: {text}")
27 except Exception as e:
28 logger.error(f"Error sending file: {e}")
29 await ctx.respond("eror :c", ephemeral=True)
30
31 def _get_voice_manager(self):
32 from cogs.voice import get_voice_manager
33 return get_voice_manager(self.bot)
34
35 async def _get_or_connect_voice_client(self, channel):
36 """Get existing voice client or connect to new channel"""
37 voice_mgr = self._get_voice_manager()
38 return await voice_mgr.join_channel(channel)
39
40 async def _handle_voice_output(self, ctx, temp_path, text, volume):
41 """Handle TTS voice channel output"""
42 await ctx.respond(content="\u200b", ephemeral=True)
43 await ctx.interaction.delete_original_response()
44
45 if not (ctx.author.voice and ctx.author.voice.channel):
46 logger.info("User is not in a voice channel")
47 return
48
49 channel = ctx.author.voice.channel
50 try:
51 voice_mgr = self._get_voice_manager()
52
53 if voice_mgr.get_voice_client and await voice_mgr.get_client(channel.guild):
54 vc = await voice_mgr.get_client(channel.guild)
55 if vc and vc.is_playing():
56 logger.info("Audio already playing, skipping TTS")
57 return
58
59 audio_source = discord.FFmpegPCMAudio(
60 temp_path,
61 options=f'-filter:a "volume={volume}"'
62 )
63 logger.info(f"Playing TTS: {text}")
64 await voice_mgr.play_source(channel, audio_source)
65
66 except Exception as e:
67 logger.error(f"Error in voice channel handling: {e}")
68 await ctx.respond("eror :c", ephemeral=True)
69
70 async def _cleanup_temp_file(self, temp_path):
71 """Clean up temporary file"""
72 try:
73 if temp_path and os.path.exists(temp_path):
74 os.unlink(temp_path)
75 except Exception as e:
76 logger.error(f"Error cleaning up temp file {temp_path}: {e}")
77
78 @commands.slash_command(name="tts", description="Generuje TTS dla danego tekstu")
79 async def tts(self, ctx,
80 text: str = discord.Option(str, "Tekst do przeczytania", required=True),
81 output: str = discord.Option(str, "Wybierz sposób odtwarzania", choices=["file", "vc"], default="vc")
82 ):
83 await ctx.defer()
84
85 # Create temporary file in temp folder
86 from cogs.utils.temp import get_temp_dir
87 temp_path = os.path.join(get_temp_dir(), f"tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3")
88
89 try:
90 # Generate and save TTS to temp file
91 tts = gTTS(text=text, lang='pl')
92 loop = asyncio.get_event_loop()
93 await loop.run_in_executor(None, tts.save, temp_path)
94
95 # Load settings
96 volume = get_volume()
97
98 # Handle output based on user choice
99 if output == "file":
100 await self._handle_file_output(ctx, temp_path, text)
101 elif output == "vc":
102 await self._handle_voice_output(ctx, temp_path, text, volume)
103
104 except Exception as e:
105 logger.error(f"TTS generation error: {e}")
106 await ctx.respond("Error generating TTS", ephemeral=True)
107 finally:
108 await self._cleanup_temp_file(temp_path)
109
110def setup(bot):
111 bot.add_cog(TTS(bot))
diff --git a/cogs/uploader.py b/cogs/uploader.py
deleted file mode 100644
index a5d0e05..0000000
--- a/cogs/uploader.py
+++ /dev/null
@@ -1,156 +0,0 @@
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 aiofiles
8import requests
9import urllib.parse
10from typing import Optional
11import discord
12from discord.ext import commands
13from discord import AutocompleteContext
14from cogs.utils import file_search as search_utils
15from cogs.utils.settings import get_uploadurl
16import logging
17
18logger = logging.getLogger(__name__)
19
20
21class Uploader(commands.Cog):
22 def __init__(self, bot):
23 from cogs.utils.temp import get_temp_dir
24 self.bot = bot
25 self.temp_dir = get_temp_dir()
26 self._cached_urls = {}
27 self._uploadurl = get_uploadurl()
28
29 async def _file_autocomplete(self, ctx: AutocompleteContext):
30 query = ctx.value.lower() if ctx.value else ""
31 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25)
32
33 user_id = ctx.interaction.user.id
34 self._cached_urls[user_id] = {urllib.parse.unquote(url.split('/')[-1]): url for url in results}
35
36 choices = []
37 for url in results[:25]:
38 filename = urllib.parse.unquote(url.split('/')[-1])
39 display_name = filename[:99] + "…" if len(filename) > 100 else filename
40 choices.append(discord.OptionChoice(name=display_name, value=filename))
41 return choices
42
43 async def _download_from_url(self, url: str) -> Optional[str]:
44 """Download file from copyparty URL and return local path"""
45 try:
46 response = requests.get(url)
47 if response.status_code != 200:
48 return None
49
50 filename = url.split('/')[-1].split('?')[0]
51 filename = urllib.parse.unquote(filename)
52 file_path = os.path.join(self.temp_dir, filename)
53
54 async with aiofiles.open(file_path, 'wb') as f:
55 await f.write(response.content)
56 return file_path
57 except Exception as e:
58 logger.error(f"Error downloading from {url}: {e}")
59 return None
60
61 async def _upload_to_copyparty(self, file_path: str) -> Optional[str]:
62 """Upload file to copyparty and return download URL"""
63 try:
64 async with aiofiles.open(file_path, 'rb') as f:
65 file_content = await f.read()
66
67 files = {'f': (os.path.basename(file_path), file_content, 'application/octet-stream')}
68 headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'}
69 params = {'j': '1', 'ck': 'b2'}
70
71 response = requests.post(self._uploadurl, files=files, headers=headers, params=params)
72
73 if response.status_code in [200, 201]:
74 result = response.json()
75 return result.get('fileurl')
76 except Exception as e:
77 logger.error(f"Upload error: {e}")
78 return None
79
80 async def _upload_file(self, file_path: str) -> Optional[str]:
81 """Upload a file and return the URL"""
82 if self._uploadurl == "http://localhost:3939":
83 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default")
84 return None
85
86 return await self._upload_to_copyparty(file_path)
87
88 @commands.message_command(
89 name="upload",
90 description="wyślij załączniki na copyparty",
91 )
92 async def message_upload(self, ctx, message: discord.Message):
93 if not message.attachments:
94 await ctx.respond("Brak załączników w wiadomości", ephemeral=True)
95 return
96
97 await ctx.defer()
98
99 uploaded_urls = []
100 failed_count = 0
101
102 for attachment in message.attachments:
103 file_path = os.path.join(self.temp_dir, attachment.filename)
104 await attachment.save(file_path)
105
106 url = await self._upload_file(file_path)
107 if url:
108 uploaded_urls.append(url)
109 else:
110 failed_count += 1
111
112 if os.path.exists(file_path):
113 os.remove(file_path)
114
115 if uploaded_urls:
116 response = f"Wysłano {len(uploaded_urls)} plików:\n" + "\n".join(uploaded_urls)
117 if failed_count > 0:
118 response += f"\n\nNie wysłano {failed_count} plików"
119 await ctx.respond(response)
120 else:
121 await ctx.respond("Nie udało się wysłać żadnych plików", ephemeral=True)
122
123 @commands.slash_command(
124 name="upload",
125 description="wyślij plik na copyparty",
126 )
127 @discord.option(
128 name="zalacznik",
129 description="plik z discorda",
130 required=True,
131 type=discord.Attachment
132 )
133 async def upload(self, ctx, zalacznik: discord.Attachment = None):
134 await ctx.defer()
135
136 file_path = None
137 file_path = os.path.join(self.temp_dir, zalacznik.filename)
138 await zalacznik.save(file_path)
139
140 if not file_path or not os.path.exists(file_path):
141 await ctx.respond("Nie udało się pobrać pliku", ephemeral=True)
142 return
143
144 try:
145 dl_url = await self._upload_file(file_path)
146 if dl_url:
147 await ctx.respond(dl_url)
148 else:
149 await ctx.respond("Nie udało się wysłać pliku", ephemeral=True)
150 finally:
151 if file_path and os.path.exists(file_path):
152 os.remove(file_path)
153
154
155def setup(bot):
156 bot.add_cog(Uploader(bot))
diff --git a/cogs/utils/database.py b/cogs/utils/database.py
deleted file mode 100644
index ea7d356..0000000
--- a/cogs/utils/database.py
+++ /dev/null
@@ -1,126 +0,0 @@
1import os
2import sqlite3
3import logging
4from contextlib import contextmanager
5
6logger = logging.getLogger(__name__)
7
8DB_PATH = "data/senator.db"
9
10_initialized = False
11
12
13def get_connection():
14 conn = sqlite3.connect(get_db_path())
15 return _ConnectionContextManager(conn)
16
17
18def get_db_path():
19 return os.getenv("SENATOR_DB_PATH", DB_PATH)
20
21
22def set_db_path(path):
23 global DB_PATH
24 DB_PATH = path
25
26
27class _ConnectionContextManager:
28 """Context manager for sqlite3 connections that auto-closes."""
29 def __init__(self, conn):
30 self.conn = conn
31
32 def __enter__(self):
33 return self.conn
34
35 def __exit__(self, exc_type, exc_val, exc_tb):
36 self.conn.close()
37 return False
38
39
40def init_database():
41 global _initialized
42 if _initialized:
43 return
44
45 _initialized = True
46
47 db_path = get_db_path()
48 db_dir = os.path.dirname(db_path)
49 if db_dir and not os.path.exists(db_dir):
50 os.makedirs(db_dir)
51
52 conn = sqlite3.connect(db_path)
53 try:
54 c = conn.cursor()
55
56 c.execute("PRAGMA foreign_keys = ON")
57
58 c.execute("""
59 CREATE TABLE IF NOT EXISTS settings (
60 key TEXT PRIMARY KEY,
61 value TEXT
62 )""")
63
64 c.execute("""
65 CREATE TABLE IF NOT EXISTS cron_jobs (
66 id INTEGER PRIMARY KEY AUTOINCREMENT,
67 cron_expression TEXT NOT NULL,
68 type TEXT NOT NULL,
69 channel_id INTEGER NOT NULL,
70 active INTEGER DEFAULT 1,
71 audio_filename TEXT,
72 message TEXT,
73 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
74 )""")
75
76 c.execute("""
77 CREATE TABLE IF NOT EXISTS voice_activity (
78 user_id TEXT PRIMARY KEY,
79 join_time REAL,
80 total_active REAL DEFAULT 0,
81 total_afk REAL DEFAULT 0,
82 is_afk INTEGER DEFAULT 0,
83 afk_start REAL,
84 active_start REAL,
85 channel_name TEXT,
86 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
87 )""")
88
89 c.execute("CREATE INDEX IF NOT EXISTS idx_voice_activity_user_id ON voice_activity(user_id)")
90 c.execute("CREATE INDEX IF NOT EXISTS idx_cron_jobs_active ON cron_jobs(active)")
91
92 c.execute("""
93 CREATE TABLE IF NOT EXISTS triggers (
94 id INTEGER PRIMARY KEY AUTOINCREMENT,
95 name TEXT NOT NULL UNIQUE,
96 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
97 last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
98 )""")
99
100 c.execute("""
101 CREATE TABLE IF NOT EXISTS trigger_keywords (
102 id INTEGER PRIMARY KEY AUTOINCREMENT,
103 trigger_id INTEGER NOT NULL,
104 keyword TEXT NOT NULL,
105 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
106 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE,
107 UNIQUE(trigger_id, keyword)
108 )""")
109
110 c.execute("""
111 CREATE TABLE IF NOT EXISTS trigger_responses (
112 id INTEGER PRIMARY KEY AUTOINCREMENT,
113 trigger_id INTEGER NOT NULL,
114 response_text TEXT NOT NULL,
115 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
116 FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
117 )""")
118
119 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_trigger_id ON trigger_keywords(trigger_id)")
120 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_keywords_keyword ON trigger_keywords(keyword)")
121 c.execute("CREATE INDEX IF NOT EXISTS idx_trigger_responses_trigger_id ON trigger_responses(trigger_id)")
122
123 conn.commit()
124 logger.info("Database initialized successfully")
125 finally:
126 conn.close()
diff --git a/cogs/utils/file_search.py b/cogs/utils/file_search.py
deleted file mode 100644
index 788f50c..0000000
--- a/cogs/utils/file_search.py
+++ /dev/null
@@ -1,167 +0,0 @@
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 json
7import logging
8import aiohttp
9import os
10from typing import List
11from cogs.utils.settings import get_baseurl
12
13logger = logging.getLogger(__name__)
14
15
16class UniversalSearcher:
17 """Handles searching for files via copyparty API."""
18
19 @staticmethod
20 async def search_copyparty(query: str, max_results: int = 25, file_extensions: List[str] | None = None):
21 """Search for files using copyparty API (jPOST method).
22
23 Args:
24 query: Search query string
25 max_results: Maximum number of results to return
26 file_extensions: Optional list of file extensions to filter by (e.g., ['mp3', 'wav'])
27
28 Returns:
29 List of matching file URLs
30 """
31 if not query:
32 return []
33
34 query = query.strip()
35 logger.info(f"Starting copyparty search for query: '{query}'")
36
37 tokens = query.split()
38 if not tokens:
39 return []
40
41 token_queries = []
42 for token in tokens:
43 path_query = f'path like "*{token}*"'
44 name_query = f'name like "*{token}*"'
45 token_queries.append(f"({path_query} or {name_query})")
46
47 if len(token_queries) == 1:
48 name_query = token_queries[0]
49 else:
50 name_query = " and ".join(token_queries)
51
52 if file_extensions:
53 ext_filters = [f"name like \"*.{ext.lower()}\"" for ext in file_extensions]
54 ext_query = " or ".join(ext_filters)
55 query = f"{name_query} and ({ext_query})"
56 else:
57 query = name_query
58
59 try:
60 baseurl = get_baseurl()
61 password = os.getenv("COPYPARTY_PASSWORD")
62
63 async with aiohttp.ClientSession() as session:
64 headers = {"Content-Type": "application/json"}
65 if password:
66 headers["PW"] = password
67 async with session.post(f"{baseurl}/?srch", headers=headers, data=json.dumps({"n": max_results, "q": query})) as response:
68 if response.status == 200:
69 data = await response.json()
70 files = UniversalSearcher._extract_files_from_response(data)
71 results = UniversalSearcher._convert_files_to_urls(files, baseurl, max_results)
72
73 logger.info(f"Copyparty search complete. Found {len(results)} matches.")
74 return results
75 else:
76 logger.error(f"Copyparty API returned status {response.status}")
77 return []
78
79 except aiohttp.ClientError as e:
80 logger.error(f"HTTP error during copyparty search: {e}")
81 return []
82 except Exception as e:
83 logger.error(f"Error during copyparty search: {str(e)}", exc_info=True)
84 return []
85
86 @staticmethod
87 def _extract_files_from_response(data):
88 """Extract files list from copyparty API response."""
89 if isinstance(data, dict):
90 if 'hits' in data:
91 return data['hits']
92 elif 'files' in data:
93 return data['files']
94 elif 'results' in data:
95 return data['results']
96 else:
97 return data if isinstance(data, list) else []
98 elif isinstance(data, list):
99 return data
100 else:
101 return []
102
103 @staticmethod
104 def _convert_files_to_urls(files, baseurl, max_results):
105 """Convert file info objects to URLs."""
106 results = []
107
108 for file_info in files[:max_results]:
109 if isinstance(file_info, dict):
110 file_url = UniversalSearcher._extract_url_from_dict(file_info, baseurl)
111 if file_url:
112 results.append(file_url)
113 elif isinstance(file_info, str):
114 if file_info.startswith('http'):
115 results.append(file_info)
116 else:
117 results.append(f"{baseurl}/{file_info.lstrip('/')}")
118
119 return results
120
121 @staticmethod
122 def _extract_url_from_dict(file_info, baseurl):
123 """Extract URL from file info dictionary."""
124 if 'url' in file_info:
125 return file_info['url']
126 elif 'rp' in file_info:
127 return f"{baseurl}/{file_info['rp']}"
128 elif 'path' in file_info:
129 return f"{baseurl}/{file_info['path'].lstrip('/')}"
130 elif 'name' in file_info:
131 return f"{baseurl}/{file_info['name'].lstrip('/')}"
132 else:
133 return None
134
135 @staticmethod
136 async def search_copyparty_autocomplete(query: str, max_results: int = 10):
137 """Wrapper for autocomplete searches using copyparty (general search - no file type filtering).
138
139 Args:
140 query: Search query string
141 max_results: Maximum number of results to return
142
143 Returns:
144 List of matching file URLs
145 """
146 if not query:
147 return []
148
149 return await UniversalSearcher.search_copyparty(query, max_results)
150
151 @staticmethod
152 async def search_copyparty_autocomplete_audio(query: str, max_results: int = 10):
153 """Wrapper for autocomplete searches using copyparty (audio files only).
154
155 Args:
156 query: Search query string
157 max_results: Maximum number of results to return
158
159 Returns:
160 List of matching file URLs (audio files only)
161 """
162 if not query:
163 return []
164
165 # Audio file extensions for music commands
166 audio_extensions = ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'mp4', "mkv"]
167 return await UniversalSearcher.search_copyparty(query, max_results, audio_extensions)
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py
deleted file mode 100644
index b1a585c..0000000
--- a/cogs/utils/settings.py
+++ /dev/null
@@ -1,114 +0,0 @@
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 logging
7import sqlite3
8import os
9from dataclasses import dataclass, field
10
11from cogs.utils.database import get_connection, init_database
12
13logger = logging.getLogger(__name__)
14
15DEFAULT_BASEURL = "http://localhost:3939"
16
17init_database()
18
19
20@dataclass
21class Settings:
22 volume: float = 1.0
23 baseurl: str = DEFAULT_BASEURL
24 _data: dict = field(default_factory=dict, init=False)
25
26 async def load(self) -> "Settings":
27 default_settings = {
28 "volume": 1.0,
29 "baseurl": DEFAULT_BASEURL,
30 }
31
32 try:
33 with get_connection() as conn:
34 c = conn.cursor()
35 c.execute("SELECT key, value FROM settings")
36 rows = c.fetchall()
37
38 if rows:
39 self._data = {row[0]: row[1] for row in rows}
40 self._sync_from_data()
41 logger.info("Settings loaded from database")
42 else:
43 self._data = default_settings.copy()
44 self._sync_from_data()
45 await self.save()
46 logger.info("Created default settings in database")
47 except 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 try:
56 self.volume = float(self._data.get("volume", 1.0))
57 except (ValueError, TypeError):
58 self.volume = 1.0
59 self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL)
60
61 def _sync_to_data(self):
62 self._data["volume"] = str(self.volume)
63 self._data["baseurl"] = self.baseurl
64
65 async def save(self):
66 self._sync_to_data()
67 with get_connection() as conn:
68 c = conn.cursor()
69 c.execute("PRAGMA foreign_keys = ON")
70 for key, value in self._data.items():
71 c.execute(
72 "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
73 (key, value),
74 )
75 conn.commit()
76
77
78_settings_instance: Settings | None = None
79
80
81async def load_settings() -> Settings:
82 global _settings_instance
83 _settings_instance = Settings()
84 await _settings_instance.load()
85 return _settings_instance
86
87
88async def save_settings():
89 if _settings_instance:
90 await _settings_instance.save()
91
92
93def get_settings() -> Settings | None:
94 return _settings_instance
95
96
97def get_volume() -> float:
98 return _settings_instance.volume if _settings_instance else 1.0
99
100
101def get_baseurl() -> str:
102 url = os.getenv("COPYPARTY_URL")
103 if not url:
104 logger.warning("COPYPARTY_URL not set! Using default http://localhost:3939")
105 return DEFAULT_BASEURL
106 return url.rstrip("/")
107
108
109def get_uploadurl() -> str:
110 url = os.getenv("COPYPARTY_URL_UPLOAD")
111 if not url:
112 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default http://localhost:3939")
113 return DEFAULT_BASEURL
114 return url.rstrip("/")
diff --git a/cogs/utils/temp.py b/cogs/utils/temp.py
deleted file mode 100644
index 0b2c8d7..0000000
--- a/cogs/utils/temp.py
+++ /dev/null
@@ -1,18 +0,0 @@
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
diff --git a/cogs/utils/url_helper.py b/cogs/utils/url_helper.py
deleted file mode 100644
index eac06f1..0000000
--- a/cogs/utils/url_helper.py
+++ /dev/null
@@ -1,21 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6
7def clean_url_for_ytdlp(url: str) -> str:
8 """Clean URL by removing everything after the first '&'.
9
10 This prevents yt-dlp from interpreting additional query parameters
11 (like &list=...) as playlist URLs.
12
13 Example:
14 Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM&
15 Output: https://www.youtube.com/watch?v=lPGipwoJiOM
16 """
17 # Find the first '&' and truncate
18 first_ampersand = url.find('&')
19 if first_ampersand != -1:
20 return url[:first_ampersand]
21 return url
diff --git a/cogs/vclogs.py b/cogs/vclogs.py
deleted file mode 100644
index 35826d4..0000000
--- a/cogs/vclogs.py
+++ /dev/null
@@ -1,316 +0,0 @@
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 datetime
7import logging
8import os
9import asyncio
10import discord
11from discord.ext import commands
12from gtts import gTTS
13from cogs.voice import get_voice_manager
14from cogs.utils.settings import get_volume, save_settings
15from cogs.utils.database import get_connection, init_database
16
17import tempfile
18
19logger = logging.getLogger(__name__)
20
21init_database()
22
23
24def load_activity_data():
25 try:
26 with get_connection() as conn:
27 c = conn.cursor()
28 c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity")
29 rows = c.fetchall()
30
31 return {
32 row[0]: {
33 "join_time": row[1],
34 "total_active": row[2],
35 "total_afk": row[3],
36 "is_afk": bool(row[4]),
37 "afk_start": row[5],
38 "active_start": row[6],
39 "channel_name": row[7],
40 }
41 for row in rows
42 }
43 except Exception as e:
44 logger.error(f"Error loading voice activity: {e}")
45 return {}
46
47
48def save_activity_data(data):
49 try:
50 with get_connection() as conn:
51 c = conn.cursor()
52 c.execute("PRAGMA foreign_keys = ON")
53
54 c.execute("DELETE FROM voice_activity")
55
56 for user_id, user_data in data.items():
57 c.execute(
58 """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name)
59 VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
60 (
61 user_id,
62 user_data.get("join_time"),
63 user_data.get("total_active", 0),
64 user_data.get("total_afk", 0),
65 int(user_data.get("is_afk", False)),
66 user_data.get("afk_start"),
67 user_data.get("active_start"),
68 user_data.get("channel_name"),
69 ),
70 )
71 conn.commit()
72 logger.info(f"Saved voice activity for {len(data)} users")
73 except Exception as e:
74 logger.error(f"Error saving voice activity: {e}")
75
76
77def format_duration(seconds):
78 hours, remainder = divmod(int(seconds), 3600)
79 minutes, seconds = divmod(remainder, 60)
80 if hours > 0:
81 return f"{hours}h {minutes}m {seconds}s"
82 elif minutes > 0:
83 return f"{minutes}m {seconds}s"
84 return f"{seconds}s"
85
86
87class VcLogs(commands.Cog):
88 def __init__(self, bot):
89 self.bot = bot
90 self._voice_manager = None
91
92 def get_voice_manager(self):
93 if self._voice_manager is None:
94 self._voice_manager = get_voice_manager(self.bot)
95 return self._voice_manager
96
97 @staticmethod
98 async def handle_state_change(member, channel, state_type, is_enabled, log_channel):
99 """Helper method to handle voice state changes"""
100 state_messages = {
101 'deaf': {
102 True: f"{member.display_name} jest bity przez ojca",
103 False: f"{member.display_name} uciek od ojca"
104 },
105 'mute': {
106 True: f"{member.display_name} idzie robić jajecznice",
107 False: f"{member.display_name} zrobił jajecznice"
108 }
109 }
110
111 tts_text = state_messages[state_type][is_enabled]
112 return tts_text, channel
113
114 async def play_tts(self, channel, text):
115 from cogs.utils.temp import get_temp_dir
116
117 voice_mgr = self.get_voice_manager()
118 temp_dir = get_temp_dir()
119 tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3")
120 tts = gTTS(text=text, lang='pl')
121 logger.info("saving file")
122
123 loop = asyncio.get_event_loop()
124 await loop.run_in_executor(None, tts.save, tts_file)
125
126 try:
127 voice_status = await voice_mgr.get_voice_status(channel.guild)
128 voice_client = voice_status[0] if voice_status else None
129 is_connected = voice_status[1] if voice_status else False
130 is_playing = voice_status[2] if voice_status else False
131
132 if is_connected:
133 from cogs.voice import is_voice_client_alive
134 if not is_voice_client_alive(voice_client, channel.guild):
135 logger.warning("Stale voice client in play_tts, reconnecting")
136 try:
137 await voice_client.disconnect()
138 except Exception:
139 pass
140 is_connected = False
141
142 if not is_connected:
143 voice_client = await voice_mgr.join_channel(channel)
144 voice_status = await voice_mgr.get_voice_status(channel.guild)
145 voice_client = voice_status[0] if voice_status else None
146 is_connected = voice_status[1] if voice_status else False
147 is_playing = voice_status[2] if voice_status else False
148
149 if not is_connected or voice_client is None:
150 logger.error("Failed to join voice channel")
151 return
152
153 volume = get_volume()
154 logger.info(f"connected: {voice_client}")
155
156 if voice_client:
157 if is_playing:
158 logger.info("voice already playing, skipping TTS")
159 return
160
161 logger.info("preparing to play audio")
162 audio_source = discord.FFmpegPCMAudio(tts_file)
163 await voice_mgr.play_source(channel, audio_source, volume=volume)
164 except Exception as e:
165 logger.error(f"TTS playback error: {e}")
166 finally:
167 if os.path.exists(tts_file):
168 os.remove(tts_file)
169
170 @commands.slash_command(
171 name="log_volume"
172 )
173 async def volume(self, ctx: discord.ApplicationContext, volume: int):
174 from cogs.utils.settings import get_settings
175 settings = get_settings()
176 settings.volume = volume
177 await save_settings()
178 await ctx.followup.send(f"Volume has been set to: {volume}")
179 logger.info(f"Volume changed to: {volume}")
180
181 @commands.Cog.listener()
182 async def on_voice_state_update(self, member, before, after):
183 log_channel_id = os.getenv("STATUS_CHANNEL_ID")
184 if not log_channel_id:
185 logger.warning("STATUS_CHANNEL_ID not set in environment")
186 return
187
188 log_channel = self.bot.get_channel(int(log_channel_id))
189 if log_channel is None:
190 logger.warning("Log channel not found or not accessible")
191 return
192
193 current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
194 current_ts = datetime.datetime.now().timestamp()
195 tts_text = None
196 tts_channel = None
197
198 if member.bot:
199 return
200
201 user_id = str(member.id)
202 activity_data = load_activity_data()
203
204 if before.channel is None and after.channel is not None:
205 is_afk = after.self_mute or after.self_deaf
206 activity_data[user_id] = {
207 'join_time': current_ts,
208 'total_active': 0,
209 'total_afk': 0,
210 'is_afk': is_afk,
211 'afk_start': current_ts if is_afk else None,
212 'active_start': current_ts if not is_afk else None,
213 'channel_name': after.channel.name
214 }
215 save_activity_data(activity_data)
216
217 message = f"[{current_time}] {member.name} dołączył na {after.channel.name} <t:{int(current_ts)}:R>"
218 tts_text = f"{member.display_name} sie zespawnił"
219 tts_channel = after.channel
220 await log_channel.send(message)
221 logger.info(f"User joined voice channel: {message}")
222
223 elif before.channel is not None and after.channel is None:
224 if user_id in activity_data:
225 data = activity_data[user_id]
226
227 if data.get('is_afk') and data.get('afk_start'):
228 afk_duration = current_ts - data['afk_start']
229 data['total_afk'] += afk_duration
230 elif data.get('active_start'):
231 active_since_start = current_ts - data['active_start']
232 data['total_active'] += active_since_start
233
234 active_str = format_duration(data['total_active'])
235 afk_str = format_duration(data['total_afk'])
236
237 message = (
238 f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>\n"
239 f"Aktywny: {active_str} | AFK: {afk_str}"
240 )
241 tts_text = f"{member.display_name} jest chujem"
242 tts_channel = before.channel
243 await log_channel.send(message)
244 logger.info(f"User left voice channel: {message}")
245
246 del activity_data[user_id]
247 save_activity_data(activity_data)
248 else:
249 message = f"[{current_time}] {member.name} rozłączył się z {before.channel.name} <t:{int(current_ts)}:R>"
250 tts_text = f"{member.display_name} jest chujem"
251 tts_channel = before.channel
252 await log_channel.send(message)
253 logger.info(f"User left voice channel: {message}")
254
255 elif user_id in activity_data:
256 data = activity_data[user_id]
257
258 if data.get('is_afk') and not (after.self_mute or after.self_deaf):
259 afk_duration = current_ts - data['afk_start']
260 data['total_afk'] += afk_duration
261 data['afk_start'] = None
262 data['is_afk'] = False
263 data['active_start'] = current_ts
264 save_activity_data(activity_data)
265
266 afk_str = format_duration(afk_duration)
267
268 if after.self_mute:
269 activity = "odciszył"
270 else:
271 activity = "odgłuchnął"
272
273 message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n"
274 f"AFK: {afk_str}" )
275 await log_channel.send(message)
276 logger.info(f"User returned from AFK: {message}")
277 elif not data.get('is_afk') and (after.self_mute or after.self_deaf):
278 active_since_start = 0
279 if data.get('active_start'):
280 active_since_start = current_ts - data['active_start']
281 data['total_active'] += active_since_start
282 data['afk_start'] = current_ts
283 data['is_afk'] = True
284 data['active_start'] = None
285 save_activity_data(activity_data)
286
287 if after.self_mute:
288 activity = "wyciszył"
289 else:
290 activity = "ogłuchnął"
291
292 active_str = format_duration(active_since_start)
293 message = ( f"[{current_time}] {member.name} {activity} się na {after.channel.name} <t:{int(current_ts)}:R>\n"
294 f"Aktywny: {active_str}" )
295 await log_channel.send(message)
296 logger.info(f"User went AFK: {message}")
297
298 if before.self_deaf != after.self_deaf:
299 tts_text, tts_channel = await self.handle_state_change(
300 member, after.channel, 'deaf', after.self_deaf, log_channel
301 )
302 if tts_text and tts_channel:
303 await self.play_tts(tts_channel, tts_text)
304 return
305
306 if before.self_mute != after.self_mute:
307 tts_text, tts_channel = await self.handle_state_change(
308 member, after.channel, 'mute', after.self_mute, log_channel
309 )
310
311 if tts_text and tts_channel:
312 await self.play_tts(tts_channel, tts_text)
313
314
315def setup(bot):
316 bot.add_cog(VcLogs(bot))
diff --git a/cogs/voice.py b/cogs/voice.py
deleted file mode 100644
index a224336..0000000
--- a/cogs/voice.py
+++ /dev/null
@@ -1,444 +0,0 @@
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 discord
7import logging
8import asyncio
9import os
10import urllib.parse
11from dataclasses import dataclass, field
12from typing import Any
13
14import mutagen
15
16logger = logging.getLogger(__name__)
17
18
19_voice_managers: dict[int, "VoiceManager"] = {}
20
21
22def get_voice_manager(bot) -> "VoiceManager":
23 """Get or create VoiceManager for a specific bot instance."""
24 bot_id = bot.user.id if bot.user else id(bot)
25 if bot_id not in _voice_managers:
26 _voice_managers[bot_id] = VoiceManager(bot)
27 return _voice_managers[bot_id]
28
29
30def is_voice_client_alive(voice_client, guild):
31 """Check if a voice client connection is healthy."""
32 if voice_client is None:
33 return False
34 if not voice_client.is_connected():
35 return False
36 if voice_client.channel is None:
37 return False
38 if guild.get_channel(voice_client.channel.id) is None:
39 return False
40
41 try:
42 ws = voice_client._connection
43 s = ws.socket
44 if s is None or s.fileno() == -1:
45 return False
46 except Exception:
47 return False
48
49 return True
50
51
52@dataclass
53class VoiceManager:
54 bot: Any
55 _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
56 _connecting: set[int] = field(default_factory=set, init=False)
57
58 # --- Connection -----------------------------------------------------------
59
60 async def _ensure_voice_client(self, channel):
61 """Unified connect/move/reconnect logic.
62
63 Returns a healthy VoiceClient connected to *channel*, or None on failure.
64 """
65 guild = channel.guild
66 guild_id = guild.id
67
68 async with self._lock:
69 if guild_id in self._connecting:
70 logger.info(f"Connection already in progress for guild {guild_id}, skipping")
71 return None
72
73 vc = discord.utils.get(self.bot.voice_clients, guild=guild)
74
75 # --- resolve existing client --------------------------------------
76 if vc is not None:
77 if vc.is_connected():
78 if vc.channel == channel:
79 if is_voice_client_alive(vc, guild):
80 logger.info(f"Already connected to {channel}")
81 return vc
82 logger.warning(f"Stale connection to {channel}, reconnecting")
83 # Disconnect resets the state machine to 'disconnected'
84 # but keeps the VoiceClient alive. Reusing it avoids a
85 # race where Discord sends VOICE_SERVER_UPDATE between
86 # the destroy and the fresh-connect below, causing the
87 # new client to timeout waiting for the event (since the
88 # old one already consumed it).
89 try:
90 await vc.disconnect()
91 except Exception:
92 pass
93 try:
94 await vc.connect(
95 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
96 )
97 logger.info(f"Reconnected to voice channel: {channel}")
98 return vc
99 except Exception as e:
100 logger.error(f"Failed to reconnect stale voice client: {e}")
101 try:
102 channel._state._remove_voice_client(guild.id)
103 except Exception:
104 pass
105 vc = None
106 else:
107 try:
108 await vc.move_to(channel)
109 logger.info(f"Moved to voice channel: {channel}")
110 return vc
111 except Exception as e:
112 logger.error(f"Failed to move voice client: {e}")
113 try:
114 await vc.disconnect()
115 except Exception:
116 pass
117 try:
118 await vc.connect(
119 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
120 )
121 logger.info(f"Reconnected after failed move: {channel}")
122 return vc
123 except Exception:
124 pass
125 try:
126 channel._state._remove_voice_client(guild.id)
127 except Exception:
128 pass
129 vc = None
130 else:
131 logger.info("Voice client exists but disconnected, reconnecting")
132 try:
133 await vc.disconnect()
134 except Exception:
135 pass
136 try:
137 await vc.connect(
138 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
139 )
140 logger.info(f"Reconnected disconnected client: {channel}")
141 return vc
142 except Exception as e:
143 logger.error(f"Failed to reconnect disconnected client: {e}")
144 try:
145 channel._state._remove_voice_client(guild.id)
146 except Exception:
147 pass
148 vc = None
149
150 # --- create fresh connection --------------------------------------
151 self._connecting.add(guild_id)
152 try:
153 await asyncio.sleep(0.3)
154 try:
155 vc = await channel.connect(timeout=10.0, reconnect=False)
156 logger.info(f"Connected to voice channel: {channel}")
157 return vc
158 except discord.ClientException as e:
159 if "Already connected" in str(e):
160 logger.warning(f"Stale voice state detected, forcing reconnect: {e}")
161 vc = discord.utils.get(self.bot.voice_clients, guild=guild)
162 if vc:
163 try:
164 await vc.disconnect()
165 except Exception:
166 pass
167 else:
168 try:
169 channel._state._remove_voice_client(guild.id)
170 except Exception:
171 pass
172 await asyncio.sleep(0.3)
173 try:
174 vc = await channel.connect(timeout=10.0, reconnect=False)
175 logger.info(f"Reconnected to voice channel: {channel}")
176 return vc
177 except Exception as e2:
178 logger.error(f"Failed to reconnect after stale cleanup: {e2}")
179 return None
180 logger.error(f"Failed to connect to voice channel: {e}")
181 return None
182 except Exception as e:
183 logger.error(f"Failed to connect to voice channel: {e}")
184 return None
185 finally:
186 self._connecting.discard(guild_id)
187
188 async def connect(self, channel):
189 """Join a voice channel (public alias)."""
190 return await self._ensure_voice_client(channel)
191
192 async def join_channel(self, channel):
193 """Backward-compatible alias for connect()."""
194 return await self.connect(channel)
195
196 async def join_by_channel_id(self, channel_id):
197 """Join a voice channel by channel ID."""
198 channel = self.bot.get_channel(channel_id)
199 if not channel:
200 logger.error(f"Channel {channel_id} not found")
201 return None
202 if not isinstance(channel, discord.VoiceChannel):
203 logger.error(f"Channel {channel_id} is not a voice channel")
204 return None
205 return await self.connect(channel)
206
207 async def get_client(self, guild):
208 """Get existing voice client for a guild."""
209 return discord.utils.get(self.bot.voice_clients, guild=guild)
210
211 async def get_voice_client(self, guild):
212 """Backward-compatible alias for get_client()."""
213 return await self.get_client(guild)
214
215 async def get_status(self, guild):
216 """Get voice client status.
217
218 Returns:
219 tuple: (voice_client, is_connected, is_playing) or (None, False, False)
220 """
221 vc = await self.get_client(guild)
222 if vc is None:
223 return None, False, False
224 return vc, vc.is_connected(), vc.is_playing()
225
226 async def get_voice_status(self, guild):
227 """Backward-compatible alias for get_status()."""
228 return await self.get_status(guild)
229
230 async def disconnect(self, guild):
231 """Stop playback and disconnect from voice."""
232 vc = await self.get_client(guild)
233 if vc:
234 vc.stop()
235 logger.info("Disconnecting from voice")
236 await vc.disconnect()
237 logger.info(f"Disconnected from guild {guild}")
238
239 async def stop(self, guild):
240 """Backward-compatible alias for disconnect()."""
241 await self.disconnect(guild)
242
243 # --- Playback ------------------------------------------------------------
244
245 async def _play_await(self, vc, audio_source):
246 """Play an audio source and wait for completion using an asyncio.Future.
247
248 Returns True on success, False on error. No polling, no magic sleeps.
249 """
250 loop = asyncio.get_event_loop()
251 future = loop.create_future()
252
253 def after(error):
254 if not future.done():
255 if error:
256 future.set_exception(Exception(str(error)))
257 else:
258 future.set_result(True)
259
260 vc.play(audio_source, after=after)
261
262 try:
263 await future
264 return True
265 except Exception as e:
266 logger.error(f"Playback error: {e}")
267 return False
268
269 async def play_source(self, channel, audio_source, interrupt=True, volume=1.0):
270 """Connect to *channel* and play a pre-made audio source.
271
272 Returns True on success, False on failure.
273 """
274 vc = await self.connect(channel)
275 if not vc:
276 logger.error("play_source: could not connect to voice channel")
277 return False
278
279 if interrupt and (vc.is_playing() or vc.is_paused()):
280 vc.stop()
281
282 if abs(volume - 1.0) > 1e-9:
283 audio_source = discord.PCMVolumeTransformer(audio_source)
284 audio_source.volume = volume
285
286 logger.info("Playing via play_source")
287 return await self._play_await(vc, audio_source)
288
289 # --- Source preparation (folded from AudioPlaybackManager) ---------------
290
291 @staticmethod
292 async def validate_voice_channel(ctx):
293 """Validate user is in a voice channel. Returns the channel or None."""
294 if not ctx.author.voice or not ctx.author.voice.channel:
295 await ctx.respond(
296 "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True
297 )
298 return None
299 return ctx.author.voice.channel
300
301 @staticmethod
302 async def prepare_file_source(source):
303 """Validate a local file and return (display_name, absolute_path) or (None, None)."""
304 logger.info(f"Attempting to play local file: {source}")
305
306 if not os.path.isabs(source):
307 source = os.path.abspath(source)
308 logger.debug(f"Converted to absolute path: {source}")
309
310 if not os.path.exists(source):
311 logger.error(f"File not found: {source}")
312 return None, None
313
314 filename = os.path.basename(source)
315 display_name = os.path.splitext(filename)[0]
316 return display_name, source
317
318 @staticmethod
319 async def prepare_audio_source(source, is_url, display_name):
320 """Prepare FFmpegPCMAudio from a URL or local file.
321
322 Returns (audio_source, final_display_name).
323 """
324 if is_url:
325 before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"
326 audio_source = discord.FFmpegPCMAudio(source, before_options=before_options)
327 filename = os.path.basename(source.split("?")[0])
328 filename = urllib.parse.unquote(filename)
329 clean_filename = os.path.splitext(filename)[0]
330 return audio_source, clean_filename
331 else:
332 audio_source = discord.FFmpegPCMAudio(source=source)
333 try:
334 metadata = mutagen.open(source)
335 if metadata:
336 title = (
337 metadata.get("title", [None])[0]
338 if hasattr(metadata, "get")
339 else None
340 )
341 if title:
342 display_name = title
343 logger.debug(f"Read metadata - title: {title}")
344 else:
345 display_name = os.path.splitext(os.path.basename(source))[0]
346 except Exception as e:
347 logger.warning(f"Could not read metadata from {source}: {str(e)}")
348 display_name = os.path.splitext(os.path.basename(source))[0]
349
350 return audio_source, display_name
351
352 @staticmethod
353 async def prepare_source(source):
354 """Determine whether *source* is a URL or local file.
355
356 Returns (source_path, display_name, is_url) or (None, None, None) on failure.
357 """
358 is_url = source.startswith(("http://", "https://"))
359
360 if is_url:
361 logger.info(f"Attempting to play stream from URL: {source}")
362 return source, source, is_url
363
364 display_name, absolute_source = await VoiceManager.prepare_file_source(source)
365 if not display_name:
366 return None, None, None
367
368 return absolute_source, display_name, is_url
369
370 @staticmethod
371 async def cleanup_temp_file(source):
372 """Remove a temporary file if it exists."""
373 if source and os.path.exists(source):
374 try:
375 os.remove(source)
376 logger.info(f"Cleaned up temporary file: {source}")
377 except Exception as e:
378 logger.error(f"Error cleaning up file {source}: {str(e)}")
379
380 @staticmethod
381 async def cleanup_if_needed(source, delete_after_play, is_url):
382 """Conditionally clean up a file after playback."""
383 if delete_after_play and not is_url:
384 await VoiceManager.cleanup_temp_file(source)
385
386 # --- High-level play (full flow) -----------------------------------------
387
388 async def play(self, ctx, source, delete_after_play=False, interrupt=True):
389 """High-level playback: validate context, prepare source, connect, play, cleanup.
390
391 Sends ``grom: {name}`` immediately on start, and an error follow-up
392 if playback fails.
393
394 Args:
395 ctx: Discord application context (used for channel resolution & error messages).
396 source: Local file path or URL to stream.
397 delete_after_play: Remove local file after playback (no-op for URLs).
398 interrupt: Stop currently playing audio before starting.
399
400 Returns:
401 bool: True if playback completed successfully, False otherwise.
402 """
403 prepared_source, display_name, is_url = await VoiceManager.prepare_source(source)
404 if not prepared_source:
405 await VoiceManager.cleanup_if_needed(source, delete_after_play, False)
406 return False
407
408 target_channel = await VoiceManager.validate_voice_channel(ctx)
409 if not target_channel:
410 await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)
411 return False
412
413 vc = await self.connect(target_channel)
414 if not vc:
415 await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)
416 return False
417
418 if interrupt and (vc.is_playing() or vc.is_paused()):
419 vc.stop()
420
421 audio_source, final_display_name = await VoiceManager.prepare_audio_source(
422 prepared_source, is_url, display_name
423 )
424
425 try:
426 await ctx.followup.send(f"grom: {final_display_name}")
427 except Exception as e:
428 logger.error(e)
429
430 success = await self._play_await(vc, audio_source)
431
432 if success:
433 logger.info(f"Playback completed: {final_display_name}")
434 else:
435 logger.error(f"Playback failed: {prepared_source}")
436 try:
437 await ctx.followup.send(
438 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
439 )
440 except Exception:
441 pass
442 await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)
443
444 return success
diff --git a/cogs/web.py b/cogs/web.py
deleted file mode 100644
index fd22f9f..0000000
--- a/cogs/web.py
+++ /dev/null
@@ -1,90 +0,0 @@
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 discord
7from discord import Embed
8from discord.ext import commands
9import requests
10from bs4 import BeautifulSoup
11import urllib.parse
12import yt_dlp
13import logging
14from cogs.utils.url_helper import clean_url_for_ytdlp
15
16logger = logging.getLogger(__name__)
17
18
19class Web(commands.Cog):
20 def __init__(self, bot):
21 self.bot = bot
22
23 def _extract_youtube_title(self, url):
24 """Extract YouTube video title if URL is a YouTube link."""
25 if "youtube.com/watch" not in url:
26 return None
27
28 url = clean_url_for_ytdlp(url)
29 logger.info("youtube link")
30 try:
31 ydl_opts = {"quiet": True, "nowarnings": True}
32 with yt_dlp.YoutubeDL(ydl_opts) as ydl:
33 info = ydl.extract_info(url, download=False)
34 return info.get("title") if info else None
35 except Exception:
36 pass
37 return None
38
39 def _process_search_results(self, links, query):
40 """Process search results and create embed."""
41 embed = Embed(title="Search Results", description=f"Top results for: {query}", color=0x1E90FF)
42 view = discord.ui.View(disable_on_timeout=True)
43
44 for i, link in enumerate(links[:5], start=1):
45 title = link.text
46 youtube_title = self._extract_youtube_title(link.get("href"))
47 if youtube_title:
48 title = youtube_title
49
50 embed.add_field(name=f"Result {i}", value=f"[{title}]({link.get('href')})", inline=False)
51
52 return embed, view
53
54 @commands.slash_command(
55 name="web",
56 description="Search the web using DuckDuckGo"
57 )
58 async def web(
59 self,
60 ctx: discord.ApplicationContext,
61 query: str
62 ):
63 await ctx.defer()
64 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"}
65 encoded_query = urllib.parse.quote_plus(query)
66 url = f"https://duckduckgo.com/html/?q={encoded_query}"
67 response = requests.get(url, headers=headers)
68
69 logger.info(f"Query: {query}")
70
71 if response.status_code != 200:
72 await ctx.respond(content=f"Error: {response.status_code}", ephemeral=True)
73 logger.error(f"Error: {response.status_code}")
74 return
75
76 soup = BeautifulSoup(response.text, "html.parser")
77 links = soup.find_all("a", class_="result__a")
78
79 if not links:
80 await ctx.respond(content="No results found.", ephemeral=True)
81 logger.warning("No results found")
82 return
83
84 embed, view = self._process_search_results(links, query)
85 await ctx.respond(embed=embed, view=view)
86 logger.info("Search results sent")
87
88
89def setup(bot):
90 bot.add_cog(Web(bot))