aboutsummaryrefslogtreecommitdiffstats
path: root/cogs
diff options
context:
space:
mode:
authorsalmonik <salmonik@noreply.codeberg.org>2026-04-19 18:10:09 +0200
committersalmonik <salmonik@noreply.codeberg.org>2026-04-19 18:10:09 +0200
commitf8e1cf36e1efa0602bf2e450835f2a67654762a6 (patch)
treefe6b12226d7eebe2a0dc6525de3414d0c3718cc8 /cogs
parentadd support for x^2+y^2=0,4 and alike (diff)
parentfix: proper aiohttp async context manager mocking in test (diff)
downloadsenator-sperma-f8e1cf36e1efa0602bf2e450835f2a67654762a6.tar.gz
senator-sperma-f8e1cf36e1efa0602bf2e450835f2a67654762a6.zip
Merge pull request 'massive changes' (#3) from dev into main
Reviewed-on: https://codeberg.org/salmonik/senator-sperma/pulls/3 stable enough
Diffstat (limited to 'cogs')
-rw-r--r--cogs/ai.py35
-rw-r--r--cogs/assistant.py13
-rw-r--r--cogs/compression.py1
-rw-r--r--cogs/cron.py8
-rw-r--r--cogs/downloader.py2
-rw-r--r--cogs/music/audio_playback.py55
-rw-r--r--cogs/music/play_commands.py112
-rw-r--r--cogs/plot.py188
-rw-r--r--cogs/qr.py8
-rw-r--r--cogs/trigger.py44
-rw-r--r--cogs/tts.py12
-rw-r--r--cogs/utils/settings.py11
-rw-r--r--cogs/utils/temp.py4
-rw-r--r--cogs/vclogs.py29
-rw-r--r--cogs/voice.py4
-rw-r--r--cogs/web.py2
16 files changed, 246 insertions, 282 deletions
diff --git a/cogs/ai.py b/cogs/ai.py
index 4a04693..6f79ac5 100644
--- a/cogs/ai.py
+++ b/cogs/ai.py
@@ -7,7 +7,7 @@ import os
7import discord 7import discord
8from discord.ext import commands 8from discord.ext import commands
9import logging 9import logging
10import requests 10import httpx
11from gtts import gTTS 11from gtts import gTTS
12from cogs.music import audio_playback 12from cogs.music import audio_playback
13import io 13import io
@@ -52,23 +52,24 @@ class AI(commands.Cog):
52 ], 52 ],
53 53
54 } 54 }
55 response = requests.post(url=url, headers=headers, json=payload) 55 async with httpx.AsyncClient() as client:
56 response_data = response.json() 56 response = await client.post(url=url, headers=headers, json=payload)
57 logger.info(response_data) 57 response_data = response.json()
58 if not response_data["choices"]: 58 logger.info(response_data)
59 await ctx.send_followup("bot siem zesroł") 59 if not response_data["choices"]:
60 else: 60 await ctx.send_followup("bot siem zesroł")
61 response_text = response_data["choices"][0]["message"]["content"]
62 if tts:
63 # Generate TTS
64 tts_obj = gTTS(text=response_text, lang='pl')
65 tts_buffer = io.BytesIO()
66 tts_obj.write_to_fp(tts_buffer)
67 tts_buffer.seek(0)
68 await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True)
69
70 else: 61 else:
71 await ctx.send_followup(response_text) 62 response_text = response_data["choices"][0]["message"]["content"]
63 if tts:
64 # Generate TTS
65 tts_obj = gTTS(text=response_text, lang='pl')
66 tts_buffer = io.BytesIO()
67 tts_obj.write_to_fp(tts_buffer)
68 tts_buffer.seek(0)
69 await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True)
70
71 else:
72 await ctx.send_followup(response_text)
72 73
73 74
74 75
diff --git a/cogs/assistant.py b/cogs/assistant.py
index 71f5150..21e4989 100644
--- a/cogs/assistant.py
+++ b/cogs/assistant.py
@@ -6,7 +6,7 @@
6import re 6import re
7import discord 7import discord
8from discord.ext import commands 8from discord.ext import commands
9import requests 9import httpx
10import json 10import json
11import logging 11import logging
12import shutil 12import shutil
@@ -133,11 +133,12 @@ class Jarvis(commands.Cog):
133 133
134 @staticmethod 134 @staticmethod
135 async def _make_api_request(payload, headers): 135 async def _make_api_request(payload, headers):
136 url = "https://openrouter.ai/api/v1/chat/completions" 136 async with httpx.AsyncClient() as client:
137 response = requests.post(url=url, headers=headers, json=payload) 137 url = "https://openrouter.ai/api/v1/chat/completions"
138 response_data = response.json() 138 response = await client.post(url=url, headers=headers, json=payload)
139 logger.info(response_data) 139 response_data = response.json()
140 return response_data 140 logger.info(response_data)
141 return response_data
141 142
142 @staticmethod 143 @staticmethod
143 def _parse_response(response_data): 144 def _parse_response(response_data):
diff --git a/cogs/compression.py b/cogs/compression.py
index 150239f..acc9eda 100644
--- a/cogs/compression.py
+++ b/cogs/compression.py
@@ -275,6 +275,7 @@ class Compression(commands.Cog):
275 if file_type == "Image": 275 if file_type == "Image":
276 command, output_path = command 276 command, output_path = command
277 elif file_type == "Audio": 277 elif file_type == "Audio":
278 # Audio output path remains unchanged - already processed in _process_audio
278 pass 279 pass
279 280
280 success = await self._execute_compression(command) 281 success = await self._execute_compression(command)
diff --git a/cogs/cron.py b/cogs/cron.py
index 6b83866..da8127b 100644
--- a/cogs/cron.py
+++ b/cogs/cron.py
@@ -74,7 +74,7 @@ class Cron(commands.Cog):
74 prev_time = cron.get_prev(datetime) 74 prev_time = cron.get_prev(datetime)
75 time_diff = abs((now - prev_time).total_seconds()) 75 time_diff = abs((now - prev_time).total_seconds())
76 result = time_diff <= 30 76 result = time_diff <= 30
77 logger.info( 77 logger.debug(
78 f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}" 78 f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}"
79 ) 79 )
80 return result 80 return result
@@ -92,13 +92,13 @@ class Cron(commands.Cog):
92 92
93 channel = self.bot.get_channel(channel_id) 93 channel = self.bot.get_channel(channel_id)
94 if not channel: 94 if not channel:
95 logger.warning(f"Channel {channel_id} not found for task {task['id']}") 95 logger.warning(f"Channel {channel_id} not found for task ID: {task.get('id')}")
96 return 96 return
97 97
98 logger.info(f"Found channel {channel} (type: {type(channel).__name__})") 98 logger.info(f"Found channel {channel} (type: {type(channel).__name__})")
99 99
100 perms = channel.permissions_for(channel.guild.me) 100 perms = channel.permissions_for(channel.guild.me)
101 logger.info( 101 logger.debug(
102 f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}" 102 f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}"
103 ) 103 )
104 104
@@ -151,7 +151,7 @@ class Cron(commands.Cog):
151 message = task["message"] 151 message = task["message"]
152 logger.info(f"Message to send: '{message}' | Channel: {channel.id}") 152 logger.info(f"Message to send: '{message}' | Channel: {channel.id}")
153 await channel.send(message) 153 await channel.send(message)
154 logger.info("Message sent successfully") 154 logger.debug("Message sent successfully")
155 155
156 async def audio_autocomplete(self, ctx: discord.AutocompleteContext): 156 async def audio_autocomplete(self, ctx: discord.AutocompleteContext):
157 """Autocomplete for audio files from file_search""" 157 """Autocomplete for audio files from file_search"""
diff --git a/cogs/downloader.py b/cogs/downloader.py
index d364b3a..36bd723 100644
--- a/cogs/downloader.py
+++ b/cogs/downloader.py
@@ -195,7 +195,7 @@ class Downloader(commands.Cog):
195 } 195 }
196 196
197 @staticmethod 197 @staticmethod
198 def _create_error_response(content: str, details: str, temp_dir: str) -> dict: 198 def _create_error_response(content: str, details: str, temp_dir: str | None) -> dict:
199 return { 199 return {
200 "status": "error", 200 "status": "error",
201 "type": "error", 201 "type": "error",
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py
index 1267fa7..28c6118 100644
--- a/cogs/music/audio_playback.py
+++ b/cogs/music/audio_playback.py
@@ -60,17 +60,18 @@ class AudioPlaybackManager:
60 """Connect or move to voice channel. Returns voice client.""" 60 """Connect or move to voice channel. Returns voice client."""
61 try: 61 try:
62 # Check if vc is valid and actually connected 62 # Check if vc is valid and actually connected
63 is_connected = vc is not None and vc.is_connected() 63 if vc is not None and vc.is_connected():
64 # Connect to voice channel if not connected 64 # Already connected - check if need to move
65 if not is_connected: 65 if vc.channel != target_channel:
66 logger.info(f"Connecting to voice channel: {target_channel.name}") 66 logger.info(f"Moving to voice channel: {target_channel.name}")
67 vc = await target_channel.connect() 67 await vc.move_to(target_channel)
68 logger.info(f"Connected to voice channel: {target_channel.name}") 68 logger.info(f"Moved to voice channel: {target_channel.name}")
69 # Move to user's channel if in a different one 69 return vc
70 elif vc.channel != target_channel: 70
71 logger.info(f"Moving to voice channel: {target_channel.name}") 71 # Not connected - connect now
72 await vc.move_to(target_channel) 72 logger.info(f"Connecting to voice channel: {target_channel.name}")
73 logger.info(f"Moved to voice channel: {target_channel.name}") 73 vc = await target_channel.connect()
74 logger.info(f"Connected to voice channel: {target_channel.name}")
74 return vc 75 return vc
75 except Exception as e: 76 except Exception as e:
76 logger.error(f"Error connecting to voice channel: {str(e)}") 77 logger.error(f"Error connecting to voice channel: {str(e)}")
@@ -138,15 +139,35 @@ class AudioPlaybackManager:
138 @staticmethod 139 @staticmethod
139 async def execute_playback(ctx, source, display_name, is_url): 140 async def execute_playback(ctx, source, display_name, is_url):
140 """Execute the actual audio playback.""" 141 """Execute the actual audio playback."""
141 vc = ctx.voice_client
142 target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) 142 target_channel = await AudioPlaybackManager.validate_voice_channel(ctx)
143 if not target_channel: 143 if not target_channel:
144 return False 144 return False
145 145
146 # Get existing voice client from bot or connect
147 guild = ctx.guild
148 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
149
146 try: 150 try:
147 vc = await AudioPlaybackManager.connect_to_voice_channel( 151 # Handle various connection states
148 ctx, vc, target_channel 152 if vc is None:
149 ) 153 logger.info("No existing voice client, connecting...")
154 try:
155 vc = await target_channel.connect()
156 except discord.ClientException:
157 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
158 elif not vc.is_connected():
159 logger.info("Voice client exists but disconnected, reconnecting...")
160 try:
161 await vc.disconnect()
162 except Exception:
163 pass
164 try:
165 vc = await target_channel.connect()
166 except discord.ClientException:
167 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
168 elif vc.channel != target_channel:
169 logger.info(f"Moving to voice channel: {target_channel.name}")
170 await vc.move_to(target_channel)
150 171
151 # Stop any currently playing audio 172 # Stop any currently playing audio
152 if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] 173 if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr]
@@ -171,7 +192,7 @@ class AudioPlaybackManager:
171 # Play the audio 192 # Play the audio
172 vc.play(audio_source, after=after_playing) 193 vc.play(audio_source, after=after_playing)
173 logger.info(f"Playing: {final_display_name}") 194 logger.info(f"Playing: {final_display_name}")
174 await ctx.respond(f"grom: {final_display_name}") 195 await ctx.followup.send(f"grom: {final_display_name}")
175 196
176 # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds 197 # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds
177 await asyncio.sleep(5) 198 await asyncio.sleep(5)
@@ -195,7 +216,7 @@ class AudioPlaybackManager:
195 216
196 except Exception as e: 217 except Exception as e:
197 logger.error(f"Error in play: {str(e)}", exc_info=True) 218 logger.error(f"Error in play: {str(e)}", exc_info=True)
198 await ctx.respond(f"eror: {str(e)}", ephemeral=True) 219 await ctx.followup.send(f"eror: {str(e)}", ephemeral=True)
199 return False 220 return False
200 221
201 @staticmethod 222 @staticmethod
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
index fd5113a..5373436 100644
--- a/cogs/music/play_commands.py
+++ b/cogs/music/play_commands.py
@@ -6,18 +6,25 @@
6import logging 6import logging
7import os 7import os
8import urllib.parse 8import urllib.parse
9import asyncio
9from os import PathLike 10from os import PathLike
10from typing import Awaitable, Callable, List, Tuple 11from typing import Awaitable, Callable, List, Tuple
12import re
13from yt_dlp import YoutubeDL
11 14
12import discord 15import discord
13from discord.commands import OptionChoice 16from discord.commands import OptionChoice
14from discord.ext import commands 17from discord.ext import commands
15 18
16from cogs.music import audio_playback, media_processing 19from cogs.music import audio_playback, media_processing
17from cogs.utils import file_search as search_utils 20from cogs.utils.file_search import UniversalSearcher
18 21
19logger = logging.getLogger(__name__) 22logger = logging.getLogger(__name__)
20 23
24EXTERNAL_URL_PATTERN = re.compile(
25 r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))"
26)
27
21 28
22class PlayCommands(commands.Cog): 29class PlayCommands(commands.Cog):
23 """Discord slash commands for music playback.""" 30 """Discord slash commands for music playback."""
@@ -88,9 +95,12 @@ class PlayCommands(commands.Cog):
88 self, ctx: discord.AutocompleteContext 95 self, ctx: discord.AutocompleteContext
89 ) -> List[OptionChoice]: 96 ) -> List[OptionChoice]:
90 """Autocomplete handler for search queries.""" 97 """Autocomplete handler for search queries."""
98 if ctx.value.lower().startswith(("http://", "https://")):
99 return []
100
91 query = ctx.value.lower() 101 query = ctx.value.lower()
92 try: 102 try:
93 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( 103 results = await UniversalSearcher.search_copyparty_autocomplete_audio(
94 query, max_results=25 104 query, max_results=25
95 ) 105 )
96 choices = [] 106 choices = []
@@ -139,9 +149,10 @@ class PlayCommands(commands.Cog):
139 local_path = os.path.join(self.temp_dir, filename) 149 local_path = os.path.join(self.temp_dir, filename)
140 os.makedirs(self.temp_dir, exist_ok=True) 150 os.makedirs(self.temp_dir, exist_ok=True)
141 151
152 loop = asyncio.get_event_loop()
142 with open(local_path, "wb") as f: 153 with open(local_path, "wb") as f:
143 async for chunk in response.content.iter_chunked(8192): 154 async for chunk in response.content.iter_chunked(8192):
144 f.write(chunk) 155 await loop.run_in_executor(None, f.write, chunk)
145 156
146 compressed_path = await media_processing.AudioCompressor.compress( 157 compressed_path = await media_processing.AudioCompressor.compress(
147 ctx, local_path 158 ctx, local_path
@@ -162,6 +173,96 @@ class PlayCommands(commands.Cog):
162 except Exception: 173 except Exception:
163 pass 174 pass
164 175
176 async def _handle_external_url_play(
177 self, ctx: discord.ApplicationContext, url: str
178 ) -> None:
179 """Download from external URL, upload to copyparty, then play."""
180 local_path = None
181 try:
182 ytdl_opts = {
183 "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"),
184 "postprocessors": [
185 {
186 "key": "FFmpegExtractAudio",
187 "preferredcodec": "mp3",
188 "preferredquality": "192",
189 }
190 ],
191 "force_ipv4": True,
192 }
193
194 loop = asyncio.get_event_loop()
195 with YoutubeDL(ytdl_opts) as ydl:
196 info_dict = await loop.run_in_executor(
197 None, lambda: ydl.extract_info(url, download=True)
198 )
199 title = info_dict.get("title", "nieznany")
200 base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0]
201 local_path = f"{base_filename}.mp3"
202
203 if not os.path.exists(local_path):
204 await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True)
205 return
206
207 upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path))
208
209 await audio_playback.AudioPlaybackManager.play(
210 ctx, local_path, delete_after_play=True
211 )
212
213 try:
214 await asyncio.wait_for(upload_task, timeout=30.0)
215 except asyncio.TimeoutError:
216 logger.warning("Upload timed out")
217 except Exception as e:
218 logger.error(f"Error downloading external URL: {str(e)}", exc_info=True)
219 if local_path and os.path.exists(local_path):
220 try:
221 os.remove(local_path)
222 except Exception:
223 pass
224 await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True)
225
226 async def _upload_to_copyparty_async(self, file_path: str) -> None:
227 """Upload file to copyparty asynchronously (fire and forget)."""
228 import aiohttp
229
230 try:
231 logger.info(f"Starting async upload: {file_path}")
232 baseurl = os.getenv("COPYPARTY_URL_UPLOAD")
233 password = os.getenv("COPYPARTY_PASSWORD", "")
234
235 logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}")
236
237 if not baseurl:
238 logger.error("COPYPARTY_URL_UPLOAD not set!")
239 return
240
241 with open(file_path, "rb") as f:
242 content = f.read()
243
244 files = aiohttp.FormData()
245 files.add_field(
246 "f",
247 content,
248 filename=os.path.basename(file_path),
249 content_type="application/octet-stream",
250 )
251 headers = {"Cookie": f"cppwd={password}"}
252 params = {"j": "1", "ck": "b2"}
253
254 logger.info(f"Posting to {baseurl}")
255 async with aiohttp.ClientSession() as session:
256 async with session.post(baseurl, data=files, headers=headers, params=params) as response:
257 text = await response.text()
258 logger.info(f"Upload response: {response.status} - {text[:200] if text else 'empty'}")
259
260 if response.status in [200, 201]:
261 result = await response.json()
262 logger.info(f"Uploaded to copyparty: {result.get('fileurl')}")
263 except Exception as e:
264 logger.error(f"Async upload failed: {e}", exc_info=True)
265
165 async def _handle_local_file_play( 266 async def _handle_local_file_play(
166 self, ctx: discord.ApplicationContext, file: str, compressed: bool 267 self, ctx: discord.ApplicationContext, file: str, compressed: bool
167 ) -> None: 268 ) -> None:
@@ -209,7 +310,10 @@ class PlayCommands(commands.Cog):
209 await audio_playback.AudioPlaybackManager.play(ctx, file_path) 310 await audio_playback.AudioPlaybackManager.play(ctx, file_path)
210 else: 311 else:
211 try: 312 try:
212 await self._handle_local_file_play(ctx, melodia, compressed) 313 if EXTERNAL_URL_PATTERN.match(melodia.lower()):
314 await self._handle_external_url_play(ctx, melodia)
315 else:
316 await self._handle_local_file_play(ctx, melodia, compressed)
213 except Exception as e: 317 except Exception as e:
214 logger.error(f"Error playing: {str(e)}", exc_info=True) 318 logger.error(f"Error playing: {str(e)}", exc_info=True)
215 await ctx.respond( 319 await ctx.respond(
diff --git a/cogs/plot.py b/cogs/plot.py
deleted file mode 100644
index 4fcde44..0000000
--- a/cogs/plot.py
+++ /dev/null
@@ -1,188 +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 math
9import os
10import re
11
12import discord
13from discord.ext import commands
14
15logger = logging.getLogger(__name__)
16
17_plt_module = None
18_plot_available = False
19
20
21def _ensure_plot():
22 global _plt_module, _plot_available
23
24 if _plt_module is not None:
25 return _plot_available
26
27 try:
28 import matplotlib
29 matplotlib.use("Agg")
30 import matplotlib.pyplot as _plt_module
31 _plot_available = True
32 except ImportError:
33 _plot_available = False
34 logger.warning("matplotlib not available, plot command will be disabled")
35 return _plot_available
36
37
38def parse_expression(expr: str) -> str:
39 expr = expr.replace("^", "**")
40 expr = re.sub(r"(\d)([x])", r"\1*\2", expr)
41 expr = re.sub(r"([x])(\d)", r"\1*\2", expr)
42 expr = re.sub(r"(\d)([y])", r"\1*\2", expr)
43 expr = re.sub(r"([y])(\d)", r"\1*\2", expr)
44 return expr
45
46
47def is_implicit(expr: str) -> bool:
48 return "=" in expr and "y" in expr and "x" in expr
49
50
51def parse_implicit(expr: str) -> tuple[str, str]:
52 lhs, rhs = expr.split("=", 1)
53 lhs = parse_expression(lhs.strip())
54 rhs = parse_expression(rhs.strip())
55 return lhs, rhs
56
57
58class Plot(commands.Cog):
59 def __init__(self, bot):
60 self.bot = bot
61
62 def _generate_plot(self, func_expr: str, output_path: str):
63 global _plt_module
64
65 if not _ensure_plot():
66 raise RuntimeError("matplotlib not available")
67
68 plt = _plt_module
69
70 if is_implicit(func_expr):
71 self._generate_implicit_plot(func_expr, output_path)
72 return
73
74 parsed_expr = parse_expression(func_expr)
75
76 x = [i * 0.1 for i in range(-100, 101)]
77 y = []
78
79 for xi in x:
80 try:
81 result = eval(
82 parsed_expr,
83 {
84 "x": xi,
85 "math": math,
86 "sin": math.sin,
87 "cos": math.cos,
88 "tan": math.tan,
89 "sqrt": math.sqrt,
90 "log": math.log,
91 "exp": math.exp,
92 "pi": math.pi,
93 "e": math.e,
94 },
95 )
96 if math.isfinite(result):
97 y.append(result)
98 else:
99 y.append(float("nan"))
100 except Exception:
101 y.append(float("nan"))
102
103 fig, ax = plt.subplots(figsize=(10, 6))
104 ax.plot(x, y, linewidth=2, color="#00ff00")
105 ax.set_title(f"y = {func_expr}", fontsize=14)
106 ax.set_xlabel("x", fontsize=12)
107 ax.set_ylabel("y", fontsize=12)
108 ax.grid(True, alpha=0.3)
109 ax.axhline(y=0, color="white", linewidth=0.5)
110 ax.axvline(x=0, color="white", linewidth=0.5)
111 ax.set_facecolor("#1a1a1a")
112 fig.patch.set_facecolor("#1a1a1a")
113
114 plt.savefig(output_path, dpi=100, bbox_inches="tight")
115 plt.close()
116
117 def _generate_implicit_plot(self, func_expr: str, output_path: str):
118 global _plt_module
119 plt = _plt_module
120
121 lhs, rhs = parse_implicit(func_expr)
122
123 x_range = 20
124 y_range = 20
125
126 x_vals = []
127 y_vals = []
128
129 for xi in range(-x_range * 10, x_range * 10 + 1):
130 x = xi / 10.0
131 for yi in range(-y_range * 10, y_range * 10 + 1):
132 y = yi / 10.0
133 try:
134 left_val = eval(lhs, {"x": x, "y": y, "math": math, "sqrt": math.sqrt, "pi": math.pi, "e": math.e})
135 right_val = eval(rhs, {"x": x, "y": y, "math": math, "sqrt": math.sqrt, "pi": math.pi, "e": math.e})
136 diff = abs(left_val - right_val)
137 if math.isfinite(diff) and diff < 0.3:
138 x_vals.append(x)
139 y_vals.append(y)
140 except Exception:
141 pass
142
143 fig, ax = plt.subplots(figsize=(10, 10))
144 ax.scatter(x_vals, y_vals, s=1, color="#00ff00", alpha=0.5)
145 ax.set_title(f"{func_expr}", fontsize=14)
146 ax.set_xlabel("x", fontsize=12)
147 ax.set_ylabel("y", fontsize=12)
148 ax.grid(True, alpha=0.3)
149 ax.axhline(y=0, color="white", linewidth=0.5)
150 ax.axvline(x=0, color="white", linewidth=0.5)
151 ax.set_xlim(-x_range, x_range)
152 ax.set_ylim(-y_range, y_range)
153 ax.set_aspect("equal")
154 ax.set_facecolor("#1a1a1a")
155 fig.patch.set_facecolor("#1a1a1a")
156
157 plt.savefig(output_path, dpi=100, bbox_inches="tight")
158 plt.close()
159
160 @commands.slash_command(name="funkcja", description="Generuje wykres funkcji matematycznej")
161 async def plot(self, ctx, funkcja: str = discord.Option(str, "Funkcja np. x^3, sin(x), x^2 + 2*x - 1", required=True)):
162 if not _ensure_plot():
163 await ctx.respond("Generowanie wykresów nie jest dostępne (brak matplotlib)", ephemeral=True)
164 return
165
166 await ctx.defer()
167
168 from cogs.utils.temp import get_temp_dir
169 temp_path = os.path.join(get_temp_dir(), f"plot_{os.getpid()}_{asyncio.get_event_loop().time()}.png")
170
171 try:
172 loop = asyncio.get_event_loop()
173 await loop.run_in_executor(None, self._generate_plot, funkcja, temp_path)
174 await ctx.respond(file=discord.File(temp_path, filename="plot.png"))
175 logger.info(f"Generated plot for function: {funkcja}")
176 except Exception as e:
177 logger.error(f"Plot generation error: {e}")
178 await ctx.respond(f"Błąd generowania wykresu: {str(e)}", ephemeral=True)
179 finally:
180 try:
181 if os.path.exists(temp_path):
182 os.unlink(temp_path)
183 except Exception as e:
184 logger.error(f"Error cleaning up temp file: {e}")
185
186
187def setup(bot):
188 bot.add_cog(Plot(bot)) \ No newline at end of file
diff --git a/cogs/qr.py b/cogs/qr.py
index c1fd499..32c574e 100644
--- a/cogs/qr.py
+++ b/cogs/qr.py
@@ -22,10 +22,10 @@ _decode_available = False
22 22
23def _ensure_qr(): 23def _ensure_qr():
24 global _pil_image, _decode_func, _decode_available 24 global _pil_image, _decode_func, _decode_available
25 25
26 if _pil_image is not None: 26 if _pil_image is not None:
27 return _decode_available 27 return _decode_available
28 28
29 try: 29 try:
30 from PIL import Image 30 from PIL import Image
31 from pyzbar.pyzbar import decode as _decode_func 31 from pyzbar.pyzbar import decode as _decode_func
@@ -48,7 +48,7 @@ class QR(commands.Cog):
48 if response.status == 200: 48 if response.status == 200:
49 content = await response.read() 49 content = await response.read()
50 with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f: 50 with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
51 f.write(content) 51 await asyncio.get_event_loop().run_in_executor(None, f.write, content)
52 return f.name 52 return f.name
53 return None 53 return None
54 54
@@ -137,7 +137,7 @@ class QR(commands.Cog):
137 137
138 def _decode_qr(self, image_path): 138 def _decode_qr(self, image_path):
139 """Decode QR code from image (runs in executor)""" 139 """Decode QR code from image (runs in executor)"""
140 if not _ensure_qr(): 140 if not _ensure_qr() or _decode_func is None:
141 return None 141 return None
142 142
143 try: 143 try:
diff --git a/cogs/trigger.py b/cogs/trigger.py
index 0e76cef..02e5573 100644
--- a/cogs/trigger.py
+++ b/cogs/trigger.py
@@ -15,6 +15,12 @@ logger = logging.getLogger(__name__)
15 15
16DB_PATH = 'data/triggers.db' 16DB_PATH = 'data/triggers.db'
17 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
18 24
19def get_connection(): 25def get_connection():
20 return sqlite3.connect(DB_PATH) 26 return sqlite3.connect(DB_PATH)
@@ -112,10 +118,10 @@ class Trigger(commands.Cog):
112 c = conn.cursor() 118 c = conn.cursor()
113 c.execute("SELECT id, name FROM triggers") 119 c.execute("SELECT id, name FROM triggers")
114 for trigger_id, name in c.fetchall(): 120 for trigger_id, name in c.fetchall():
115 c.execute("SELECT keyword FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) 121 c.execute(_SELECT_KEYWORD_SQL, (trigger_id,))
116 keywords = [row[0] for row in c.fetchall()] 122 keywords = [row[0] for row in c.fetchall()]
117 if any(keyword in content for keyword in keywords): 123 if any(keyword in content for keyword in keywords):
118 c.execute("SELECT response_text FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) 124 c.execute(_SELECT_RESPONSE_SQL, (trigger_id,))
119 responses = [row[0] for row in c.fetchall()] 125 responses = [row[0] for row in c.fetchall()]
120 if responses: 126 if responses:
121 await message.channel.send(random.choice(responses).replace('\\n', '\n')) 127 await message.channel.send(random.choice(responses).replace('\\n', '\n'))
@@ -137,7 +143,7 @@ class Trigger(commands.Cog):
137 ctx: discord.ApplicationContext, 143 ctx: discord.ApplicationContext,
138 name = discord.Option( 144 name = discord.Option(
139 str, 145 str,
140 "the name of the trigger", 146 _TRIGGER_NAME_OPTION,
141 autocomplete= discord.utils.basic_autocomplete(get_type), 147 autocomplete= discord.utils.basic_autocomplete(get_type),
142 required=True 148 required=True
143 ) 149 )
@@ -157,7 +163,7 @@ class Trigger(commands.Cog):
157 ctx: discord.ApplicationContext, 163 ctx: discord.ApplicationContext,
158 name = discord.Option( 164 name = discord.Option(
159 str, 165 str,
160 "the name of the trigger", 166 _TRIGGER_NAME_OPTION,
161 autocomplete= discord.utils.basic_autocomplete(get_type), 167 autocomplete= discord.utils.basic_autocomplete(get_type),
162 required=True 168 required=True
163 ), 169 ),
@@ -174,10 +180,10 @@ class Trigger(commands.Cog):
174 ): 180 ):
175 with get_connection() as conn: 181 with get_connection() as conn:
176 c = conn.cursor() 182 c = conn.cursor()
177 c.execute("SELECT id FROM triggers WHERE name=?", (name,)) 183 c.execute(_SELECT_TRIGGER_ID_SQL, (name,))
178 row = c.fetchone() 184 row = c.fetchone()
179 if not row: 185 if not row:
180 await ctx.respond("Trigger not found.") 186 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
181 logger.warning(f"Trigger not found: {name}") 187 logger.warning(f"Trigger not found: {name}")
182 return 188 return
183 trigger_id = row[0] 189 trigger_id = row[0]
@@ -204,7 +210,7 @@ class Trigger(commands.Cog):
204 ctx: discord.ApplicationContext, 210 ctx: discord.ApplicationContext,
205 name = discord.Option( 211 name = discord.Option(
206 str, 212 str,
207 "the name of the trigger", 213 _TRIGGER_NAME_OPTION,
208 autocomplete= discord.utils.basic_autocomplete(get_type), 214 autocomplete= discord.utils.basic_autocomplete(get_type),
209 required=True 215 required=True
210 ), 216 ),
@@ -221,10 +227,10 @@ class Trigger(commands.Cog):
221 ): 227 ):
222 with get_connection() as conn: 228 with get_connection() as conn:
223 c = conn.cursor() 229 c = conn.cursor()
224 c.execute("SELECT id FROM triggers WHERE name=?", (name,)) 230 c.execute(_SELECT_TRIGGER_ID_SQL, (name,))
225 row = c.fetchone() 231 row = c.fetchone()
226 if not row: 232 if not row:
227 await ctx.respond("Trigger not found.") 233 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
228 logger.warning(f"Trigger not found: {name}") 234 logger.warning(f"Trigger not found: {name}")
229 return 235 return
230 trigger_id = row[0] 236 trigger_id = row[0]
@@ -245,17 +251,17 @@ class Trigger(commands.Cog):
245 ctx: discord.ApplicationContext, 251 ctx: discord.ApplicationContext,
246 name = discord.Option( 252 name = discord.Option(
247 str, 253 str,
248 "the name of the trigger", 254 _TRIGGER_NAME_OPTION,
249 autocomplete= discord.utils.basic_autocomplete(get_type), 255 autocomplete= discord.utils.basic_autocomplete(get_type),
250 required=True 256 required=True
251 ) 257 )
252 ): 258 ):
253 with get_connection() as conn: 259 with get_connection() as conn:
254 c = conn.cursor() 260 c = conn.cursor()
255 c.execute("SELECT id FROM triggers WHERE name=?", (name,)) 261 c.execute(_SELECT_TRIGGER_ID_SQL, (name,))
256 row = c.fetchone() 262 row = c.fetchone()
257 if not row: 263 if not row:
258 await ctx.respond("Trigger not found.") 264 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
259 return 265 return
260 trigger_id = row[0] 266 trigger_id = row[0]
261 c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) 267 c.execute("DELETE FROM trigger_keywords WHERE trigger_id=?", (trigger_id,))
@@ -275,7 +281,7 @@ class Trigger(commands.Cog):
275 ctx: discord.ApplicationContext, 281 ctx: discord.ApplicationContext,
276 name = discord.Option( 282 name = discord.Option(
277 str, 283 str,
278 "the name of the trigger", 284 _TRIGGER_NAME_OPTION,
279 autocomplete=discord.utils.basic_autocomplete(get_type), 285 autocomplete=discord.utils.basic_autocomplete(get_type),
280 required=True 286 required=True
281 ) 287 )
@@ -286,13 +292,13 @@ class Trigger(commands.Cog):
286 c.execute("SELECT id FROM triggers WHERE name=?", (name,)) 292 c.execute("SELECT id FROM triggers WHERE name=?", (name,))
287 row = c.fetchone() 293 row = c.fetchone()
288 if not row: 294 if not row:
289 await ctx.respond("Trigger not found.") 295 await ctx.respond(_TRIGGER_NOT_FOUND_MSG)
290 logger.warning(f"Trigger not found: {name}") 296 logger.warning(f"Trigger not found: {name}")
291 return 297 return
292 trigger_id = row[0] 298 trigger_id = row[0]
293 c.execute("SELECT keyword FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) 299 c.execute(_SELECT_KEYWORD_SQL, (trigger_id,))
294 triggers = [row[0] for row in c.fetchall()] 300 triggers = [row[0] for row in c.fetchall()]
295 c.execute("SELECT response_text FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) 301 c.execute(_SELECT_RESPONSE_SQL, (trigger_id,))
296 responses = [row[0] for row in c.fetchall()] 302 responses = [row[0] for row in c.fetchall()]
297 trigger_list = "\n".join(triggers) 303 trigger_list = "\n".join(triggers)
298 response_list = "\n".join(responses) 304 response_list = "\n".join(responses)
@@ -303,13 +309,13 @@ class Trigger(commands.Cog):
303 c.execute("SELECT id, name FROM triggers") 309 c.execute("SELECT id, name FROM triggers")
304 messages = [] 310 messages = []
305 for trigger_id, name in c.fetchall(): 311 for trigger_id, name in c.fetchall():
306 c.execute("SELECT keyword FROM trigger_keywords WHERE trigger_id=?", (trigger_id,)) 312 c.execute(_SELECT_KEYWORD_SQL, (trigger_id,))
307 triggers = ", ".join([row[0] for row in c.fetchall()]) 313 triggers = ", ".join([row[0] for row in c.fetchall()])
308 c.execute("SELECT response_text FROM trigger_responses WHERE trigger_id=?", (trigger_id,)) 314 c.execute(_SELECT_RESPONSE_SQL, (trigger_id,))
309 responses = ", ".join([row[0] for row in c.fetchall()]) 315 responses = ", ".join([row[0] for row in c.fetchall()])
310 messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}") 316 messages.append(f"**{name}**\nTriggers: {triggers}\nResponses: {responses}")
311 await ctx.respond("\n\n".join(messages)) 317 await ctx.respond("\n\n".join(messages))
312 logger.info(f"Triggers listed") 318 logger.info("Triggers listed")
313 319
314 @trigger_group.command( 320 @trigger_group.command(
315 description="how to use trigger group?" 321 description="how to use trigger group?"
diff --git a/cogs/tts.py b/cogs/tts.py
index 3dc4c09..68eb27f 100644
--- a/cogs/tts.py
+++ b/cogs/tts.py
@@ -54,19 +54,17 @@ class TTS(commands.Cog):
54 if not voice_client: 54 if not voice_client:
55 await ctx.respond("Failed to connect to voice channel", ephemeral=True) 55 await ctx.respond("Failed to connect to voice channel", ephemeral=True)
56 return 56 return
57 57
58 # Play the TTS audio with volume adjustment 58 if voice_client.is_playing():
59 await ctx.respond("Audio is already playing, try again later", ephemeral=True)
60 return
61
59 audio_source = discord.FFmpegPCMAudio( 62 audio_source = discord.FFmpegPCMAudio(
60 temp_path, 63 temp_path,
61 options=f'-filter:a "volume={volume}"' 64 options=f'-filter:a "volume={volume}"'
62 ) 65 )
63 voice_client.play(audio_source)
64 logger.info(f"Playing TTS: {text}") 66 logger.info(f"Playing TTS: {text}")
65 67
66 # Wait for the audio to finish playing
67 while voice_client.is_playing():
68 await asyncio.sleep(0.1)
69
70 except Exception as e: 68 except Exception as e:
71 logger.error(f"Error in voice channel handling: {e}") 69 logger.error(f"Error in voice channel handling: {e}")
72 await ctx.respond("eror :c", ephemeral=True) 70 await ctx.respond("eror :c", ephemeral=True)
diff --git a/cogs/utils/settings.py b/cogs/utils/settings.py
index 984ec16..d66ce7f 100644
--- a/cogs/utils/settings.py
+++ b/cogs/utils/settings.py
@@ -12,18 +12,19 @@ from dataclasses import dataclass, field
12logger = logging.getLogger(__name__) 12logger = logging.getLogger(__name__)
13 13
14settings_file = "data/settings.json" 14settings_file = "data/settings.json"
15DEFAULT_BASEURL = "http://localhost:8080"
15 16
16 17
17@dataclass 18@dataclass
18class Settings: 19class Settings:
19 volume: float = 1.0 20 volume: float = 1.0
20 baseurl: str = "http://localhost:8080" 21 baseurl: str = DEFAULT_BASEURL
21 _data: dict = field(default_factory=dict, init=False) 22 _data: dict = field(default_factory=dict, init=False)
22 23
23 async def load(self) -> "Settings": 24 async def load(self) -> "Settings":
24 default_settings = { 25 default_settings = {
25 "volume": 1.0, 26 "volume": 1.0,
26 "baseurl": "http://localhost:8080", 27 "baseurl": DEFAULT_BASEURL,
27 } 28 }
28 29
29 try: 30 try:
@@ -44,7 +45,7 @@ class Settings:
44 self._sync_from_data() 45 self._sync_from_data()
45 await self.save() 46 await self.save()
46 logger.info("Created default settings.json") 47 logger.info("Created default settings.json")
47 except (json.JSONDecodeError, Exception) as e: 48 except json.JSONDecodeError as e:
48 logger.error(f"Error loading settings: {e}") 49 logger.error(f"Error loading settings: {e}")
49 self._data = default_settings.copy() 50 self._data = default_settings.copy()
50 self._sync_from_data() 51 self._sync_from_data()
@@ -53,7 +54,7 @@ class Settings:
53 54
54 def _sync_from_data(self): 55 def _sync_from_data(self):
55 self.volume = self._data.get("volume", 1.0) 56 self.volume = self._data.get("volume", 1.0)
56 self.baseurl = self._data.get("baseurl", "http://localhost:8080") 57 self.baseurl = self._data.get("baseurl", DEFAULT_BASEURL)
57 58
58 def _sync_to_data(self): 59 def _sync_to_data(self):
59 self._data["volume"] = self.volume 60 self._data["volume"] = self.volume
@@ -89,4 +90,4 @@ def get_volume() -> float:
89 90
90 91
91def get_baseurl() -> str: 92def get_baseurl() -> str:
92 return _settings_instance.baseurl.rstrip("/") if _settings_instance else "http://localhost:8080" 93 return _settings_instance.baseurl.rstrip("/") if _settings_instance else DEFAULT_BASEURL
diff --git a/cogs/utils/temp.py b/cogs/utils/temp.py
index 528d6a3..0b2c8d7 100644
--- a/cogs/utils/temp.py
+++ b/cogs/utils/temp.py
@@ -8,11 +8,11 @@ import tempfile
8 8
9def get_temp_dir(subdir: str = "senator_sperma") -> str: 9def get_temp_dir(subdir: str = "senator_sperma") -> str:
10 """Get or create a temp subdirectory. 10 """Get or create a temp subdirectory.
11 11
12 Uses system temp directory (/tmp on Linux, %TEMP% on Windows). 12 Uses system temp directory (/tmp on Linux, %TEMP% on Windows).
13 OS handles cleanup automatically. 13 OS handles cleanup automatically.
14 """ 14 """
15 base_temp = tempfile.gettempdir() 15 base_temp = tempfile.gettempdir()
16 temp_path = os.path.join(base_temp, subdir) 16 temp_path = os.path.join(base_temp, subdir)
17 os.makedirs(temp_path, exist_ok=True) 17 os.makedirs(temp_path, exist_ok=True)
18 return temp_path \ No newline at end of file 18 return temp_path
diff --git a/cogs/vclogs.py b/cogs/vclogs.py
index 397d296..fb0a2e0 100644
--- a/cogs/vclogs.py
+++ b/cogs/vclogs.py
@@ -14,9 +14,11 @@ from gtts import gTTS
14from cogs.voice import get_voice_manager 14from cogs.voice import get_voice_manager
15from cogs.utils.settings import get_volume, save_settings 15from cogs.utils.settings import get_volume, save_settings
16 16
17import tempfile
18
17logger = logging.getLogger(__name__) 19logger = logging.getLogger(__name__)
18 20
19ACTIVITY_FILE = 'temp/voice_activity.json' 21ACTIVITY_FILE = os.path.join(tempfile.gettempdir(), 'voice_activity.json')
20 22
21 23
22def load_activity_data(): 24def load_activity_data():
@@ -100,12 +102,20 @@ class VcLogs(commands.Cog):
100 volume = get_volume() 102 volume = get_volume()
101 logger.info(f"connected: {voice_client}") 103 logger.info(f"connected: {voice_client}")
102 104
103 if voice_client and not is_playing: 105 if voice_client:
106 if is_playing:
107 logger.info("voice already playing, stopping current audio")
108 voice_client.stop()
109 await asyncio.sleep(0.1)
110
104 logger.info("preparing to play audio") 111 logger.info("preparing to play audio")
105 audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') 112 audio_source = discord.FFmpegPCMAudio(tts_file)
113 if volume != 1.0:
114 audio_source = discord.PCMVolumeTransformer(audio_source)
115 audio_source.volume = volume
106 voice_client.play(audio_source) 116 voice_client.play(audio_source)
107 while voice_client.is_playing(): 117 while voice_client.is_playing():
108 await asyncio.sleep(0.5) 118 await asyncio.sleep(0.1)
109 except Exception as e: 119 except Exception as e:
110 logger.error(f"TTS playback error: {e}") 120 logger.error(f"TTS playback error: {e}")
111 finally: 121 finally:
@@ -125,7 +135,16 @@ class VcLogs(commands.Cog):
125 135
126 @commands.Cog.listener() 136 @commands.Cog.listener()
127 async def on_voice_state_update(self, member, before, after): 137 async def on_voice_state_update(self, member, before, after):
128 log_channel = self.bot.get_channel(1213843195457183885) 138 log_channel_id = os.getenv("STATUS_CHANNEL_ID")
139 if not log_channel_id:
140 logger.warning("STATUS_CHANNEL_ID not set in environment")
141 return
142
143 log_channel = self.bot.get_channel(int(log_channel_id))
144 if log_channel is None:
145 logger.warning("Log channel not found or not accessible")
146 return
147
129 current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 148 current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
130 current_ts = datetime.datetime.now().timestamp() 149 current_ts = datetime.datetime.now().timestamp()
131 tts_text = None 150 tts_text = None
diff --git a/cogs/voice.py b/cogs/voice.py
index 07015cc..319bb16 100644
--- a/cogs/voice.py
+++ b/cogs/voice.py
@@ -95,7 +95,7 @@ class VoiceManager:
95 if not voice_client: 95 if not voice_client:
96 return False 96 return False
97 97
98 if volume != 1.0: 98 if abs(volume - 1.0) > 1e-9:
99 audio_source = discord.PCMVolumeTransformer(audio_source) 99 audio_source = discord.PCMVolumeTransformer(audio_source)
100 audio_source.volume = volume 100 audio_source.volume = volume
101 101
@@ -197,4 +197,4 @@ class Voice(commands.Cog):
197 197
198 198
199def setup(bot): 199def setup(bot):
200 bot.add_cog(Voice(bot)) \ No newline at end of file 200 bot.add_cog(Voice(bot))
diff --git a/cogs/web.py b/cogs/web.py
index e003399..b072e99 100644
--- a/cogs/web.py
+++ b/cogs/web.py
@@ -97,4 +97,4 @@ class Web(commands.Cog):
97 97
98 98
99def setup(bot): 99def setup(bot):
100 bot.add_cog(Web(bot)) \ No newline at end of file 100 bot.add_cog(Web(bot))