aboutsummaryrefslogtreecommitdiffstats
path: root/cogs
diff options
context:
space:
mode:
Diffstat (limited to 'cogs')
-rw-r--r--cogs/cron.py377
1 files changed, 377 insertions, 0 deletions
diff --git a/cogs/cron.py b/cogs/cron.py
new file mode 100644
index 0000000..6b83866
--- /dev/null
+++ b/cogs/cron.py
@@ -0,0 +1,377 @@
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 json
8import logging
9import os
10from datetime import datetime
11from venv import logger
12
13import discord
14from croniter import croniter
15from discord.ext import commands
16
17from cogs.utils import file_search as search_utils
18from cogs.voice import get_voice_manager
19
20logger = logging.getLogger(__name__)
21
22
23class Cron(commands.Cog):
24 def __init__(self, bot):
25 self.bot = bot
26 self.scheduled_tasks = []
27 self.cron_file = "data/cron_jobs.json"
28 self.load_tasks()
29 self.task = bot.loop.create_task(self.schedule_checker())
30
31 def load_tasks(self):
32 """Load scheduled tasks from file"""
33 try:
34 if os.path.exists(self.cron_file):
35 with open(self.cron_file, "r") as f:
36 self.scheduled_tasks = json.load(f)
37 logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs")
38 else:
39 self.scheduled_tasks = []
40 except Exception as e:
41 logger.error(f"Error loading cron jobs: {e}")
42 self.scheduled_tasks = []
43
44 def save_tasks(self):
45 """Save scheduled tasks to file"""
46 try:
47 os.makedirs(os.path.dirname(self.cron_file), exist_ok=True)
48 with open(self.cron_file, "w") as f:
49 json.dump(self.scheduled_tasks, f, indent=2)
50 except Exception as e:
51 logger.error(f"Error saving cron jobs: {e}")
52
53 async def schedule_checker(self):
54 """Background task to check and execute scheduled jobs"""
55 await self.bot.wait_until_ready()
56 while not self.bot.is_closed():
57 try:
58 now = datetime.now()
59 for task in self.scheduled_tasks:
60 if not task.get("active", True):
61 continue
62 if self.should_run_task(task, now):
63 await self.execute_task(task)
64 await asyncio.sleep(30)
65 except Exception as e:
66 logger.error(f"Error in schedule checker: {e}")
67 await asyncio.sleep(30)
68
69 def should_run_task(self, task, now):
70 """Check if a task should run now based on its cron expression"""
71 try:
72 cron_expr = task["cron_expression"]
73 cron = croniter(cron_expr, now)
74 prev_time = cron.get_prev(datetime)
75 time_diff = abs((now - prev_time).total_seconds())
76 result = time_diff <= 30
77 logger.info(
78 f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}"
79 )
80 return result
81 except Exception as e:
82 logger.error(
83 f"Error checking cron expression {task.get('cron_expression')}: {e}"
84 )
85 return False
86
87 async def execute_task(self, task):
88 """Execute a scheduled task"""
89 try:
90 task_type = task["type"]
91 channel_id = task["channel_id"]
92
93 channel = self.bot.get_channel(channel_id)
94 if not channel:
95 logger.warning(f"Channel {channel_id} not found for task {task['id']}")
96 return
97
98 logger.info(f"Found channel {channel} (type: {type(channel).__name__})")
99
100 perms = channel.permissions_for(channel.guild.me)
101 logger.info(
102 f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}"
103 )
104
105 if task_type in ("zagraj_audio", "audio"):
106 await self.play_audio_task(task, channel)
107 elif task_type in ("wyślij_wiadomość", "send_message"):
108 await self.send_message_task(task, channel)
109
110 logger.info(f"Executed task {task['id']} of type {task_type}")
111 except Exception as e:
112 logger.error(f"Error executing task {task.get('id')}: {e}")
113
114 async def play_audio_task(self, task, channel):
115 """Play audio task implementation using voice manager"""
116 audio_url = task["audio_filename"]
117
118 if isinstance(channel, discord.VoiceChannel):
119 voice_mgr = get_voice_manager(self.bot)
120 voice_client = await voice_mgr.join_channel(channel)
121 if not voice_client:
122 if channel.guild.system_channel:
123 await channel.guild.system_channel.send(
124 "Nie udano się połączyć z kanałem głosowym"
125 )
126 return
127
128 try:
129 audio_source = discord.FFmpegPCMAudio(audio_url)
130 voice_client.play(audio_source)
131
132 while voice_client.is_playing():
133 await asyncio.sleep(1)
134
135 await voice_client.disconnect()
136 logger.info(f"Finished playing audio: {audio_url}")
137 except Exception as e:
138 logger.error(f"Error playing audio: {e}")
139 if voice_client.is_connected():
140 await voice_client.disconnect()
141 if channel.guild.system_channel:
142 await channel.guild.system_channel.send(
143 f"Błąd podczas odgrywania audio: {audio_url}"
144 )
145 else:
146 if hasattr(channel, "send"):
147 await channel.send("Odtwarzanie audio wymaga kanału głosowego")
148
149 async def send_message_task(self, task, channel):
150 """Send message task implementation"""
151 message = task["message"]
152 logger.info(f"Message to send: '{message}' | Channel: {channel.id}")
153 await channel.send(message)
154 logger.info("Message sent successfully")
155
156 async def audio_autocomplete(self, ctx: discord.AutocompleteContext):
157 """Autocomplete for audio files from file_search"""
158 query = ctx.value.lower() if ctx.value else ""
159 if not query:
160 return ""
161
162 audio_extensions = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "mp4", "mkv"]
163 results = await search_utils.UniversalSearcher.search_copyparty(
164 query, max_results=25, file_extensions=audio_extensions
165 )
166
167 choices = []
168 for url in results[:25]:
169 filename = url.split("/")[-1]
170 display_name = filename[:98] + "…" if len(filename) > 100 else filename
171 choices.append(discord.OptionChoice(name=display_name, value=url))
172
173 return (
174 choices
175 if choices
176 else [discord.OptionChoice(name="Brak wyników", value="")]
177 )
178
179 @commands.slash_command(
180 name="cron_dodaj_audio", description="Dodaj nowe zadanie cron audio"
181 )
182 async def cron_add_audio(
183 self,
184 ctx,
185 cron_expression: str = discord.Option(
186 str,
187 "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)",
188 required=True,
189 ),
190 channel: discord.VoiceChannel = discord.Option(
191 discord.VoiceChannel,
192 "Kanał głosowy na którym będzie wykonywana akcja",
193 required=True,
194 ),
195 audio_filename: str = discord.Option(
196 str,
197 "Plik audio z serwera copyparty",
198 required=True,
199 autocomplete=audio_autocomplete,
200 ),
201 ):
202 try:
203 croniter(cron_expression)
204 except Exception:
205 await ctx.respond(
206 "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc",
207 ephemeral=True,
208 )
209 return
210
211 task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1
212
213 task = {
214 "id": task_id,
215 "cron_expression": cron_expression,
216 "type": "audio",
217 "channel_id": channel.id,
218 "active": True,
219 }
220
221 task["audio_filename"] = audio_filename
222
223 self.scheduled_tasks.append(task)
224 self.save_tasks()
225
226 await ctx.respond(
227 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>"
228 )
229
230 @commands.slash_command(
231 name="cron_dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość"
232 )
233 async def cron_add_message(
234 self,
235 ctx,
236 cron_expression: str = discord.Option(
237 str,
238 "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)",
239 required=True,
240 ),
241 channel: discord.TextChannel = discord.Option(
242 discord.TextChannel, "Kanał na którym będzie wykonana akcja", required=True
243 ),
244 message: str = discord.Option(str, "Wiadomość do wysłania", required=True),
245 ):
246 """Add a new cron job"""
247
248 try:
249 croniter(cron_expression)
250 except Exception:
251 await ctx.respond(
252 "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc",
253 ephemeral=True,
254 )
255 return
256
257 task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1
258
259 task = {
260 "id": task_id,
261 "cron_expression": cron_expression,
262 "type": "wiadomość",
263 "channel_id": channel.id,
264 "active": True,
265 "message": message,
266 }
267
268 self.scheduled_tasks.append(task)
269 self.save_tasks()
270
271 await ctx.respond(
272 f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>"
273 )
274
275 def _get_baseurl(self):
276 """Get base URL from settings"""
277 try:
278 from cogs.utils.settings import get_baseurl
279
280 return get_baseurl()
281 except Exception:
282 return ""
283
284 @commands.slash_command(
285 name="cron_pokaż", description="Pokaż wszystkie zadania cron"
286 )
287 async def cron_list(self, ctx):
288 """List all cron jobs"""
289 if not self.scheduled_tasks:
290 await ctx.respond("Brak zadań cron")
291 return
292
293 embed = discord.Embed(title="Zaplanowane prace cron", color=0x00FF00)
294
295 for task in self.scheduled_tasks:
296 channel = self.bot.get_channel(task["channel_id"])
297 channel_name = f"#{channel}"
298 status = "AKTYWNA" if task.get("active", True) else "PAUZA"
299
300 if task["type"] in ("zagraj_audio", "audio"):
301 action_desc = f"Zagraj audio: `{task.get('audio_filename', 'N/A')}`"
302 else:
303 msg = task.get("message", "")
304 action_desc = (
305 f"Wyślij wiadomość: `{msg[:50]}{'...' if len(msg) > 50 else ''}`"
306 )
307
308 embed.add_field(
309 name=f"Zadanie #{task['id']} ({status})",
310 value=f"**Czas:** `{task['cron_expression']}`\n**Akcja:** {action_desc}\n**Kanał:** {channel_name}",
311 inline=False,
312 )
313
314 await ctx.respond(embed=embed)
315
316 @commands.slash_command(name="cron_usuń", description="Usuń zadanie cron z ID")
317 async def cron_remove(
318 self,
319 ctx,
320 job_id: int = discord.Option(
321 int, "ID zadania cron do usunięcia", required=True
322 ),
323 ):
324 """Remove a cron job by ID"""
325 for i, task in enumerate(self.scheduled_tasks):
326 if task["id"] == job_id:
327 self.scheduled_tasks.pop(i)
328 self.save_tasks()
329 await ctx.respond(f"Usunięto zadanie cron #{job_id}")
330 return
331
332 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
333
334 @commands.slash_command(name="cron_pauza", description="Zapauzuj zadanie cron z ID")
335 async def cron_pause(
336 self,
337 ctx,
338 job_id: int = discord.Option(
339 int, "ID zadania cron do zatrzymania", required=True
340 ),
341 ):
342 """Pause a cron job by ID"""
343 for task in self.scheduled_tasks:
344 if task["id"] == job_id:
345 task["active"] = False
346 self.save_tasks()
347 await ctx.respond(f"Zapauzowano zadanie cron #{job_id}")
348 return
349
350 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
351
352 @commands.slash_command(name="cron_wznów", description="Wznów zadanie cron z ID")
353 async def cron_resume(
354 self,
355 ctx,
356 job_id: int = discord.Option(
357 int, "ID zadania cron do wznowienia", required=True
358 ),
359 ):
360 """Resume a paused cron job by ID"""
361 for task in self.scheduled_tasks:
362 if task["id"] == job_id:
363 task["active"] = True
364 self.save_tasks()
365 await ctx.respond(f"Wznowiono zadanie cron z ID #{job_id}")
366 return
367
368 await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}")
369
370 def cog_unload(self):
371 """Clean up when cog is unloaded"""
372 if hasattr(self, "task"):
373 self.task.cancel()
374
375
376def setup(bot):
377 bot.add_cog(Cron(bot))