417 lines
17 KiB
Python
417 lines
17 KiB
Python
#!/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 <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()
|
||
|
||
# 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 <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__ = "In‑process cron scheduler (room‑aware, no system crontab)"
|
||
__help__ = """
|
||
<details>
|
||
<summary><strong>!cron</strong> – Schedule commands (room‑context aware)</summary>
|
||
<ul>
|
||
<li><code>!cron add <cron_expr> <command> [tz=IANA]</code> – Add job to current room</li>
|
||
<li><code>!cron remove <job_id></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 <job_id></code> – Re‑enable a disabled job</li>
|
||
<li><code>!cron disable <job_id></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>
|
||
"""
|