From 10a6028037f84d552b3b415839b85fb44e4f4592 Mon Sep 17 00:00:00 2001 From: Hash Borgir Date: Thu, 7 May 2026 15:28:50 -0500 Subject: [PATCH] admin plugin and roomstats plugin added. cron fixed and ddg fixed --- cron_jobs.db | Bin 0 -> 12288 bytes plugins/admin.py | 538 ++++++++++++++++++++++++++++++ plugins/cron.py | 457 +++++++++++++++++++++---- plugins/ddg.py | 769 +++++++++++++++++-------------------------- plugins/roomstats.py | 434 ++++++++++++++++++++++++ requirements.txt | 4 +- roomstats.db | Bin 0 -> 12288 bytes 7 files changed, 1673 insertions(+), 529 deletions(-) create mode 100644 cron_jobs.db create mode 100644 plugins/admin.py create mode 100644 plugins/roomstats.py create mode 100644 roomstats.db diff --git a/cron_jobs.db b/cron_jobs.db new file mode 100644 index 0000000000000000000000000000000000000000..7f9a8cde83f9709fcd701d031d16f0eb774be6bc GIT binary patch literal 12288 zcmeI#F-yZh6bJCTRulw_TgSewEiHnG;ApI;6w|cE3)m^7%?Xtzu}LcGC^+b+_EK%7 z$4Y6J&e=H;V~p!n)@vDAzOzj9J 1: + raise UserResolutionError(matches) + # else: not found (unlikely) → continue + raise ValueError(f"No member with display name '{' '.join(tokens)}' found.") + +async def resolve_user_from_target(bot, room_id, target): + """ + Resolve a target string to a Matrix user ID. + Accepts: full MXID (@user:domain), display name (multi‑word), or number + (referring to a previous ambiguous resolution). + Returns (mxid, display_name_or_None). + Raises ValueError or UserResolutionError. + """ + if target.startswith("@"): + return target, None + + _cleanup_resolutions() + + # Check for number reference to a previous ambiguous match + if target.isdigit(): + idx = int(target) - 1 + if room_id in _pending_resolution: + pending = _pending_resolution[room_id] + if 0 <= idx < len(pending["matches"]): + match = pending["matches"][idx] + del _pending_resolution[room_id] + return match["mxid"], match.get("display_name") + else: + raise ValueError(f"Invalid selection {target}. Choose from the list.") + else: + raise ValueError("No pending resolution. Use @user:domain or display name.") + + # If we reached here, `target` is a single token, but might be part of a longer name. + # That case is handled by calling _resolve_multiword from handle_command. + # But for completeness, we still attempt a direct cache match. + await _populate_name_cache(bot, room_id) + cache = _name_cache.get(room_id, {}) + mxid = cache.get(target.strip().lower()) + if mxid: + return mxid, target.strip().lower() + elif mxid is None: + # Ambiguous: fetch and raise + resp = await bot.async_client.joined_members(room_id) + matches = [] + for member in resp.members: + if (member.display_name or "").strip().lower() == target.strip().lower(): + matches.append({"mxid": member.user_id, "display_name": member.display_name}) + if len(matches) == 1: + return matches[0]["mxid"], matches[0]["display_name"] + elif len(matches) > 1: + raise UserResolutionError(matches) + raise ValueError(f"No member with display name '{target}' found.") + +# ------------------------------------------------------------------ +# Power level helpers +# ------------------------------------------------------------------ +async def get_power_level(bot, room_id, user_id): + try: + resp = await bot.async_client.room_get_state_event( + room_id, "m.room.power_levels" + ) + if resp.content: + return resp.content.get("users", {}).get( + user_id, resp.content.get("users_default", 0) + ) + except Exception: + pass + return 0 + +async def has_mod_permission(bot, room_id, user_id, config): + if user_id == config.admin_user: + return True + pl = await get_power_level(bot, room_id, user_id) + return pl >= 50 + +async def fetch_power_levels(bot, room_id): + try: + resp = await bot.async_client.room_get_state_event( + room_id, "m.room.power_levels" + ) + return resp.content if resp.content else {} + except Exception: + return {} + +async def set_power_level(bot, room_id, user_id, new_pl): + current = await fetch_power_levels(bot, room_id) + if not current: + raise RuntimeError("Could not retrieve power levels.") + users = current.setdefault("users", {}) + users[user_id] = new_pl + await bot.async_client.room_put_state( + room_id, "m.room.power_levels", current + ) + +async def get_banned_users(bot, room_id): + try: + resp = await bot.async_client.room_get_state(room_id) + banned = [] + for event in resp.events: + if ( + event.get("type") == "m.room.member" + and event.get("content", {}).get("membership") == "ban" + ): + banned.append(event["state_key"]) + return banned + except Exception as e: + logger.error(f"Failed to fetch bans: {e}") + return [] + +# ------------------------------------------------------------------ +# Main command handler +# ------------------------------------------------------------------ +async def handle_command(room, message, bot, prefix, config): + """Dispatches !admin or standalone moderation commands.""" + match = botlib.MessageMatch(room, message, bot, prefix) + if not match.is_not_from_this_bot() or not match.prefix(): + return + + room_id = room.room_id + sender = message.sender + + standalone_actions = { + "kick": "kick", + "ban": "ban", + "unban": "unban", + "invite": "invite", + "whois": "whois", + "op": "op", + "deop": "deop", + "topic": "topic", + "roomname": "roomname", + "avatar": "avatar", + "members": "members", + "bans": "bans", + "modhelp": "help", + "admin": "admin", + } + + cmd = match.command() + if cmd not in standalone_actions: + return + + # Permission gate (skip for help) + if cmd not in ("modhelp", "help"): + if not await has_mod_permission(bot, room_id, sender, config): + await bot.api.send_text_message( + room_id, "⛔ You don't have permission to use moderator commands." + ) + return + + args = match.args() + + # Determine action and sub_args + if cmd == "admin": + if not args: + await bot.api.send_text_message(room_id, "Usage: !admin [args...]") + return + action = args[0].lower() + sub_args = args[1:] if len(args) > 1 else [] + else: + action = cmd + sub_args = args + + # ------------------------------------------------------------ + # User-targeting actions + # ------------------------------------------------------------ + if action in ("kick", "ban", "invite", "whois", "op", "deop"): + if not sub_args: + await bot.api.send_text_message( + room_id, f"Missing user. Usage: !{cmd} <@user|name> [reason...]" + ) + return + + # For op/deop, the last token might be a power level (number) + # For kick/ban, everything after the name is the reason. + # Strategy: Try progressively longer multi-word names from the start of sub_args. + # For op: if the very last token is a pure integer, it's a power level; rest is the name. + # For kick/ban: everything is name except we can't know the boundary, so just try the whole thing. + if action in ("op", "deop"): + # Try to parse last token as power level + potential_pl = sub_args[-1] + try: + power = int(potential_pl) + # Success: power level found, name is sub_args[:-1] + name_tokens = sub_args[:-1] + if not name_tokens: + await bot.api.send_text_message(room_id, "Missing user name.") + return + except ValueError: + # No numeric power, whole sub_args is the name + name_tokens = sub_args + power = None + else: + # kick, ban, invite, whois + name_tokens = sub_args # entire args is the name + power = None + + # Resolve the multi-word name + try: + target_mxid, target_display = await _resolve_multiword(bot, room_id, name_tokens) + except UserResolutionError as e: + lines = [ + "Multiple users found. Re‑issue the command with a number:" + ] + for i, m in enumerate(e.matches, 1): + lines.append(f"{i}. {m['mxid']} ({m['display_name']})") + await bot.api.send_text_message(room_id, "\n".join(lines)) + return + except ValueError as e: + # Fallback: also try the old way with just the first token (maybe they used @user) + target_str = sub_args[0] + try: + target_mxid, target_display = await resolve_user_from_target(bot, room_id, target_str) + except Exception as e2: + await bot.api.send_text_message(room_id, str(e2)) + return + + # Determine reason and power level for op/deop + if action in ("op", "deop"): + if action == "op": + requested_pl = power if power is not None else 50 + if requested_pl > 50: + await bot.api.send_text_message( + room_id, + "Maximum power level for op is 50 (moderator). Setting to 50." + ) + new_pl = 50 + else: + new_pl = requested_pl + else: + new_pl = 0 + + sender_pl = await get_power_level(bot, room_id, sender) + target_pl = await get_power_level(bot, room_id, target_mxid) + if sender != config.admin_user and sender_pl <= target_pl: + await bot.api.send_text_message( + room_id, + "⛔ You can only modify users with a lower power level than yours.", + ) + return + + try: + await set_power_level(bot, room_id, target_mxid, new_pl) + verb = "Promoted" if action == "op" else "Demoted" + await bot.api.send_text_message( + room_id, f"✅ {verb} {target_mxid} to power level {new_pl}." + ) + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed to set power: {e}") + + else: + # For kick/ban/invite: reason is everything after the name tokens + reason = " ".join(sub_args[len(name_tokens):]) if len(sub_args) > len(name_tokens) else "" + + if action == "kick": + try: + await bot.async_client.room_kick(room_id, target_mxid, reason) + await bot.api.send_text_message(room_id, f"👢 Kicked {target_mxid}.") + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed to kick: {e}") + + elif action == "ban": + try: + await bot.async_client.room_ban(room_id, target_mxid, reason) + await bot.api.send_text_message(room_id, f"🚫 Banned {target_mxid}.") + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed to ban: {e}") + + elif action == "invite": + try: + await bot.async_client.room_invite(room_id, target_mxid) + await bot.api.send_text_message(room_id, f"📨 Invited {target_mxid}.") + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed to invite: {e}") + + elif action == "whois": + try: + resp = await bot.async_client.joined_members(room_id) + member_info = None + for m in resp.members: + if m.user_id == target_mxid: + member_info = m + break + pl = await get_power_level(bot, room_id, target_mxid) + display = member_info.display_name if member_info else "Unknown" + msg = ( + f"**User:** `{target_mxid}`\n" + f"**Display Name:** {display}\n" + f"**Power Level:** {pl}" + ) + await bot.api.send_text_message(room_id, msg) + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed to get whois: {e}") + + # ------------------------------------------------------------ + # UNBAN + # ------------------------------------------------------------ + elif action == "unban": + if not sub_args: + await bot.api.send_text_message(room_id, "Usage: !unban <@user:domain>") + return + target_str = sub_args[0] + if not target_str.startswith("@"): + await bot.api.send_text_message( + room_id, "For unban, please provide the full Matrix ID (e.g., @user:domain)." + ) + return + try: + await bot.async_client.room_unban(room_id, target_str) + await bot.api.send_text_message(room_id, f"🔓 Unbanned {target_str}.") + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed to unban: {e}") + + # ------------------------------------------------------------ + # TOPIC + # ------------------------------------------------------------ + elif action == "topic": + if not sub_args: + try: + resp = await bot.async_client.room_get_state_event(room_id, "m.room.topic") + topic = resp.content.get("topic", "(none)") if resp.content else "(none)" + await bot.api.send_text_message(room_id, f"📝 Topic: {topic}") + except Exception: + await bot.api.send_text_message(room_id, "Could not retrieve topic.") + else: + new_topic = " ".join(sub_args) + try: + await bot.async_client.room_put_state(room_id, "m.room.topic", {"topic": new_topic}) + await bot.api.send_text_message(room_id, "📝 Topic updated.") + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed: {e}") + + # ------------------------------------------------------------ + # ROOM NAME + # ------------------------------------------------------------ + elif action == "roomname": + if not sub_args: + try: + resp = await bot.async_client.room_get_state_event(room_id, "m.room.name") + name = resp.content.get("name", "(none)") if resp.content else "(none)" + await bot.api.send_text_message(room_id, f"🏠 Room name: {name}") + except Exception: + await bot.api.send_text_message(room_id, "Could not retrieve room name.") + else: + new_name = " ".join(sub_args) + try: + await bot.async_client.room_put_state(room_id, "m.room.name", {"name": new_name}) + await bot.api.send_text_message(room_id, "🏠 Room name updated.") + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed: {e}") + + # ------------------------------------------------------------ + # AVATAR + # ------------------------------------------------------------ + elif action == "avatar": + if not sub_args: + try: + resp = await bot.async_client.room_get_state_event(room_id, "m.room.avatar") + url = resp.content.get("url", "not set") if resp.content else "not set" + await bot.api.send_text_message(room_id, f"🖼️ Avatar URL: {url}") + except Exception: + await bot.api.send_text_message(room_id, "Could not retrieve avatar.") + else: + mxc_url = sub_args[0] + if not mxc_url.startswith("mxc://"): + await bot.api.send_text_message(room_id, "Invalid avatar URL. Must start with mxc://.") + return + try: + await bot.async_client.room_put_state(room_id, "m.room.avatar", {"url": mxc_url}) + await bot.api.send_text_message(room_id, "🖼️ Avatar set.") + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed: {e}") + + # ------------------------------------------------------------ + # MEMBERS + # ------------------------------------------------------------ + elif action == "members": + try: + resp = await bot.async_client.joined_members(room_id) + if not resp.members: + await bot.api.send_text_message(room_id, "No members found.") + return + lines = ["**Members:**"] + for member in resp.members: + display = member.display_name or member.user_id + pl = await get_power_level(bot, room_id, member.user_id) + lines.append(f"{display} (`{member.user_id}`) [PL: {pl}]") + msg = "\n".join(lines) + while len(msg) > 4000: + part = msg[:4000] + msg = msg[4000:] + await bot.api.send_text_message(room_id, part) + if msg: + await bot.api.send_text_message(room_id, msg) + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed to list members: {e}") + + # ------------------------------------------------------------ + # BANS + # ------------------------------------------------------------ + elif action == "bans": + try: + banned = await get_banned_users(bot, room_id) + if not banned: + await bot.api.send_text_message(room_id, "No banned users.") + else: + ban_list = "\n".join(f"• `{u}`" for u in banned) + await bot.api.send_text_message(room_id, f"**Banned users:**\n{ban_list}") + except Exception as e: + await bot.api.send_text_message(room_id, f"❌ Failed to fetch bans: {e}") + + # ------------------------------------------------------------ + # HELP + # ------------------------------------------------------------ + elif action in ("help", "modhelp"): + help_text = """ +**Moderator Commands (standalone or via !admin):** +- `!kick <@user|name> [reason]` – Kick a user +- `!ban <@user|name> [reason]` – Ban a user +- `!unban <@user:domain>` – Unban (full MXID required) +- `!invite <@user|name>` – Invite a user +- `!whois <@user|name>` – Show user details & power level +- `!op <@user|name> [pl=50]` – Promote user (max 50, moderator) +- `!deop <@user|name>` – Demote user to power level 0 +- `!topic [new topic]` – Show / set room topic +- `!roomname [new name]` – Show / set room name +- `!avatar [mxc://...]` – Show / set room avatar +- `!members` – List all joined members with power levels +- `!bans` – List all banned users +- `!modhelp` – Show this help + +Names may be **multi‑word**; the bot will automatically detect them. +If the name is ambiguous you'll be asked to choose from a numbered list. + """ + await bot.api.send_text_message(room_id, help_text.strip()) + + else: + await bot.api.send_text_message( + room_id, f"Unknown action: {action}. Use `!modhelp`." + ) + +# ------------------------------------------------------------------ +# Plugin metadata +# ------------------------------------------------------------------ +__version__ = "1.1.0" +__author__ = "Funguy Admin" +__description__ = "Full room moderation – multi‑word name support" +__help__ = """ +
+Admin / Moderator Commands +
    +
  • !kick, !ban, !unban, !invite
  • +
  • !op (max PL 50), !deop, !whois
  • +
  • !topic, !roomname, !avatar
  • +
  • !members, !bans
  • +
  • !admin <action> also works as a parent command
  • +
+

Power level ≥ 50 required (or global admin).

+

Multi‑word display names are automatically recognized.

+
+""" diff --git a/plugins/cron.py b/plugins/cron.py index 2838a25..a000e76 100644 --- a/plugins/cron.py +++ b/plugins/cron.py @@ -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 [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 [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 -# --------------------------------------------------------------------------- -# 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 (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 via cron syntax +!cron – Schedule commands (room‑context aware)
    -
  • !cron add <room_id> <cron_entry> <command> – Add job
  • -
  • !cron remove <room_id> <command> – Remove job
  • +
  • !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.

+

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 * * *

""" diff --git a/plugins/ddg.py b/plugins/ddg.py index 068bb6d..09f8d5a 100644 --- a/plugins/ddg.py +++ b/plugins/ddg.py @@ -1,510 +1,345 @@ +#!/usr/bin/env python3 """ -This plugin provides DuckDuckGo search functionality using the DuckDuckGo Instant Answer API. +DuckDuckGo search plugin (ddgs library). Results are shown inside collapsible details boxes. """ +import asyncio import logging -import requests -import json +from html import escape + import simplematrixbotlib as botlib -from urllib.parse import quote, urlencode -import html +from ddgs import DDGS -DDG_API_URL = "https://api.duckduckgo.com/" -DDG_SEARCH_URL = "https://html.duckduckgo.com/html/" +logger = logging.getLogger("ddg") +# --------------------------------------------------------------------------- +# Async search wrapper +# --------------------------------------------------------------------------- +async def _async_search(func, *args, **kwargs): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: func(*args, **kwargs)) + +# --------------------------------------------------------------------------- +# Command handler +# --------------------------------------------------------------------------- async def handle_command(room, message, bot, prefix, config): - """ - Function to handle DuckDuckGo search commands. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None - """ match = botlib.MessageMatch(room, message, bot, prefix) - if match.is_not_from_this_bot() and match.prefix() and match.command("ddg"): - logging.info("Received !ddg command") + if not (match.is_not_from_this_bot() and match.prefix() and match.command("ddg")): + return - args = match.args() + args = match.args() + if not args: + await send_help(room, bot) + return - if len(args) < 1: - await show_usage(room, bot) + subcommand = args[0].lower() + + # ---- Instant answer (default) ---- + if subcommand in ("instant", "i"): + query = " ".join(args[1:]) if len(args) > 1 else "" + if not query: + await bot.api.send_text_message(room.room_id, "Usage: !ddg instant ") return + await instant_answer(room, bot, query) - subcommand = args[0].lower() - - if subcommand == "search": - if len(args) < 2: - await bot.api.send_text_message(room.room_id, "Usage: !ddg search ") - return - query = ' '.join(args[1:]) - await ddg_search(room, bot, query) - - elif subcommand == "instant": - if len(args) < 2: - await bot.api.send_text_message(room.room_id, "Usage: !ddg instant ") - return - query = ' '.join(args[1:]) - await ddg_instant_answer(room, bot, query) - - elif subcommand == "image": - if len(args) < 2: - await bot.api.send_text_message(room.room_id, "Usage: !ddg image ") - return - query = ' '.join(args[1:]) - await ddg_image_search(room, bot, query) - - elif subcommand == "news": - if len(args) < 2: - await bot.api.send_text_message(room.room_id, "Usage: !ddg news ") - return - query = ' '.join(args[1:]) - await ddg_news_search(room, bot, query) - - elif subcommand == "video": - if len(args) < 2: - await bot.api.send_text_message(room.room_id, "Usage: !ddg video ") - return - query = ' '.join(args[1:]) - await ddg_video_search(room, bot, query) - - elif subcommand == "bang": - if len(args) < 2: - await show_bang_help(room, bot) - return - bang_query = ' '.join(args[1:]) - await ddg_bang_search(room, bot, bang_query) - - elif subcommand == "define": - if len(args) < 2: - await bot.api.send_text_message(room.room_id, "Usage: !ddg define ") - return - word = ' '.join(args[1:]) - await ddg_definition(room, bot, word) - - elif subcommand == "calc": - if len(args) < 2: - await bot.api.send_text_message(room.room_id, "Usage: !ddg calc ") - return - expression = ' '.join(args[1:]) - await ddg_calculator(room, bot, expression) - - elif subcommand == "weather": - location = ' '.join(args[1:]) if len(args) > 1 else "" - await ddg_weather(room, bot, location) - - elif subcommand == "help": - await show_usage(room, bot) - - else: - # Default to instant answer search - query = ' '.join(args) - await ddg_instant_answer(room, bot, query) - -async def show_usage(room, bot): - """Display DuckDuckGo command usage.""" - usage = """ -🦆 DuckDuckGo Search Commands - -!ddg <query> - Instant answer search (default) -!ddg search <query> - Web search with results -!ddg instant <query> - Instant answer with detailed info -!ddg image <query> - Image search -!ddg news <query> - News search -!ddg video <query> - Video search -!ddg bang <!bang query> - Use DuckDuckGo bangs -!ddg define <word> - Word definitions -!ddg calc <expression> - Calculator -!ddg weather [location] - Weather information -!ddg help - Show this help - -Examples: -• !ddg python programming -• !ddg search matrix protocol -• !ddg image cute cats -• !ddg bang !w matrix -• !ddg define serendipity -• !ddg calc 2+2*5 -• !ddg weather London - -Popular Bangs: -• !w - Wikipedia -• !g - Google -• !yt - YouTube -• !aw - ArchWiki -• !gh - GitHub -• !so - Stack Overflow -""" - await bot.api.send_markdown_message(room.room_id, usage) - -async def show_bang_help(room, bot): - """Display DuckDuckGo bang help.""" - bang_help = """ -🦆 DuckDuckGo Bangs - -Usage: !ddg bang <!bang query> - -Popular Bangs: -• !ddg bang !w matrix - Search Wikipedia -• !ddg bang !g python - Search Google -• !ddg bang !yt music - Search YouTube -• !ddg bang !aw arch - Search ArchWiki -• !ddg bang !gh repository - Search GitHub -• !ddg bang !so error - Search Stack Overflow -• !ddg bang !amazon book - Search Amazon -• !ddg bang !imdb movie - Search IMDb -• !ddg bang !reddit topic - Search Reddit -• !ddg bang !tw tweet - Search Twitter - -More Bangs: -• !ddg - DuckDuckGo -• !bing - Bing -• !ddgimages - DuckDuckGo Images -• !npm - npm packages -• !cpp - C++ reference -• !python - Python docs -• !rust - Rust docs -• !mdn - MDN Web Docs - -Thousands of bangs available! See: https://duckduckgo.com/bangs -""" - await bot.api.send_markdown_message(room.room_id, bang_help) - -async def ddg_instant_answer(room, bot, query): - """Get DuckDuckGo instant answer.""" - try: - params = { - 'q': query, - 'format': 'json', - 'no_html': '1', - 'skip_disambig': '1', - 'no_redirect': '1' - } - - logging.info(f"Fetching DuckDuckGo instant answer for: {query}") - - response = requests.get(DDG_API_URL, params=params, timeout=10) - - if response.status_code != 200: - # If API fails, provide direct search link - search_url = f"https://duckduckgo.com/?q={quote(query)}" - await bot.api.send_markdown_message( - room.room_id, - f"🦆 DuckDuckGo: {html.escape(query)}

" - f"API temporarily unavailable. Search on DuckDuckGo" - ) + # ---- Web search ---- + elif subcommand == "search": + query = " ".join(args[1:]) if len(args) > 1 else "" + if not query: + await bot.api.send_text_message(room.room_id, "Usage: !ddg search ") return + await web_search(room, bot, query) - data = response.json() - - output = f"🦆 DuckDuckGo: {html.escape(query)}

" - - # Handle different answer types - if data.get('AbstractText'): - # Wikipedia-style answer - output += f"📚 {data.get('Heading', 'Definition')}
" - output += f"{html.escape(data['AbstractText'])}
" - if data.get('AbstractURL'): - output += f"Read more on {data.get('AbstractSource', 'Wikipedia')}
" - - elif data.get('Answer'): - # Direct answer - output += f"💡 Answer
" - output += f"{html.escape(data['Answer'])}
" - - elif data.get('Definition'): - # Definition - output += f"📖 Definition
" - output += f"{html.escape(data['Definition'])}
" - if data.get('DefinitionSource'): - output += f"Source: {data['DefinitionSource']}
" - - elif data.get('Results'): - # List of results - output += f"🔍 Results
" - for result in data['Results'][:3]: - output += f"• {html.escape(result.get('Text', 'Result'))}
" - - elif data.get('RelatedTopics'): - # Related topics - output += f"🔗 Related Topics
" - for topic in data['RelatedTopics'][:3]: - if isinstance(topic, dict) and topic.get('FirstURL'): - output += f"• {html.escape(topic.get('Text', 'Topic'))}
" - elif isinstance(topic, dict) and topic.get('Name'): - output += f"• {html.escape(topic['Name'])}
" - - else: - # No instant answer found, show search results - output += "🔍 No instant answer found.
" - - # Add search link - search_url = f"https://duckduckgo.com/?q={quote(query)}" - output += f"
View all results on DuckDuckGo" - - await bot.api.send_markdown_message(room.room_id, output) - - except Exception as e: - # Fallback to direct search link - search_url = f"https://duckduckgo.com/?q={quote(query)}" - await bot.api.send_markdown_message( - room.room_id, - f"🦆 DuckDuckGo: {html.escape(query)}

" - f"Error accessing API. Search on DuckDuckGo" - ) - logging.error(f"Error in ddg_instant_answer: {e}") - -async def ddg_search(room, bot, query): - """Perform web search with multiple results.""" - try: - await ddg_web_search(room, bot, query, limit=5) - except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error performing search: {str(e)}") - -async def ddg_web_search(room, bot, query, limit=5): - """Perform web search and return results.""" - try: - params = { - 'q': query, - 'format': 'json' - } - - response = requests.get(DDG_API_URL, params=params, timeout=10) - - if response.status_code != 200: - # Fallback to direct search - search_url = f"https://duckduckgo.com/?q={quote(query)}" - await bot.api.send_markdown_message( - room.room_id, - f"🔍 DuckDuckGo Search: {html.escape(query)}

" - f"API temporarily unavailable. Search on DuckDuckGo" - ) + # ---- Image search ---- + elif subcommand == "image": + query = " ".join(args[1:]) if len(args) > 1 else "" + if not query: + await bot.api.send_text_message(room.room_id, "Usage: !ddg image ") return + await image_search(room, bot, query) - data = response.json() - - output = f"🔍 DuckDuckGo Search: {html.escape(query)}

" - - results_shown = 0 - - # Show instant answer if available - if data.get('AbstractText') and results_shown < limit: - output += f"💡 {data.get('Heading', 'Instant Answer')}
" - abstract = data['AbstractText'][:200] + "..." if len(data['AbstractText']) > 200 else data['AbstractText'] - output += f"{html.escape(abstract)}
" - if data.get('AbstractURL'): - output += f"Read more
" - output += "
" - results_shown += 1 - - # Show web results - if data.get('Results') and results_shown < limit: - output += "🌐 Web Results
" - for result in data['Results'][:limit - results_shown]: - output += f"• {html.escape(result.get('Text', 'Result'))}
" - results_shown += 1 - - # Show related topics - if data.get('RelatedTopics') and results_shown < limit: - output += "🔗 Related Topics
" - for topic in data['RelatedTopics'][:limit - results_shown]: - if isinstance(topic, dict) and topic.get('FirstURL'): - output += f"• {html.escape(topic.get('Text', 'Topic'))}
" - results_shown += 1 - - # Add search link - search_url = f"https://duckduckgo.com/?q={quote(query)}" - output += f"
View all results on DuckDuckGo" - - await bot.api.send_markdown_message(room.room_id, output) - - except Exception as e: - # Fallback to direct search - search_url = f"https://duckduckgo.com/?q={quote(query)}" - await bot.api.send_markdown_message( - room.room_id, - f"🔍 DuckDuckGo Search: {html.escape(query)}

" - f"Error accessing API. Search on DuckDuckGo" - ) - logging.error(f"Error in ddg_web_search: {e}") - -async def ddg_image_search(room, bot, query): - """Perform image search.""" - try: - params = { - 'q': query, - 'format': 'json', - 'iax': 'images', - 'ia': 'images' - } - - response = requests.get(DDG_API_URL, params=params, timeout=10) - - if response.status_code != 200: - search_url = f"https://duckduckgo.com/?q={quote(query)}&iax=images&ia=images" - await bot.api.send_markdown_message( - room.room_id, - f"🖼️ DuckDuckGo Images: {html.escape(query)}

" - f"API temporarily unavailable. Search images on DuckDuckGo" - ) + # ---- News search ---- + elif subcommand == "news": + query = " ".join(args[1:]) if len(args) > 1 else "" + if not query: + await bot.api.send_text_message(room.room_id, "Usage: !ddg news ") return + await news_search(room, bot, query) - data = response.json() + # ---- Video search ---- + elif subcommand == "video": + query = " ".join(args[1:]) if len(args) > 1 else "" + if not query: + await bot.api.send_text_message(room.room_id, "Usage: !ddg video ") + return + await video_search(room, bot, query) - output = f"🖼️ DuckDuckGo Images: {html.escape(query)}

" + # ---- Bang search ---- + elif subcommand == "bang": + bang_query = " ".join(args[1:]) if len(args) > 1 else "" + if not bang_query: + await bang_help(room, bot) + return + await bang_search(room, bot, bang_query) - if data.get('Results'): - output += "📸 Image Results
" - for image in data['Results'][:3]: - output += f"• {html.escape(image.get('Title', 'Image'))}
" - if image.get('Width') and image.get('Height'): - output += f" Size: {image['Width']}×{image['Height']}
" - else: - output += "No image results found.
" + # ---- Definitions ---- + elif subcommand == "define": + word = " ".join(args[1:]) if len(args) > 1 else "" + if not word: + await bot.api.send_text_message(room.room_id, "Usage: !ddg define ") + return + await definition(room, bot, word) - # Add search link - search_url = f"https://duckduckgo.com/?q={quote(query)}&iax=images&ia=images" - output += f"
View all images on DuckDuckGo" + # ---- Calculator ---- + elif subcommand == "calc": + expr = " ".join(args[1:]) if len(args) > 1 else "" + if not expr: + await bot.api.send_text_message(room.room_id, "Usage: !ddg calc ") + return + await calculator(room, bot, expr) - await bot.api.send_markdown_message(room.room_id, output) - - except Exception as e: - search_url = f"https://duckduckgo.com/?q={quote(query)}&iax=images&ia=images" - await bot.api.send_markdown_message( - room.room_id, - f"🖼️ DuckDuckGo Images: {html.escape(query)}

" - f"Error accessing API. Search images on DuckDuckGo" - ) - -async def ddg_news_search(room, bot, query): - """Perform news search.""" - try: - search_url = f"https://duckduckgo.com/?q={quote(query)}&iar=news" - await bot.api.send_markdown_message( - room.room_id, - f"📰 DuckDuckGo News: {html.escape(query)}

" - f"View news on DuckDuckGo" - ) - except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error performing news search: {str(e)}") - -async def ddg_video_search(room, bot, query): - """Perform video search.""" - try: - search_url = f"https://duckduckgo.com/?q={quote(query)}&iar=videos" - await bot.api.send_markdown_message( - room.room_id, - f"🎬 DuckDuckGo Videos: {html.escape(query)}

" - f"View videos on DuckDuckGo" - ) - except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error performing video search: {str(e)}") - -async def ddg_bang_search(room, bot, bang_query): - """Perform search using DuckDuckGo bangs.""" - try: - # Create search URL directly - this is more reliable than API for bangs - search_url = f"https://duckduckgo.com/?q={quote(bang_query)}" - - # Common bangs with descriptions - bang_descriptions = { - '!w': 'Wikipedia', - '!g': 'Google', - '!yt': 'YouTube', - '!aw': 'ArchWiki', - '!gh': 'GitHub', - '!so': 'Stack Overflow', - '!amazon': 'Amazon', - '!imdb': 'IMDb', - '!reddit': 'Reddit', - '!tw': 'Twitter' - } - - # Extract bang for description - bang = bang_query.split(' ')[0] if ' ' in bang_query else bang_query - description = bang_descriptions.get(bang, 'Site-specific search') - - output = f"🎯 DuckDuckGo Bang: {html.escape(bang)}
" - output += f"Description: {description}
" - - if ' ' in bang_query: - output += f"Query: {html.escape(bang_query.split(' ', 1)[1])}

" - - output += f"Search with {html.escape(bang)} on DuckDuckGo" - - await bot.api.send_markdown_message(room.room_id, output) - - except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error with bang search: {str(e)}") - -async def ddg_definition(room, bot, word): - """Get word definition.""" - try: - search_url = f"https://duckduckgo.com/?q=define+{quote(word)}" - await bot.api.send_markdown_message( - room.room_id, - f"📖 Definition: {html.escape(word)}

" - f"Get definition on DuckDuckGo" - ) - except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error getting definition: {str(e)}") - -async def ddg_calculator(room, bot, expression): - """Use DuckDuckGo as a calculator.""" - try: - search_url = f"https://duckduckgo.com/?q={quote(expression)}" - await bot.api.send_markdown_message( - room.room_id, - f"🧮 Calculator: {html.escape(expression)}

" - f"Calculate on DuckDuckGo" - ) - except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error with calculator: {str(e)}") - -async def ddg_weather(room, bot, location): - """Get weather information.""" - try: + # ---- Weather ---- + elif subcommand == "weather": + location = " ".join(args[1:]) if len(args) > 1 else "" if not location: location = "current location" + await weather(room, bot, location) - search_url = f"https://duckduckgo.com/?q=weather+{quote(location)}" + # ---- Help ---- + elif subcommand == "help": + await send_help(room, bot) + + # ---- Default: treat as instant answer ---- + else: + query = " ".join(args) + await instant_answer(room, bot, query) + + +# ============================== +# Result functions (all wrapped in
) +# ============================== + +async def instant_answer(room, bot, query): + """Top web result wrapped in a collapsible box.""" + try: + with DDGS() as ddgs: + results = await _async_search(ddgs.text, query, max_results=1) + except Exception as e: + logger.error(f"DDG instant answer error: {e}") await bot.api.send_markdown_message( room.room_id, - f"🌤️ Weather: {html.escape(location)}

" - f"Get weather on DuckDuckGo" + f"🦆 DuckDuckGo: {escape(query)}

Error fetching results. Try again later." ) + return + + content = "" + if results: + r = results[0] + title = escape(r.get("title", "Result")) + body = escape(r.get("body", "")) + content = f"💡 {title}
{body[:300]}…
Read more" + else: + search_url = f"https://duckduckgo.com/?q={escape(query)}" + content = f"No results found.
🔍 Search on DuckDuckGo" + + msg = f"""
+🦆 DuckDuckGo: {escape(query)} +{content} +
""" + await bot.api.send_markdown_message(room.room_id, msg) + + +async def web_search(room, bot, query): + try: + with DDGS() as ddgs: + results = await _async_search(ddgs.text, query, max_results=5) except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error getting weather: {str(e)}") + logger.error(f"DDG web search error: {e}") + await bot.api.send_text_message(room.room_id, f"Error: {e}") + return + + if not results: + await bot.api.send_text_message(room.room_id, f"No results for '{query}'.") + return + + items = "" + for r in results: + title = escape(r.get("title", "Result")) + body = escape(r.get("body", "")) + items += f"• {title}
{body[:200]}…

" + + msg = f"""
+🔍 Search: {escape(query)} +{items} +
""" + await bot.api.send_markdown_message(room.room_id, msg) + + +async def image_search(room, bot, query): + try: + with DDGS() as ddgs: + results = await _async_search(ddgs.images, query, max_results=3) + except Exception as e: + logger.error(f"DDG image error: {e}") + await bot.api.send_text_message(room.room_id, f"Error: {e}") + return + + if not results: + await bot.api.send_text_message(room.room_id, f"No images for '{query}'.") + return + + items = "" + for img in results: + title = escape(img.get("title", "Image")) + items += f"• {title}" + if img.get("width") and img.get("height"): + items += f" ({img['width']}×{img['height']})" + items += "
" + + search_url = f"https://duckduckgo.com/?q={escape(query)}&iax=images&ia=images" + items += f"
🔍 View all images" + + msg = f"""
+🖼️ Images: {escape(query)} +{items} +
""" + await bot.api.send_markdown_message(room.room_id, msg) + + +async def news_search(room, bot, query): + try: + with DDGS() as ddgs: + results = await _async_search(ddgs.news, query, max_results=3) + except Exception as e: + logger.error(f"DDG news error: {e}") + await bot.api.send_text_message(room.room_id, f"Error: {e}") + return + + if not results: + await bot.api.send_text_message(room.room_id, f"No news for '{query}'.") + return + + items = "" + for n in results: + title = escape(n.get("title", "Article")) + body = escape(n.get("body", "")) + items += f"• {title}
{body[:200]}…

" + + msg = f"""
+📰 News: {escape(query)} +{items} +
""" + await bot.api.send_markdown_message(room.room_id, msg) + + +async def video_search(room, bot, query): + try: + with DDGS() as ddgs: + results = await _async_search(ddgs.videos, query, max_results=3) + except Exception as e: + logger.error(f"DDG video error: {e}") + await bot.api.send_text_message(room.room_id, f"Error: {e}") + return + + if not results: + await bot.api.send_text_message(room.room_id, f"No videos for '{query}'.") + return + + items = "" + for v in results: + title = escape(v.get("title", "Video")) + items += f"• {title}
" + + search_url = f"https://duckduckgo.com/?q={escape(query)}&iar=videos" + items += f"
🔍 View all videos" + + msg = f"""
+🎬 Videos: {escape(query)} +{items} +
""" + await bot.api.send_markdown_message(room.room_id, msg) + + +async def bang_search(room, bot, bang_query): + search_url = f"https://duckduckgo.com/?q={escape(bang_query)}" + content = f"🔗 Search with {escape(bang_query)} on DuckDuckGo" + msg = f"""
+🎯 Bang: {escape(bang_query)} +{content} +
""" + await bot.api.send_markdown_message(room.room_id, msg) + + +async def definition(room, bot, word): + await instant_answer(room, bot, f"define {word}") + + +async def calculator(room, bot, expr): + await instant_answer(room, bot, expr) + + +async def weather(room, bot, location): + await instant_answer(room, bot, f"weather {location}") # --------------------------------------------------------------------------- -# Plugin Metadata +# Help messages (no details wrapper – kept readable) # --------------------------------------------------------------------------- +async def bang_help(room, bot): + msg = """ +🎯 DuckDuckGo Bangs
+Usage: !ddg bang !bang query

+Popular bangs:
+• !w – Wikipedia +• !g – Google +• !yt – YouTube +• !aw – ArchWiki +• !gh – GitHub +• !so – Stack Overflow +• !reddit – Reddit +
+Full list here +""" + await bot.api.send_markdown_message(room.room_id, msg) -__version__ = "1.0.0" + +async def send_help(room, bot): + help_msg = """ +🦆 DuckDuckGo Commands
+!ddg <query> – Top result (collapsible)
+!ddg search <query> – 5 web results
+!ddg image <query> – 3 images
+!ddg news <query> – 3 news articles
+!ddg video <query> – 3 videos
+!ddg bang <!bang query> – Bang redirect
+!ddg define <word> – Definition
+!ddg calc <expr> – Calculator
+!ddg weather [city] – Weather
+!ddg help – This help +""" + await bot.api.send_markdown_message(room.room_id, help_msg) + + +# --------------------------------------------------------------------------- +# Plugin metadata +# --------------------------------------------------------------------------- +__version__ = "2.1.0" __author__ = "Funguy Bot" -__description__ = "DuckDuckGo search" +__description__ = "DuckDuckGo search – collapsible results (ddgs library, no API key)" __help__ = """
-!ddg – DuckDuckGo search and instant answers +!ddg – DuckDuckGo search (web, images, news, etc.)
    -
  • !ddg <query> – Instant answer (default)
  • -
  • !ddg search <query> – Web search results
  • -
  • !ddg instant <query> – Detailed instant answer
  • -
  • !ddg image <query> – Image search
  • -
  • !ddg news <query> – News search
  • -
  • !ddg video <query> – Video search
  • -
  • !ddg bang <!bang query> – Use DuckDuckGo bangs
  • -
  • !ddg define <word> – Word definition
  • +
  • !ddg <query> – Top web result snippet (collapsible)
  • +
  • !ddg search <query> – 5 web results
  • +
  • !ddg image <query> – 3 images
  • +
  • !ddg news <query> – 3 news articles
  • +
  • !ddg video <query> – 3 videos
  • +
  • !ddg bang <!bang query> – Bang redirect
  • +
  • !ddg define <word> – Definition
  • !ddg calc <expression> – Calculator
  • -
  • !ddg weather [location] – Weather information
  • -
  • !ddg help – Show detailed help
  • +
  • !ddg weather [location] – Weather
-

No API key required.

+

Uses ddgs library. No API key required.

""" diff --git a/plugins/roomstats.py b/plugins/roomstats.py new file mode 100644 index 0000000..302cdd0 --- /dev/null +++ b/plugins/roomstats.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +""" +plugins/roomstats.py — per‑user room statistics (Limnoria‑style). +Commands: !roomstats, !rank, !stats +""" + +import time +import re +import sqlite3 +import logging + +import nio +import simplematrixbotlib as botlib + +logger = logging.getLogger("roomstats") + +DB_PATH = "roomstats.db" + +# ------------------------------------------------------------------ +# Emoji / smiley regex (Unicode blocks) +# ------------------------------------------------------------------ +EMOJI_RE = re.compile( + "[" + "\U0001F600-\U0001F64F" # Emoticons + "\U0001F300-\U0001F5FF" # Symbols & pictographs + "\U0001F680-\U0001F6FF" # Transport & map + "\U0001F1E0-\U0001F1FF" # Flags + "\U00002702-\U000027B0" # Dingbats + "\U000024C2-\U0001F251" # Misc + "]+", re.UNICODE) + +def count_smileys(text): + """Return number of emoji occurrences.""" + return len(EMOJI_RE.findall(text)) + +# ------------------------------------------------------------------ +# Database init +# ------------------------------------------------------------------ +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + CREATE TABLE IF NOT EXISTS user_room_stats ( + room_id TEXT, + user_id TEXT, + msgs INTEGER DEFAULT 0, + chars INTEGER DEFAULT 0, + words INTEGER DEFAULT 0, + smileys INTEGER DEFAULT 0, + actions INTEGER DEFAULT 0, + joins INTEGER DEFAULT 0, + parts INTEGER DEFAULT 0, + kicks_given INTEGER DEFAULT 0, + kicked_received INTEGER DEFAULT 0, + topics_set INTEGER DEFAULT 0, + last_updated INTEGER, + PRIMARY KEY (room_id, user_id) + ) + """) + conn.commit() + conn.close() + +# ------------------------------------------------------------------ +# Multi‑word user resolution helper +# ------------------------------------------------------------------ +async def resolve_user_from_tokens(bot, room_id, tokens): + """ + Given a list of word tokens, find a matching display name. + Returns (mxid, display_name) or raises ValueError. + """ + # Build cache of (lowered display name → user_id) from joined members + resp = await bot.async_client.joined_members(room_id) + if resp.members is None: + raise ValueError("Could not fetch member list.") + + # Create a dict: lower_display → (mxid, display_name) + # If duplicate display name, store None to signal ambiguity. + cache = {} + for member in resp.members: + display = (member.display_name or "").strip() + if not display: + continue + key = display.lower() + if key in cache: + cache[key] = None + else: + cache[key] = (member.user_id, display) + + # Try progressively longer prefixes of the tokens + for end in range(len(tokens), 0, -1): + candidate = " ".join(tokens[:end]).strip().lower() + if candidate in cache: + entry = cache[candidate] + if entry is not None: + return entry # (mxid, display_name) + else: + # Ambiguous – we need to fetch and check exactly + matches = [] + for member in resp.members: + if (member.display_name or "").strip().lower() == candidate: + matches.append((member.user_id, member.display_name or member.user_id)) + if len(matches) == 1: + return matches[0] + elif len(matches) > 1: + raise ValueError( + f"Multiple users have display name '{candidate}'. Use an MXID instead." + ) + # if none, continue + raise ValueError(f"No member found for '{' '.join(tokens)}'.") + +async def resolve_user(bot, room_id, name_or_tokens): + """ + Accept either a single string (MXID or single-token display name) + or a list of tokens. Returns (mxid, display_name). + """ + if isinstance(name_or_tokens, str): + if name_or_tokens.startswith("@"): + return name_or_tokens, None + # Single token – try direct cache match or fallback to multi‑word + tokens = [name_or_tokens] + else: + tokens = name_or_tokens + + return await resolve_user_from_tokens(bot, room_id, tokens) + +# ------------------------------------------------------------------ +# Setup: register custom event listeners for membership & topics +# ------------------------------------------------------------------ +def setup(bot): + init_db() + + @bot.listener.on_custom_event(nio.RoomMemberEvent) + async def member_event(room, event): + room_id = room.room_id + membership = event.content.get("membership") + state_key = event.state_key + sender = event.sender + + # Ignore the bot's own membership changes + if state_key == bot.async_client.user_id: + return + + if membership == "join": + _incr(room_id, state_key, "joins") + elif membership == "leave": + if sender != state_key: # kick + _incr(room_id, sender, "kicks_given") + _incr(room_id, state_key, "kicked_received") + else: # part + _incr(room_id, state_key, "parts") + + @bot.listener.on_custom_event(nio.RoomTopicEvent) + async def topic_event(room, event): + room_id = room.room_id + sender = event.sender + _incr(room_id, sender, "topics_set") + +def _incr(room_id, user_id, column): + """Increment a stat column by 1, creating row if needed.""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute( + "INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", + (room_id, user_id) + ) + c.execute( + f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?", + (int(time.time()), room_id, user_id) + ) + conn.commit() + conn.close() + +# ------------------------------------------------------------------ +# Message handler – silently records stats, and handles commands +# ------------------------------------------------------------------ +async def handle_command(room, message, bot, prefix, config): + room_id = room.room_id + sender = message.sender + + # ----- silently record stats for any non‑bot message ----- + if sender != bot.async_client.user_id: # <-- FIXED + body = message.body or "" + words = len(body.split()) + chars = len(body) + smileys = count_smileys(body) + is_action = getattr(message, "msgtype", None) == "m.emote" + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender)) + c.execute( + """UPDATE user_room_stats + SET msgs = msgs + 1, + chars = chars + ?, + words = words + ?, + smileys = smileys + ?, + actions = actions + ?, + last_updated = ? + WHERE room_id = ? AND user_id = ?""", + (chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender) + ) + conn.commit() + conn.close() + + # ----- command matching ----- + match = botlib.MessageMatch(room, message, bot, prefix) + if not match.is_not_from_this_bot() or not match.prefix(): + return + + cmd = match.command() + args = match.args() + + # =============================== + # !roomstats + # =============================== + if cmd == "roomstats": + await _handle_roomstats(bot, room_id) + + # =============================== + # !rank + # =============================== + elif cmd == "rank": + if not args: + await bot.api.send_text_message( + room_id, + "Usage: !rank \n" + "Stats: msgs, chars, words, smileys, actions, joins, parts, " + "kicks_given, kicked_received, topics_set" + ) + return + col = args[0].lower() + await _handle_rank(bot, room_id, col) + + # =============================== + # !stats [] + # =============================== + elif cmd == "stats": + if args: + # Use all tokens as the display name (multi‑word) + try: + target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args) + except ValueError as e: + await bot.api.send_text_message(room_id, str(e)) + return + else: + target_mxid = sender + await _handle_user_stats(bot, room_id, target_mxid, sender) + +# ------------------------------------------------------------------ +# Command implementations +# ------------------------------------------------------------------ +VALID_STATS = { + "msgs": "Messages", + "chars": "Characters", + "words": "Words", + "smileys": "Smileys", + "actions": "Actions", + "joins": "Joins", + "parts": "Parts", + "kicks_given": "Kicks given", + "kicked_received": "Times kicked", + "topics_set": "Topics set", +} + +async def _get_aggregate(room_id): + """Return dict of aggregate stats for a room.""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("""SELECT + COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0), + COALESCE(SUM(words),0), COALESCE(SUM(smileys),0), + COALESCE(SUM(actions),0), COALESCE(SUM(joins),0), + COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0), + COALESCE(SUM(kicked_received),0), COALESCE(SUM(topics_set),0) + FROM user_room_stats WHERE room_id=?""", (room_id,)) + row = c.fetchone() + conn.close() + if not row or all(v == 0 for v in row): + return None + return { + "msgs": row[0], "chars": row[1], "words": row[2], "smileys": row[3], + "actions": row[4], "joins": row[5], "parts": row[6], + "kicks_given": row[7], "kicked_received": row[8], "topics_set": row[9] + } + +async def _handle_roomstats(bot, room_id): + agg = await _get_aggregate(room_id) + if not agg: + await bot.api.send_text_message(room_id, "No stats collected yet.") + return + + # Get top 10 by msgs + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("""SELECT user_id, msgs FROM user_room_stats + WHERE room_id=? ORDER BY msgs DESC LIMIT 10""", (room_id,)) + top = c.fetchall() + conn.close() + + # Resolve display names for top users + top_lines = [] + resp = await bot.async_client.joined_members(room_id) + for uid, cnt in top: + disp = uid + if resp.members: + for m in resp.members: + if m.user_id == uid: + disp = m.display_name or uid + break + top_lines.append(f"
  • {disp} — {cnt} msgs
  • ") + + msg = f"""
    +Room Statistics +
      +
    • 📩 Messages: {agg['msgs']}
    • +
    • 🔤 Characters: {agg['chars']}
    • +
    • 📝 Words: {agg['words']}
    • +
    • 😀 Smileys: {agg['smileys']}
    • +
    • 🎭 Actions: {agg['actions']}
    • +
    • 🚪 Joins: {agg['joins']}
    • +
    • 👋 Parts: {agg['parts']}
    • +
    • 👢 Kicks given: {agg['kicks_given']}
    • +
    • 🥾 Times kicked: {agg['kicked_received']}
    • +
    • 📌 Topics set: {agg['topics_set']}
    • +
    +

    Top 10 by messages:

    +
      +{''.join(top_lines)} +
    +
    """ + await bot.api.send_markdown_message(room_id, msg) + +async def _handle_rank(bot, room_id, col): + # Validate column + if col not in VALID_STATS: + await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}") + return + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + # Safe to use f-string because col is validated against a hardcoded set + c.execute(f"""SELECT user_id, {col} FROM user_room_stats + WHERE room_id=? AND {col} > 0 ORDER BY {col} DESC LIMIT 10""", (room_id,)) + rows = c.fetchall() + conn.close() + + if not rows: + await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.") + return + + resp = await bot.async_client.joined_members(room_id) + items = [] + for i, (uid, val) in enumerate(rows, 1): + disp = uid + if resp.members: + for m in resp.members: + if m.user_id == uid: + disp = m.display_name or uid + break + items.append(f"
  • {i}. {disp} — {val}
  • ") + + msg = f"""
    +Ranking by {VALID_STATS[col]} +
      +{''.join(items)} +
    +
    """ + await bot.api.send_markdown_message(room_id, msg) + +async def _handle_user_stats(bot, room_id, user_id, sender): + # Fetch stats + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts, + kicks_given, kicked_received, topics_set + FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id)) + row = c.fetchone() + conn.close() + + if not row or all(v == 0 for v in row): + # No stats, maybe just joined – get display name for the message + disp = user_id + resp = await bot.async_client.joined_members(room_id) + if resp.members: + for m in resp.members: + if m.user_id == user_id: + disp = m.display_name or user_id + break + await bot.api.send_text_message(room_id, f"No stats recorded for {disp}.") + return + + # Get display name + disp = user_id + resp = await bot.async_client.joined_members(room_id) + if resp.members: + for m in resp.members: + if m.user_id == user_id: + disp = m.display_name or user_id + break + + msg = f"""
    +Stats for {disp} +
      +
    • 📩 Messages: {row[0]}
    • +
    • 🔤 Characters: {row[1]}
    • +
    • 📝 Words: {row[2]}
    • +
    • 😀 Smileys: {row[3]}
    • +
    • 🎭 Actions: {row[4]}
    • +
    • 🚪 Joins: {row[5]}
    • +
    • 👋 Parts: {row[6]}
    • +
    • 👢 Kicks given: {row[7]}
    • +
    • 🥾 Times kicked: {row[8]}
    • +
    • 📌 Topics set: {row[9]}
    • +
    +
    """ + await bot.api.send_markdown_message(room_id, msg) + +# ------------------------------------------------------------------ +# Plugin metadata +# ------------------------------------------------------------------ +__version__ = "1.0.1" +__author__ = "Funguy Roomstats" +__description__ = "Per‑user room statistics (Limnoria‑style), with multi‑word name support" +__help__ = """ +
    +Room Statistics Commands +
      +
    • !roomstats – Aggregate room stats + top 10 users
    • +
    • !rank <stat> – Top 10 by a specific stat (msgs, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set)
    • +
    • !stats [name] – Show stats for a user (supports multi‑word names)
    • +
    +

    All commands work in the current room; display names are automatically resolved.

    +
    +""" diff --git a/requirements.txt b/requirements.txt index 959aad0..4ce88bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ python-dotenv requests -duckduckgo_search nio markdown2 watchdog @@ -19,3 +18,6 @@ aiohttp aiosqlite pillow omdbapi +apscheduler +pytz +ddgs \ No newline at end of file diff --git a/roomstats.db b/roomstats.db new file mode 100644 index 0000000000000000000000000000000000000000..daadc185b1d9563a70d6c86617fd7cfcf936d9bd GIT binary patch literal 12288 zcmeI%L2uJA6bEoSRM1W80@9>i&23Ys3WPY&fmneIMJs460on^umUyjc(gfQr9cK>g z^Kjw|aN^@2#9;@VjRCDD8BOAV^na4my!zQue@+f@pLN2xk@M{+l&!H zR!zHP+F4L@QaMY^XPMh#l{BVDHTOGlYCTfxxu0wU&<_C!KmY;|fB*y_009U<;C~Z% z|B7B&tJmoVrKA0nPZXD-%=0WXxmF9Q^RVl+0*?i)2W^inbh7nj_D7sdA`%vOy&@ zGv1jF)1Bl+)BOD|#jKIb!FG|F?SZD*D3VIcNjz$(L#279oT?*^ztLT~;%`xhF8$qX z!$=MH&9p)`v;3V%)2O`2HRrYx$6voJn_uO)N5uVX4hRT900Izz00bZa0SG_<0uX=z a1pckS-HO>Fxah{for=G;WEp(^KEMwhDjrq< literal 0 HcmV?d00001