""" Factoids plugin – a clone of the classic infobot / supybot Factoids plugin. Stores and retrieves factoids via Matrix chat. Commands: !fact – retrieve a factoid !fact search – search factoids by key or value !fact info – show metadata for a factoid !fact random – show a random factoid !fact stats – show database statistics !fact list [glob] – list factoid keys matching a glob pattern !fact lock – lock a factoid (admin only) !fact unlock – unlock a factoid (admin only) !fact change is – change an existing factoid !learn is – teach the bot a new factoid !forget – delete a factoid !also is – append to an existing factoid !no, is – replace a factoid (same as change) In‑line query (no prefix needed): ? – ask for a factoid Special value tags: text – replies with "text" (not "key is text") text – replies as an emote (/me) a | b | c – picks one option at random """ import sqlite3 import logging import random import re import time import asyncio import simplematrixbotlib as botlib from plugins.common import code_block, collapsible_summary, html_escape DB_PATH = "factoids.db" # --------------------------------------------------------------------------- # Database helpers # --------------------------------------------------------------------------- def init_db(): """Ensure the factoids table exists.""" conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS factoids ( factoid_key VARCHAR(64) NOT NULL DEFAULT '' PRIMARY KEY, requested_by VARCHAR(80), requested_time INTEGER, requested_count SMALLINT, created_by VARCHAR(80), created_time INTEGER DEFAULT 0, modified_by VARCHAR(80), modified_time INTEGER, locked_by VARCHAR(80), locked_time INTEGER, factoid_value TEXT NOT NULL ) """) conn.commit() conn.close() def _conn(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn # --------------------------------------------------------------------------- # Core operations # --------------------------------------------------------------------------- def _normalise_key(raw: str) -> str: """Lower-case, strip punctuation, collapse whitespace.""" key = raw.strip().lower() key = re.sub(r'[?.,!]+$', '', key) key = ' '.join(key.split()) return key def get_factoid(key: str) -> dict | None: """Return a factoid row (or None) and bump its request count.""" conn = _conn() row = conn.execute( "SELECT * FROM factoids WHERE factoid_key = ?", (key,) ).fetchone() if row: conn.execute( "UPDATE factoids SET requested_count = COALESCE(requested_count,0)+1, " "requested_time = ? WHERE factoid_key = ?", (int(time.time()), key) ) conn.commit() result = dict(row) else: result = None conn.close() return result def set_factoid(key: str, value: str, created_by: str, locked_by: str = None): """Insert or replace a factoid.""" now = int(time.time()) conn = _conn() conn.execute( """INSERT OR REPLACE INTO factoids (factoid_key, factoid_value, created_by, created_time, modified_by, modified_time, locked_by, locked_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (key, value, created_by, now, created_by, now, locked_by, now if locked_by else None) ) conn.commit() conn.close() def append_factoid(key: str, addition: str, modified_by: str) -> bool: """Append text to an existing factoid. Returns True if it existed.""" conn = _conn() row = conn.execute("SELECT factoid_value FROM factoids WHERE factoid_key = ?", (key,)).fetchone() if not row: conn.close() return False new_value = row["factoid_value"] + " or " + addition conn.execute( "UPDATE factoids SET factoid_value = ?, modified_by = ?, modified_time = ? WHERE factoid_key = ?", (new_value, modified_by, int(time.time()), key) ) conn.commit() conn.close() return True def delete_factoid(key: str) -> bool: """Delete a factoid. Returns True if it existed.""" conn = _conn() cur = conn.execute("DELETE FROM factoids WHERE factoid_key = ?", (key,)) existed = cur.rowcount > 0 conn.commit() conn.close() return existed def search_factoids(query: str, limit: int = 20) -> list[dict]: """Search factoids by key or value.""" conn = _conn() like = f"%{query}%" rows = conn.execute( "SELECT * FROM factoids WHERE factoid_key LIKE ? OR factoid_value LIKE ? LIMIT ?", (like, like, limit) ).fetchall() conn.close() return [dict(r) for r in rows] def list_keys(glob_pattern: str = None, limit: int = 50) -> list[str]: """List factoid keys, optionally matching a glob pattern.""" conn = _conn() if glob_pattern: like = glob_pattern.replace("*", "%").replace("?", "_") rows = conn.execute( "SELECT factoid_key FROM factoids WHERE factoid_key LIKE ? ORDER BY factoid_key LIMIT ?", (like, limit) ).fetchall() else: rows = conn.execute( "SELECT factoid_key FROM factoids ORDER BY factoid_key LIMIT ?", (limit,) ).fetchall() conn.close() return [r["factoid_key"] for r in rows] def random_factoid() -> dict | None: """Return a random factoid.""" conn = _conn() row = conn.execute( "SELECT * FROM factoids ORDER BY RANDOM() LIMIT 1" ).fetchone() conn.close() return dict(row) if row else None def get_stats() -> dict: """Return aggregate statistics.""" conn = _conn() total = conn.execute("SELECT COUNT(*) AS n FROM factoids").fetchone()["n"] top = conn.execute( "SELECT factoid_key, requested_count FROM factoids ORDER BY COALESCE(requested_count,0) DESC LIMIT 10" ).fetchall() conn.close() return {"total": total, "top": [dict(r) for r in top]} def lock_factoid(key: str, locked_by: str) -> bool: """Lock a factoid. Returns True if it existed.""" conn = _conn() cur = conn.execute( "UPDATE factoids SET locked_by = ?, locked_time = ? WHERE factoid_key = ?", (locked_by, int(time.time()), key) ) existed = cur.rowcount > 0 conn.commit() conn.close() return existed def unlock_factoid(key: str) -> bool: """Unlock a factoid. Returns True if it existed.""" conn = _conn() cur = conn.execute( "UPDATE factoids SET locked_by = NULL, locked_time = NULL WHERE factoid_key = ?", (key,) ) existed = cur.rowcount > 0 conn.commit() conn.close() return existed # --------------------------------------------------------------------------- # Value formatting # --------------------------------------------------------------------------- def _format_response(key: str, raw_value: str) -> str: """Format a factoid value for display, handling , , and |.""" value = raw_value.strip() if value.startswith(""): return value[len(""):].strip() if value.startswith(""): action = value[len(""):].strip() return f"* {key} {action}" if "|" in value: parts = [p.strip() for p in value.split("|")] return f"{key} is {random.choice(parts)}" return f"{key} is {value}" def _format_info(fact: dict) -> str: """Format factoid metadata as code-block rows.""" rows = [ ("🔑", "Key", fact["factoid_key"]), ("📝", "Value", fact["factoid_value"][:200] + ("…" if len(fact.get("factoid_value",""))>200 else "")), ] if fact.get("created_by"): rows.append(("👤", "Created by", fact["created_by"])) if fact.get("created_time"): rows.append(("📅", "Created", time.strftime("%Y-%m-%d", time.localtime(fact["created_time"])))) if fact.get("modified_by") and fact["modified_by"] != fact.get("created_by"): rows.append(("✏️", "Modified by", fact["modified_by"])) if fact.get("requested_count"): rows.append(("🔢", "Requested", f"{fact['requested_count']} times")) if fact.get("locked_by"): rows.append(("🔒", "Locked by", fact["locked_by"])) sections = [{"title": "", "rows": rows}] return code_block(f"ℹ️ Factoid Info: {fact['factoid_key']}", sections) # --------------------------------------------------------------------------- # Command handler # --------------------------------------------------------------------------- async def handle_command(room, message, bot, prefix, config): init_db() match = botlib.MessageMatch(room, message, bot, prefix) room_id = room.room_id sender = str(message.sender) body = (message.body or "").strip() is_admin = (sender == config.admin_user) # ---- In-line factoid query: X? (no prefix needed) ---- # Only retrieval is allowed without prefix; learning requires !learn etc. if match.is_not_from_this_bot() and not match.prefix(): stripped = body.strip() if stripped.endswith("?") and not stripped.startswith("!"): key = _normalise_key(stripped[:-1]) if key: fact = get_factoid(key) if fact: resp = _format_response(key, fact["factoid_value"]) await bot.api.send_markdown_message(room_id, resp) return # All learning now requires a ! prefix, so we ignore unprefixed messages return # ---- Prefixed commands ---- if not (match.is_not_from_this_bot() and match.prefix()): return cmd = match.command() args = match.args() # !fact if cmd == "fact": if not args: await _send_help(room, bot) return sub = args[0].lower() # !fact search if sub == "search" and len(args) >= 2: query = " ".join(args[1:]) results = search_factoids(query) if not results: await bot.api.send_text_message(room_id, f"🔍 No factoids matching '{html_escape(query)}'.") return rows = [] for f in results: val = f["factoid_value"][:80] + ("…" if len(f["factoid_value"]) > 80 else "") rows.append(("📌", f["factoid_key"], val)) sections = [{"title": f"Search: {html_escape(query)}", "rows": rows}] block = code_block(f"🔍 Factoid Search: {html_escape(query)}", sections) output = collapsible_summary(f"🔍 Factoids matching '{html_escape(query)}'", block) await bot.api.send_markdown_message(room_id, output) return # !fact info if sub == "info" and len(args) >= 2: key = _normalise_key(" ".join(args[1:])) fact = get_factoid(key) if not fact: await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.") return await bot.api.send_markdown_message(room_id, _format_info(fact)) return # !fact random if sub == "random": fact = random_factoid() if not fact: await bot.api.send_text_message(room_id, "📭 No factoids in the database yet.") return resp = _format_response(fact["factoid_key"], fact["factoid_value"]) await bot.api.send_markdown_message(room_id, resp) return # !fact stats if sub == "stats": stats = get_stats() rows = [("📊", "Total factoids", str(stats["total"]))] for i, t in enumerate(stats["top"], 1): count = t.get("requested_count") or 0 rows.append(("🏅", f"#{i} {t['factoid_key']}", f"{count} requests")) sections = [{"title": "Factoid Statistics", "rows": rows}] block = code_block("📊 Factoid Stats", sections) output = collapsible_summary("📊 Factoid Statistics", block) await bot.api.send_markdown_message(room_id, output) return # !fact list [glob] if sub == "list": pattern = " ".join(args[1:]) if len(args) > 1 else None keys = list_keys(pattern) if not keys: await bot.api.send_text_message(room_id, "📭 No factoids found.") return rows = [(f"{i}.", k, "") for i, k in enumerate(keys, 1)] title = f"Factoid Keys ({len(keys)} total)" sections = [{"title": title, "rows": rows}] block = code_block(f"📋 {title}", sections) output = collapsible_summary(title, block) await bot.api.send_markdown_message(room_id, output) return # !fact lock if sub == "lock" and len(args) >= 2: if not is_admin: await bot.api.send_text_message(room_id, "⛔ Admin only.") return key = _normalise_key(" ".join(args[1:])) if lock_factoid(key, sender): await bot.api.send_text_message(room_id, f"🔒 Locked '{html_escape(key)}'.") else: await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.") return # !fact unlock if sub == "unlock" and len(args) >= 2: if not is_admin: await bot.api.send_text_message(room_id, "⛔ Admin only.") return key = _normalise_key(" ".join(args[1:])) if unlock_factoid(key): await bot.api.send_text_message(room_id, f"🔓 Unlocked '{html_escape(key)}'.") else: await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.") return # !fact change is if sub == "change" and len(args) >= 2: rest = " ".join(args[1:]) m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE) if not m: await bot.api.send_text_message(room_id, "Usage: !fact change is ") return key = _normalise_key(m.group(1).strip()) value = m.group(2).strip() existing = get_factoid(key) if not existing: await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'. Use !learn to create one.") return if existing.get("locked_by") and not is_admin: await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {existing['locked_by']}.") return set_factoid(key, value, sender) await bot.api.send_text_message(room_id, f"✏️ Changed '{html_escape(key)}'.") return # !fact (bare retrieval) key = _normalise_key(" ".join(args)) fact = get_factoid(key) if not fact: keys = list_keys(f"*{key}*") if keys: suggestions = ", ".join(keys[:10]) await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'. Did you mean: {suggestions}?") else: await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.") return resp = _format_response(key, fact["factoid_value"]) await bot.api.send_markdown_message(room_id, resp) return # !learn is if cmd == "learn": if not args: await bot.api.send_text_message(room_id, "Usage: !learn is ") return rest = " ".join(args) m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE) if not m: await bot.api.send_text_message(room_id, "Usage: !learn is ") return key = _normalise_key(m.group(1).strip()) value = m.group(2).strip() existing = get_factoid(key) if existing and existing.get("locked_by") and not is_admin: await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {existing['locked_by']}.") return set_factoid(key, value, sender) await bot.api.send_text_message(room_id, f"💡 Learned '{html_escape(key)}'.") return # !forget if cmd == "forget": if not args: await bot.api.send_text_message(room_id, "Usage: !forget ") return key = _normalise_key(" ".join(args)) fact = get_factoid(key) if fact and fact.get("locked_by") and not is_admin: await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {fact['locked_by']}.") return if delete_factoid(key): await bot.api.send_text_message(room_id, f"🗑️ Forgot '{html_escape(key)}'.") else: await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.") return # !also is if cmd == "also": if not args: await bot.api.send_text_message(room_id, "Usage: !also is ") return rest = " ".join(args) m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE) if not m: await bot.api.send_text_message(room_id, "Usage: !also is ") return key = _normalise_key(m.group(1).strip()) value = m.group(2).strip() fact = get_factoid(key) if fact and fact.get("locked_by") and not is_admin: await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {fact['locked_by']}.") return if append_factoid(key, value, sender): await bot.api.send_text_message(room_id, f"📎 Appended to '{html_escape(key)}'.") else: set_factoid(key, value, sender) await bot.api.send_text_message(room_id, f"💡 Learned '{html_escape(key)}'.") return # !no, is (same as change) if cmd == "no": if not args: await bot.api.send_text_message(room_id, "Usage: !no, is ") return rest = " ".join(args).lstrip(",").strip() m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE) if not m: await bot.api.send_text_message(room_id, "Usage: !no, is ") return key = _normalise_key(m.group(1).strip()) value = m.group(2).strip() existing = get_factoid(key) if not existing: await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'. Use !learn to create one.") return if existing.get("locked_by") and not is_admin: await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {existing['locked_by']}.") return set_factoid(key, value, sender) await bot.api.send_text_message(room_id, f"✏️ Changed '{html_escape(key)}'.") return async def _send_help(room, bot): help_text = """
📚 Factoids Plugin Help

Commands:
!fact <key> – retrieve a factoid
<key>? – ask for a factoid inline
!learn <key> is <value> – teach the bot
!forget <key> – delete a factoid
!also <key> is <value> – append to a factoid
!no, <key> is <value> – replace a factoid
!fact change <key> is <value> – change a factoid
!fact search <query> – search factoids
!fact info <key> – show metadata
!fact random – random factoid
!fact stats – statistics
!fact list [glob] – list keys
!fact lock|unlock <key> – admin only

Special values:
<reply> text – replies with just "text"
<action> text – replies as /me
a | b | c – picks one at random

""" await bot.api.send_markdown_message(room.room_id, help_text) # --------------------------------------------------------------------------- # Plugin Metadata # --------------------------------------------------------------------------- __version__ = "1.0.0" __author__ = "Funguy Bot" __description__ = "Factoids – infobot/supybot-style factoid storage and retrieval" __help__ = """
!fact – Factoids (infobot/supybot clone)
  • !fact <key> – retrieve a factoid
  • <key>? – ask for a factoid inline
  • !learn <key> is <value> – teach
  • !forget <key> – delete
  • !also <key> is <value> – append
  • !no, <key> is <value> – replace
  • !fact search <query> – search
  • !fact random / !fact stats / !fact list
  • Special tags: <reply>, <action>, pipe (|) for random
"""