diff options
| author | salmonik <raisersoft@proton.me> | 2026-04-17 19:40:35 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-04-17 19:40:35 +0200 |
| commit | 1c7a162deb1b6dcea34c219bd35d540a4e1f1fe8 (patch) | |
| tree | 2f058e10555d2c6b7f6ac796368cef5f12527ffc | |
| parent | make voice great again (diff) | |
| download | senator-sperma-1c7a162deb1b6dcea34c219bd35d540a4e1f1fe8.tar.gz senator-sperma-1c7a162deb1b6dcea34c219bd35d540a4e1f1fe8.zip | |
add cron functionality
Signed-off-by: salmonik <raisersoft@proton.me>
| -rw-r--r-- | cogs/cron.py | 377 | ||||
| -rw-r--r-- | main.py | 54 |
2 files changed, 405 insertions, 26 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 | |||
| 6 | import asyncio | ||
| 7 | import json | ||
| 8 | import logging | ||
| 9 | import os | ||
| 10 | from datetime import datetime | ||
| 11 | from venv import logger | ||
| 12 | |||
| 13 | import discord | ||
| 14 | from croniter import croniter | ||
| 15 | from discord.ext import commands | ||
| 16 | |||
| 17 | from cogs.utils import file_search as search_utils | ||
| 18 | from cogs.voice import get_voice_manager | ||
| 19 | |||
| 20 | logger = logging.getLogger(__name__) | ||
| 21 | |||
| 22 | |||
| 23 | class 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 | |||
| 376 | def setup(bot): | ||
| 377 | bot.add_cog(Cron(bot)) | ||
| @@ -3,24 +3,22 @@ | |||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io |
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import asyncio | ||
| 6 | import logging | 7 | import logging |
| 7 | import os | 8 | import os |
| 8 | import sys | 9 | import sys |
| 9 | import asyncio | 10 | |
| 10 | import discord | 11 | import discord |
| 11 | from discord.ext import commands | 12 | from discord.ext import commands |
| 12 | from dotenv import load_dotenv | 13 | from dotenv import load_dotenv |
| 13 | 14 | ||
| 14 | from cogs.voice import get_voice_manager | ||
| 15 | from cogs.utils import settings | 15 | from cogs.utils import settings |
| 16 | 16 | ||
| 17 | # Configure logging | 17 | # Configure logging |
| 18 | logging.basicConfig( | 18 | logging.basicConfig( |
| 19 | level=logging.INFO, | 19 | level=logging.INFO, |
| 20 | format='%(levelname)s - %(filename)s - %(name)s - %(message)s', | 20 | format="%(levelname)s - %(filename)s - %(name)s - %(message)s", |
| 21 | handlers=[ | 21 | handlers=[logging.StreamHandler(sys.stdout)], |
| 22 | logging.StreamHandler(sys.stdout) | ||
| 23 | ] | ||
| 24 | ) | 22 | ) |
| 25 | logger = logging.getLogger(__name__) | 23 | logger = logging.getLogger(__name__) |
| 26 | 24 | ||
| @@ -35,13 +33,12 @@ bot = commands.Bot(intents=intents, loop=loop) | |||
| 35 | @bot.event | 33 | @bot.event |
| 36 | async def on_ready(): | 34 | async def on_ready(): |
| 37 | logger.info("ŁUKASZ SPERMA AKTYWACJA") | 35 | logger.info("ŁUKASZ SPERMA AKTYWACJA") |
| 38 | 36 | ||
| 39 | channel = bot.get_channel(1081667823799111804) | 37 | channel = bot.get_channel(1081667823799111804) |
| 40 | 38 | ||
| 41 | await channel.send("ŁUKASZ SPERMA AKTYWACJA") | 39 | await channel.send("ŁUKASZ SPERMA AKTYWACJA") |
| 42 | |||
| 43 | await settings.load_settings() | ||
| 44 | 40 | ||
| 41 | await settings.load_settings() | ||
| 45 | 42 | ||
| 46 | 43 | ||
| 47 | if __name__ == "__main__": | 44 | if __name__ == "__main__": |
| @@ -49,31 +46,36 @@ if __name__ == "__main__": | |||
| 49 | extensions = [ | 46 | extensions = [ |
| 50 | "cogs.vclogs", | 47 | "cogs.vclogs", |
| 51 | "cogs.compression", | 48 | "cogs.compression", |
| 52 | 'cogs.tts', | 49 | "cogs.tts", |
| 53 | 'cogs.web', | 50 | "cogs.web", |
| 54 | 'cogs.ai', | 51 | "cogs.ai", |
| 55 | 'cogs.conversion', | 52 | "cogs.conversion", |
| 56 | 'cogs.math_commands', | 53 | "cogs.math_commands", |
| 57 | 'cogs.downloader', | 54 | "cogs.downloader", |
| 58 | 'cogs.say', | 55 | "cogs.say", |
| 59 | 'cogs.jarvis', | 56 | "cogs.jarvis", |
| 60 | 'cogs.music.play_commands', | 57 | "cogs.music.play_commands", |
| 61 | 'cogs.music.stop', | 58 | "cogs.music.stop", |
| 62 | 'cogs.music.join', | 59 | "cogs.music.join", |
| 63 | 'cogs.search', | 60 | "cogs.search", |
| 64 | 'cogs.trigger', | 61 | "cogs.trigger", |
| 62 | "cogs.cron", | ||
| 63 | "cogs.qr", | ||
| 64 | "cogs.plot", | ||
| 65 | ] | 65 | ] |
| 66 | 66 | ||
| 67 | try: | 67 | try: |
| 68 | for i, extension in enumerate(extensions, 1): | 68 | for i, extension in enumerate(extensions, 1): |
| 69 | bot.load_extension(extension) | 69 | bot.load_extension(extension) |
| 70 | logger.info(f"Loaded {i}/{len(extensions)} extensions") | 70 | logger.info(f"Loaded {i}/{len(extensions)} extensions") |
| 71 | 71 | ||
| 72 | # Run the bot with the token from environment variables | 72 | # Run the bot with the token from environment variables |
| 73 | token = os.getenv("TOKEN") | 73 | token = os.getenv("TOKEN") |
| 74 | if not token: | 74 | if not token: |
| 75 | raise ValueError("No token provided. Please set the TOKEN environment variable.") | 75 | raise ValueError( |
| 76 | 76 | "No token provided. Please set the TOKEN environment variable." | |
| 77 | ) | ||
| 78 | |||
| 77 | bot.run(token) | 79 | bot.run(token) |
| 78 | 80 | ||
| 79 | except Exception as e: | 81 | except Exception as e: |
