#!/usr/bin/env python3 """ plugins/cron.py – In‑process cron scheduler (no system crontab). Room ID is derived automatically from the command context. """ import logging from typing import Optional import sqlite3 from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger import pytz import simplematrixbotlib as botlib logger = logging.getLogger("cron") # ------------------------------------------------------------------ # Database # ------------------------------------------------------------------ DB_PATH = "cron_jobs.db" def init_db(): with sqlite3.connect(DB_PATH) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS cron_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id TEXT NOT NULL, cron_expr TEXT NOT NULL, command TEXT NOT NULL, timezone TEXT DEFAULT 'UTC', enabled INTEGER DEFAULT 1, added_by TEXT DEFAULT '' ) """) conn.commit() # ------------------------------------------------------------------ # Fake objects for command injection # ------------------------------------------------------------------ class FakeRoom: def __init__(self, room_id): self.room_id = room_id class FakeMessage: def __init__(self, body): self.sender = "@cron:system" self.body = body # ------------------------------------------------------------------ # Scheduler # ------------------------------------------------------------------ scheduler = AsyncIOScheduler() def load_jobs(bot): """Load all enabled jobs from DB into scheduler.""" with sqlite3.connect(DB_PATH) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT id, room_id, cron_expr, command, timezone FROM cron_jobs WHERE enabled=1" ).fetchall() for row in rows: job_id = str(row["id"]) trigger = CronTrigger.from_crontab(row["cron_expr"], timezone=pytz.timezone(row["timezone"])) scheduler.add_job( fire_job, trigger=trigger, args=[bot, row["room_id"], row["command"]], id=job_id, replace_existing=True, ) logger.info(f"Loaded cron job {job_id}: {row['cron_expr']} -> {row['command']}") async def fire_job(bot, room_id: str, command: str): """Execute a scheduled command by injecting it into the bot's dispatcher.""" room = FakeRoom(room_id) message = FakeMessage(command) # Prefer the main bot instance (set by funguy.py) for full dispatch if hasattr(bot, "main_bot"): await bot.main_bot.handle_commands(room, message) else: # Fallback: direct plugin call prefix = bot.config.prefix if command.startswith(prefix): body = command[len(prefix):].strip() if " " in body: plugin_name, _ = body.split(" ", 1) else: plugin_name = body plugin_module = bot.plugins.get(plugin_name) if plugin_module: logger.info(f"Cron executing via {plugin_name}: {command}") await plugin_module.handle_command(room, message, bot, prefix, bot.config) else: logger.warning(f"No plugin found for cron command: {command}") await bot.api.send_text_message(room_id, f"[cron] Unknown command: {command}") else: # Non‑bot command: just post as plain text await bot.api.send_text_message(room_id, command) # ------------------------------------------------------------------ # Setup (called by FunguyBot after bot is created) # ------------------------------------------------------------------ def setup(bot): init_db() load_jobs(bot) if not scheduler.running: scheduler.start() logger.info("APScheduler started") # ------------------------------------------------------------------ # Command handler – auto‑detects room_id from the current room # ------------------------------------------------------------------ async def handle_command(room, message, bot, prefix, config): match = botlib.MessageMatch(room, message, bot, prefix) if not (match.is_not_from_this_bot() and match.prefix() and match.command("cron")): return # Admin only if str(message.sender) != config.admin_user: await bot.api.send_text_message(room.room_id, "⛔ You must be admin to use cron.") return args = match.args() if not args: await bot.api.send_text_message(room.room_id, "📋 Usage: !cron [arguments]") return action = args[0].lower() current_room = room.room_id # ← automatically derived # --------------------------------------------------------------- # ADD: !cron add [tz=Timezone] # --------------------------------------------------------------- if action == "add": if len(args) < 3: # at least: add, cron_expr, command await bot.api.send_text_message(room.room_id, "Usage: `!cron add [tz=IANA]`\n" "Example: `!cron add 0 8 * * * !weather london tz=Europe/London`") return cron_parts = [] command_parts = [] timezone = "UTC" # The cron expression is everything between 'add' and the last part that # starts with '!', or the whole remaining if no '!'. Simple heuristic: # We'll assume the command starts with the bot's prefix (or a word that is a plugin command). # Better: separate at the first argument that looks like a command (starts with prefix or no spaces?). # We'll use: after "add", take all tokens until we encounter a token that is likely a command # (i.e., starts with the prefix or contains a space? Actually let's just take everything after the add # as command except the trailing tz=). all_remaining = args[1:] # everything after "add" # Find possible tz= at the end if all_remaining and all_remaining[-1].startswith("tz="): timezone_str = all_remaining[-1] timezone = timezone_str.split("=", 1)[1] all_remaining = all_remaining[:-1] # remove tz part # Now all_remaining is the cron expression + command all in one list. # The last element of all_remaining might be the full command if it was quoted? # But MessageMatch.args splits by spaces, so multi-word commands are broken. # To keep it simple, we'll require the user to wrap the command in quotes if it contains spaces. # That is: !cron add "* * * * *" "!echo Hello World" tz=... # However, Matrix messages typically don't preserve quotes. # So we'll instead define: the cron expression consists of exactly 5 fields (minute, hour, dom, month, dow). # So take the first 5 tokens after "add" as cron_expr. The rest is the command. # If the command needs to be multiple words, they must be the remaining tokens. if len(all_remaining) < 6: await bot.api.send_text_message(room.room_id, "Invalid syntax. Cron expression requires 5 fields (min hour dom month dow).\n" "Example: `!cron add 0 8 * * * !weather london`") return cron_expr_tokens = all_remaining[:5] command_tokens = all_remaining[5:] cron_expr = " ".join(cron_expr_tokens) command = " ".join(command_tokens) # may be multi-word, e.g., "!weather london" # Validate timezone if timezone not in pytz.all_timezones: await bot.api.send_text_message(room.room_id, f"❌ Unknown timezone: `{timezone}`. Use IANA names like UTC, Europe/London.") return # Validate cron expression try: CronTrigger.from_crontab(cron_expr, timezone=pytz.timezone(timezone)) except Exception as e: await bot.api.send_text_message(room.room_id, f"❌ Invalid cron expression: `{cron_expr}` – {e}") return # Store in DB using current_room with sqlite3.connect(DB_PATH) as conn: cur = conn.execute( "INSERT INTO cron_jobs (room_id, cron_expr, command, timezone, added_by) VALUES (?,?,?,?,?)", (current_room, cron_expr, command, timezone, str(message.sender)) ) job_id = cur.lastrowid conn.commit() # Add to scheduler trigger = CronTrigger.from_crontab(cron_expr, timezone=pytz.timezone(timezone)) scheduler.add_job( fire_job, trigger=trigger, args=[bot, current_room, command], id=str(job_id), replace_existing=True, ) await bot.api.send_text_message(room.room_id, f"✅ Cron job **#{job_id}** added in this room:\n" f"Schedule: `{cron_expr}` ({timezone})\n" f"Command: `{command}`") # --------------------------------------------------------------- # REMOVE: !cron remove (only from current room) # --------------------------------------------------------------- elif action == "remove": if len(args) != 2: await bot.api.send_text_message(room.room_id, "Usage: `!cron remove `") return try: job_id = int(args[1]) except ValueError: await bot.api.send_text_message(room.room_id, "❌ Job ID must be a number.") return with sqlite3.connect(DB_PATH) as conn: cur = conn.execute( "DELETE FROM cron_jobs WHERE id=? AND room_id=?", (job_id, current_room) ) if cur.rowcount == 0: await bot.api.send_text_message(room.room_id, f"❌ No job #{job_id} found in this room.") return conn.commit() if scheduler.get_job(str(job_id)): scheduler.remove_job(str(job_id)) await bot.api.send_text_message(room.room_id, f"🗑️ Job #{job_id} removed from this room.") # --------------------------------------------------------------- # LIST: !cron list [*] (default: current room, * for all rooms) # --------------------------------------------------------------- elif action == "list": show_all = False if len(args) > 1: if args[1] == "*": show_all = True else: await bot.api.send_text_message(room.room_id, "Usage: `!cron list` (this room) or `!cron list *` (all rooms)") return with sqlite3.connect(DB_PATH) as conn: conn.row_factory = sqlite3.Row if show_all: rows = conn.execute( "SELECT id, room_id, cron_expr, command, timezone, enabled FROM cron_jobs ORDER BY id" ).fetchall() else: rows = conn.execute( "SELECT id, cron_expr, command, timezone, enabled FROM cron_jobs WHERE room_id=? ORDER BY id", (current_room,) ).fetchall() if not rows: scope = "all rooms" if show_all else "this room" await bot.api.send_text_message(room.room_id, f"📭 No cron jobs in {scope}.") return lines = [f"**Cron jobs in {'all rooms' if show_all else 'this room'}:**\n"] for r in rows: status = "🟢" if r["enabled"] else "🔴" if show_all: lines.append( f"{status} **#{r['id']}** in `{r['room_id']}` → " f"`{r['cron_expr']}` ({r['timezone']})\n" f" Cmd: `{r['command']}`" ) else: lines.append( f"{status} **#{r['id']}** → `{r['cron_expr']}` ({r['timezone']})\n" f" Cmd: `{r['command']}`" ) message_text = "\n".join(lines) # Chunk if needed while len(message_text) > 2000: split_at = message_text.rfind("\n", 0, 2000) if split_at == -1: split_at = 2000 chunk = message_text[:split_at] message_text = message_text[split_at:].lstrip() await bot.api.send_text_message(room.room_id, chunk) if message_text: await bot.api.send_text_message(room.room_id, message_text) # --------------------------------------------------------------- # ENABLE: !cron enable (only if in current room) # --------------------------------------------------------------- elif action == "enable": if len(args) != 2: await bot.api.send_text_message(room.room_id, "Usage: `!cron enable `") return try: job_id = int(args[1]) except ValueError: await bot.api.send_text_message(room.room_id, "❌ Job ID must be a number.") return with sqlite3.connect(DB_PATH) as conn: cur = conn.execute( "UPDATE cron_jobs SET enabled=1 WHERE id=? AND room_id=?", (job_id, current_room) ) if cur.rowcount == 0: await bot.api.send_text_message(room.room_id, f"❌ Job #{job_id} not found in this room.") return conn.commit() # Re‑add to scheduler row = conn.execute( "SELECT cron_expr, command, timezone FROM cron_jobs WHERE id=?", (job_id,) ).fetchone() if row: trigger = CronTrigger.from_crontab(row[0], timezone=pytz.timezone(row[2])) scheduler.add_job( fire_job, trigger=trigger, args=[bot, current_room, row[1]], id=str(job_id), replace_existing=True, ) await bot.api.send_text_message(room.room_id, f"✅ Job #{job_id} enabled.") # --------------------------------------------------------------- # DISABLE: !cron disable (only if in current room) # --------------------------------------------------------------- elif action == "disable": if len(args) != 2: await bot.api.send_text_message(room.room_id, "Usage: `!cron disable `") return try: job_id = int(args[1]) except ValueError: await bot.api.send_text_message(room.room_id, "❌ Job ID must be a number.") return with sqlite3.connect(DB_PATH) as conn: cur = conn.execute( "UPDATE cron_jobs SET enabled=0 WHERE id=? AND room_id=?", (job_id, current_room) ) if cur.rowcount == 0: await bot.api.send_text_message(room.room_id, f"❌ Job #{job_id} not found in this room.") return conn.commit() if scheduler.get_job(str(job_id)): scheduler.remove_job(str(job_id)) await bot.api.send_text_message(room.room_id, f"⏸️ Job #{job_id} disabled.") # --------------------------------------------------------------- # CLEAR: !cron clear (all jobs in current room) # --------------------------------------------------------------- elif action == "clear": with sqlite3.connect(DB_PATH) as conn: cur = conn.execute("SELECT id FROM cron_jobs WHERE room_id=?", (current_room,)) job_ids = [str(row[0]) for row in cur.fetchall()] conn.execute("DELETE FROM cron_jobs WHERE room_id=?", (current_room,)) conn.commit() for jid in job_ids: if scheduler.get_job(jid): scheduler.remove_job(jid) await bot.api.send_text_message(room.room_id, f"🧹 All cron jobs cleared from this room.") else: await bot.api.send_text_message(room.room_id, "❓ Unknown action. Use: add, remove, list, enable, disable, clear.") # ------------------------------------------------------------------ # Plugin metadata # ------------------------------------------------------------------ __version__ = "2.1.0" __author__ = "Funguy Cron Team" __description__ = "In‑process cron scheduler (room‑aware, no system crontab)" __help__ = """
!cron – Schedule commands (room‑context aware)
  • !cron add <cron_expr> <command> [tz=IANA] – Add job to current room
  • !cron remove <job_id> – Remove a job
  • !cron list – List jobs in current room
  • !cron list * – List jobs in all rooms (admin)
  • !cron enable <job_id> – Re‑enable a disabled job
  • !cron disable <job_id> – Disable a job
  • !cron clear – Remove all jobs from current room

Admin only. Timezone defaults to UTC; use tz=Europe/London at end.

Cron expression: 5 fields (min hour dom month dow), e.g. 0 8 * * *

"""