Files
FunguyBot/plugins/cron.py
T

417 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
plugins/cron.py Inprocess 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:
# Nonbot 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 autodetects 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 <add|remove|list|enable|disable|clear> [arguments]")
return
action = args[0].lower()
current_room = room.room_id # ← automatically derived
# ---------------------------------------------------------------
# ADD: !cron add <cron_expr> <command> [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 <cron_expr> <command> [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 <job_id> (only from current room)
# ---------------------------------------------------------------
elif action == "remove":
if len(args) != 2:
await bot.api.send_text_message(room.room_id, "Usage: `!cron remove <job_id>`")
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 <job_id> (only if in current room)
# ---------------------------------------------------------------
elif action == "enable":
if len(args) != 2:
await bot.api.send_text_message(room.room_id, "Usage: `!cron enable <job_id>`")
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()
# Readd 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 <job_id> (only if in current room)
# ---------------------------------------------------------------
elif action == "disable":
if len(args) != 2:
await bot.api.send_text_message(room.room_id, "Usage: `!cron disable <job_id>`")
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__ = "Inprocess cron scheduler (roomaware, no system crontab)"
__help__ = """
<details>
<summary><strong>!cron</strong> Schedule commands (roomcontext aware)</summary>
<ul>
<li><code>!cron add &lt;cron_expr&gt; &lt;command&gt; [tz=IANA]</code> Add job to current room</li>
<li><code>!cron remove &lt;job_id&gt;</code> Remove a job</li>
<li><code>!cron list</code> List jobs in current room</li>
<li><code>!cron list *</code> List jobs in all rooms (admin)</li>
<li><code>!cron enable &lt;job_id&gt;</code> Reenable a disabled job</li>
<li><code>!cron disable &lt;job_id&gt;</code> Disable a job</li>
<li><code>!cron clear</code> Remove all jobs from current room</li>
</ul>
<p>Admin only. Timezone defaults to UTC; use <code>tz=Europe/London</code> at end.</p>
<p>Cron expression: 5 fields (<em>min hour dom month dow</em>), e.g. <code>0 8 * * *</code></p>
</details>
"""