admin plugin and roomstats plugin added. cron fixed and ddg fixed
This commit is contained in:
+396
-61
@@ -1,81 +1,416 @@
|
||||
# plugins/cron.py
|
||||
#!/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 crontab import CronTab
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
import pytz
|
||||
|
||||
import simplematrixbotlib as botlib
|
||||
|
||||
# Database connection and cursor
|
||||
conn = sqlite3.connect('cron.db')
|
||||
cursor = conn.cursor()
|
||||
logger = logging.getLogger("cron")
|
||||
|
||||
# Create table if not exists
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS cron (
|
||||
room_id TEXT,
|
||||
cron_entry TEXT,
|
||||
command TEXT
|
||||
)''')
|
||||
conn.commit()
|
||||
# ------------------------------------------------------------------
|
||||
# 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 match.is_not_from_this_bot() and match.prefix() and match.command("cron"):
|
||||
args = match.args()
|
||||
if len(args) >= 4:
|
||||
action = args[0]
|
||||
room_id = args[1]
|
||||
cron_entry = ' '.join(args[2:-1])
|
||||
command = args[-1]
|
||||
if action == "add":
|
||||
add_cron(room_id, cron_entry, command)
|
||||
await bot.api.send_text_message(room.room_id, f"Cron added successfully")
|
||||
elif action == "remove":
|
||||
remove_cron(room_id, command)
|
||||
await bot.api.send_text_message(room.room_id, f"Cron removed successfully")
|
||||
else:
|
||||
await bot.api.send_text_message(room.room_id, "Usage: !cron add|remove room_id cron_entry command")
|
||||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("cron")):
|
||||
return
|
||||
|
||||
def add_cron(room_id, cron_entry, command):
|
||||
# Check if the cron entry already exists in the database for the given room_id and command
|
||||
cursor.execute('SELECT * FROM cron WHERE room_id=? AND command=? AND cron_entry=?', (room_id, command, cron_entry))
|
||||
existing_entry = cursor.fetchone()
|
||||
if existing_entry:
|
||||
return # Cron entry already exists, do not add duplicate
|
||||
# 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
|
||||
|
||||
# Insert the cron entry into the database
|
||||
cursor.execute('INSERT INTO cron (room_id, cron_entry, command) VALUES (?, ?, ?)', (room_id, cron_entry, command))
|
||||
conn.commit()
|
||||
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
|
||||
|
||||
def remove_cron(room_id, command):
|
||||
cursor.execute('DELETE FROM cron WHERE room_id=? AND command=?', (room_id, command))
|
||||
conn.commit()
|
||||
action = args[0].lower()
|
||||
current_room = room.room_id # ← automatically derived
|
||||
|
||||
async def run_cron_jobs(bot):
|
||||
cron = CronTab()
|
||||
for job in cron:
|
||||
cron_entry = str(job)
|
||||
for row in cursor.execute('SELECT * FROM cron WHERE cron_entry=?', (cron_entry,)):
|
||||
room_id, _, command = row
|
||||
room = await bot.api.get_room_by_id(room_id)
|
||||
if room:
|
||||
plugin_name = command.split()[0].replace(prefix, '') # Extract plugin name
|
||||
plugin_module = bot.plugins.get(plugin_name)
|
||||
if plugin_module:
|
||||
await plugin_module.handle_command(room, None, bot, prefix, config)
|
||||
# ---------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
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=).
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Funguy Bot"
|
||||
__description__ = "Cron job scheduler"
|
||||
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 via cron syntax</summary>
|
||||
<summary><strong>!cron</strong> – Schedule commands (room‑context aware)</summary>
|
||||
<ul>
|
||||
<li><code>!cron add <room_id> <cron_entry> <command></code> – Add job</li>
|
||||
<li><code>!cron remove <room_id> <command></code> – Remove job</li>
|
||||
<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.</p>
|
||||
<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>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user