From 1c7a162deb1b6dcea34c219bd35d540a4e1f1fe8 Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 17 Apr 2026 19:40:35 +0200 Subject: add cron functionality Signed-off-by: salmonik --- cogs/cron.py | 377 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 cogs/cron.py (limited to 'cogs/cron.py') 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 @@ +# This file is part of Senator Sperma +# Licensed under the GPL-3.0 License +# Copyright (C) 2026 Salmonik @ salmonik@tuta.io +# https://codeberg.org/salmonik/senator-sperma + +import asyncio +import json +import logging +import os +from datetime import datetime +from venv import logger + +import discord +from croniter import croniter +from discord.ext import commands + +from cogs.utils import file_search as search_utils +from cogs.voice import get_voice_manager + +logger = logging.getLogger(__name__) + + +class Cron(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.scheduled_tasks = [] + self.cron_file = "data/cron_jobs.json" + self.load_tasks() + self.task = bot.loop.create_task(self.schedule_checker()) + + def load_tasks(self): + """Load scheduled tasks from file""" + try: + if os.path.exists(self.cron_file): + with open(self.cron_file, "r") as f: + self.scheduled_tasks = json.load(f) + logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs") + else: + self.scheduled_tasks = [] + except Exception as e: + logger.error(f"Error loading cron jobs: {e}") + self.scheduled_tasks = [] + + def save_tasks(self): + """Save scheduled tasks to file""" + try: + os.makedirs(os.path.dirname(self.cron_file), exist_ok=True) + with open(self.cron_file, "w") as f: + json.dump(self.scheduled_tasks, f, indent=2) + except Exception as e: + logger.error(f"Error saving cron jobs: {e}") + + async def schedule_checker(self): + """Background task to check and execute scheduled jobs""" + await self.bot.wait_until_ready() + while not self.bot.is_closed(): + try: + now = datetime.now() + for task in self.scheduled_tasks: + if not task.get("active", True): + continue + if self.should_run_task(task, now): + await self.execute_task(task) + await asyncio.sleep(30) + except Exception as e: + logger.error(f"Error in schedule checker: {e}") + await asyncio.sleep(30) + + def should_run_task(self, task, now): + """Check if a task should run now based on its cron expression""" + try: + cron_expr = task["cron_expression"] + cron = croniter(cron_expr, now) + prev_time = cron.get_prev(datetime) + time_diff = abs((now - prev_time).total_seconds()) + result = time_diff <= 30 + logger.info( + f"Cron '{cron_expr}': prev={prev_time}, now={now}, diff={time_diff}, run={result}" + ) + return result + except Exception as e: + logger.error( + f"Error checking cron expression {task.get('cron_expression')}: {e}" + ) + return False + + async def execute_task(self, task): + """Execute a scheduled task""" + try: + task_type = task["type"] + channel_id = task["channel_id"] + + channel = self.bot.get_channel(channel_id) + if not channel: + logger.warning(f"Channel {channel_id} not found for task {task['id']}") + return + + logger.info(f"Found channel {channel} (type: {type(channel).__name__})") + + perms = channel.permissions_for(channel.guild.me) + logger.info( + f"Bot permissions: send_messages={perms.send_messages}, attach_files={perms.attach_files}" + ) + + if task_type in ("zagraj_audio", "audio"): + await self.play_audio_task(task, channel) + elif task_type in ("wyślij_wiadomość", "send_message"): + await self.send_message_task(task, channel) + + logger.info(f"Executed task {task['id']} of type {task_type}") + except Exception as e: + logger.error(f"Error executing task {task.get('id')}: {e}") + + async def play_audio_task(self, task, channel): + """Play audio task implementation using voice manager""" + audio_url = task["audio_filename"] + + if isinstance(channel, discord.VoiceChannel): + voice_mgr = get_voice_manager(self.bot) + voice_client = await voice_mgr.join_channel(channel) + if not voice_client: + if channel.guild.system_channel: + await channel.guild.system_channel.send( + "Nie udano się połączyć z kanałem głosowym" + ) + return + + try: + audio_source = discord.FFmpegPCMAudio(audio_url) + voice_client.play(audio_source) + + while voice_client.is_playing(): + await asyncio.sleep(1) + + await voice_client.disconnect() + logger.info(f"Finished playing audio: {audio_url}") + except Exception as e: + logger.error(f"Error playing audio: {e}") + if voice_client.is_connected(): + await voice_client.disconnect() + if channel.guild.system_channel: + await channel.guild.system_channel.send( + f"Błąd podczas odgrywania audio: {audio_url}" + ) + else: + if hasattr(channel, "send"): + await channel.send("Odtwarzanie audio wymaga kanału głosowego") + + async def send_message_task(self, task, channel): + """Send message task implementation""" + message = task["message"] + logger.info(f"Message to send: '{message}' | Channel: {channel.id}") + await channel.send(message) + logger.info("Message sent successfully") + + async def audio_autocomplete(self, ctx: discord.AutocompleteContext): + """Autocomplete for audio files from file_search""" + query = ctx.value.lower() if ctx.value else "" + if not query: + return "" + + audio_extensions = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "mp4", "mkv"] + results = await search_utils.UniversalSearcher.search_copyparty( + query, max_results=25, file_extensions=audio_extensions + ) + + choices = [] + for url in results[:25]: + filename = url.split("/")[-1] + display_name = filename[:98] + "…" if len(filename) > 100 else filename + choices.append(discord.OptionChoice(name=display_name, value=url)) + + return ( + choices + if choices + else [discord.OptionChoice(name="Brak wyników", value="")] + ) + + @commands.slash_command( + name="cron_dodaj_audio", description="Dodaj nowe zadanie cron audio" + ) + async def cron_add_audio( + self, + ctx, + cron_expression: str = discord.Option( + str, + "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", + required=True, + ), + channel: discord.VoiceChannel = discord.Option( + discord.VoiceChannel, + "Kanał głosowy na którym będzie wykonywana akcja", + required=True, + ), + audio_filename: str = discord.Option( + str, + "Plik audio z serwera copyparty", + required=True, + autocomplete=audio_autocomplete, + ), + ): + try: + croniter(cron_expression) + except Exception: + await ctx.respond( + "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", + ephemeral=True, + ) + return + + task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 + + task = { + "id": task_id, + "cron_expression": cron_expression, + "type": "audio", + "channel_id": channel.id, + "active": True, + } + + task["audio_filename"] = audio_filename + + self.scheduled_tasks.append(task) + self.save_tasks() + + await ctx.respond( + f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: audio\nKanał: <#{channel.id}>" + ) + + @commands.slash_command( + name="cron_dodaj_wiadomość", description="Dodaj nowe zadanie cron wiadomość" + ) + async def cron_add_message( + self, + ctx, + cron_expression: str = discord.Option( + str, + "Ekspresja cron (m H D M Y. '37 21 * * *' wykonuje zadanie o 21:37 codziennie)", + required=True, + ), + channel: discord.TextChannel = discord.Option( + discord.TextChannel, "Kanał na którym będzie wykonana akcja", required=True + ), + message: str = discord.Option(str, "Wiadomość do wysłania", required=True), + ): + """Add a new cron job""" + + try: + croniter(cron_expression) + except Exception: + await ctx.respond( + "Zła składnia cron, zapytaj się lokalnego eksperta o pomoc", + ephemeral=True, + ) + return + + task_id = max([t["id"] for t in self.scheduled_tasks], default=0) + 1 + + task = { + "id": task_id, + "cron_expression": cron_expression, + "type": "wiadomość", + "channel_id": channel.id, + "active": True, + "message": message, + } + + self.scheduled_tasks.append(task) + self.save_tasks() + + await ctx.respond( + f"Zadanie cron dodane. ID: {task_id}\nSkładnia: `{cron_expression}`\nAkcja: wiadomość\nKanał: <#{channel.id}>" + ) + + def _get_baseurl(self): + """Get base URL from settings""" + try: + from cogs.utils.settings import get_baseurl + + return get_baseurl() + except Exception: + return "" + + @commands.slash_command( + name="cron_pokaż", description="Pokaż wszystkie zadania cron" + ) + async def cron_list(self, ctx): + """List all cron jobs""" + if not self.scheduled_tasks: + await ctx.respond("Brak zadań cron") + return + + embed = discord.Embed(title="Zaplanowane prace cron", color=0x00FF00) + + for task in self.scheduled_tasks: + channel = self.bot.get_channel(task["channel_id"]) + channel_name = f"#{channel}" + status = "AKTYWNA" if task.get("active", True) else "PAUZA" + + if task["type"] in ("zagraj_audio", "audio"): + action_desc = f"Zagraj audio: `{task.get('audio_filename', 'N/A')}`" + else: + msg = task.get("message", "") + action_desc = ( + f"Wyślij wiadomość: `{msg[:50]}{'...' if len(msg) > 50 else ''}`" + ) + + embed.add_field( + name=f"Zadanie #{task['id']} ({status})", + value=f"**Czas:** `{task['cron_expression']}`\n**Akcja:** {action_desc}\n**Kanał:** {channel_name}", + inline=False, + ) + + await ctx.respond(embed=embed) + + @commands.slash_command(name="cron_usuń", description="Usuń zadanie cron z ID") + async def cron_remove( + self, + ctx, + job_id: int = discord.Option( + int, "ID zadania cron do usunięcia", required=True + ), + ): + """Remove a cron job by ID""" + for i, task in enumerate(self.scheduled_tasks): + if task["id"] == job_id: + self.scheduled_tasks.pop(i) + self.save_tasks() + await ctx.respond(f"Usunięto zadanie cron #{job_id}") + return + + await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") + + @commands.slash_command(name="cron_pauza", description="Zapauzuj zadanie cron z ID") + async def cron_pause( + self, + ctx, + job_id: int = discord.Option( + int, "ID zadania cron do zatrzymania", required=True + ), + ): + """Pause a cron job by ID""" + for task in self.scheduled_tasks: + if task["id"] == job_id: + task["active"] = False + self.save_tasks() + await ctx.respond(f"Zapauzowano zadanie cron #{job_id}") + return + + await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") + + @commands.slash_command(name="cron_wznów", description="Wznów zadanie cron z ID") + async def cron_resume( + self, + ctx, + job_id: int = discord.Option( + int, "ID zadania cron do wznowienia", required=True + ), + ): + """Resume a paused cron job by ID""" + for task in self.scheduled_tasks: + if task["id"] == job_id: + task["active"] = True + self.save_tasks() + await ctx.respond(f"Wznowiono zadanie cron z ID #{job_id}") + return + + await ctx.respond(f"Nie znaleziono zadania cron z ID {job_id}") + + def cog_unload(self): + """Clean up when cog is unloaded""" + if hasattr(self, "task"): + self.task.cancel() + + +def setup(bot): + bot.add_cog(Cron(bot)) -- cgit v1.2.3