# 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 logging 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 from cogs.utils.database import get_connection, init_database logger = logging.getLogger(__name__) init_database() class Cron(commands.Cog): def __init__(self, bot): self.bot = bot self.scheduled_tasks = [] self.load_tasks() self.task = bot.loop.create_task(self.schedule_checker()) def load_tasks(self): try: conn = get_connection() try: c = conn.cursor() c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") rows = c.fetchall() finally: conn.close() self.scheduled_tasks = [ { "id": row[0], "cron_expression": row[1], "type": row[2], "channel_id": row[3], "active": bool(row[4]), "audio_filename": row[5], "message": row[6], } for row in rows ] logger.info(f"Loaded {len(self.scheduled_tasks)} cron jobs from database") except Exception as e: logger.error(f"Error loading cron jobs: {e}") self.scheduled_tasks = [] def save_tasks(self): try: conn = get_connection() try: c = conn.cursor() c.execute("PRAGMA foreign_keys = ON") c.execute("DELETE FROM cron_jobs") for task in self.scheduled_tasks: c.execute( """INSERT INTO cron_jobs (id, cron_expression, type, channel_id, active, audio_filename, message) VALUES (?, ?, ?, ?, ?, ?, ?)""", ( task.get("id"), task["cron_expression"], task["type"], task["channel_id"], int(task.get("active", True)), task.get("audio_filename"), task.get("message"), ), ) conn.commit() finally: conn.close() 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.debug( 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 ID: {task.get('id')}") return logger.info(f"Found channel {channel} (type: {type(channel).__name__})") perms = channel.permissions_for(channel.guild.me) logger.debug( 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", "wiadomość"): 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) logger.info("Audio playback finished normally - disconnecting") 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(): logger.info("Audio playback failed - disconnecting") 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.debug("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="")] ) def _get_baseurl(self): """Get base URL from settings""" try: from cogs.utils.settings import get_baseurl return get_baseurl() except Exception: return "" trigger_group = discord.SlashCommandGroup("cron", "komendy cron") @trigger_group.command( name="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}>" ) @trigger_group.command( name="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}>" ) @trigger_group.command( name="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) @trigger_group.command(name="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}") @trigger_group.command(name="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}") @trigger_group.command(name="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))