548 lines
22 KiB
Python
548 lines
22 KiB
Python
"""
|
||
Factoids plugin – a clone of the classic infobot / supybot Factoids plugin.
|
||
Stores and retrieves factoids via Matrix chat.
|
||
|
||
Commands:
|
||
!fact <key> – retrieve a factoid
|
||
!fact search <query> – search factoids by key or value
|
||
!fact info <key> – 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 <key> – lock a factoid (admin only)
|
||
!fact unlock <key> – unlock a factoid (admin only)
|
||
!fact change <key> is <val> – change an existing factoid
|
||
!learn <key> is <value> – teach the bot a new factoid
|
||
!forget <key> – delete a factoid
|
||
!also <key> is <value> – append to an existing factoid
|
||
!no, <key> is <value> – replace a factoid (same as change)
|
||
|
||
In‑line query (no prefix needed):
|
||
<key>? – ask for a factoid
|
||
|
||
Special value tags:
|
||
<reply> text – replies with "text" (not "key is text")
|
||
<action> 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 <reply>, <action>, and |."""
|
||
value = raw_value.strip()
|
||
|
||
if value.startswith("<reply>"):
|
||
return value[len("<reply>"):].strip()
|
||
|
||
if value.startswith("<action>"):
|
||
action = value[len("<action>"):].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 <query>
|
||
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 <key>
|
||
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 <key>
|
||
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 <key>
|
||
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 <key> is <value>
|
||
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 <key> is <value>")
|
||
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 <key> (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 <key> is <value>
|
||
if cmd == "learn":
|
||
if not args:
|
||
await bot.api.send_text_message(room_id, "Usage: !learn <key> is <value>")
|
||
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 <key> is <value>")
|
||
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 <key>
|
||
if cmd == "forget":
|
||
if not args:
|
||
await bot.api.send_text_message(room_id, "Usage: !forget <key>")
|
||
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 <key> is <value>
|
||
if cmd == "also":
|
||
if not args:
|
||
await bot.api.send_text_message(room_id, "Usage: !also <key> is <value>")
|
||
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 <key> is <value>")
|
||
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, <key> is <value> (same as change)
|
||
if cmd == "no":
|
||
if not args:
|
||
await bot.api.send_text_message(room_id, "Usage: !no, <key> is <value>")
|
||
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, <key> is <value>")
|
||
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 = """
|
||
<details>
|
||
<summary><strong>📚 Factoids Plugin Help</strong></summary>
|
||
<p>
|
||
<strong>Commands:</strong><br>
|
||
<code>!fact <key></code> – retrieve a factoid<br>
|
||
<code><key>?</code> – ask for a factoid inline<br>
|
||
<code>!learn <key> is <value></code> – teach the bot<br>
|
||
<code>!forget <key></code> – delete a factoid<br>
|
||
<code>!also <key> is <value></code> – append to a factoid<br>
|
||
<code>!no, <key> is <value></code> – replace a factoid<br>
|
||
<code>!fact change <key> is <value></code> – change a factoid<br>
|
||
<code>!fact search <query></code> – search factoids<br>
|
||
<code>!fact info <key></code> – show metadata<br>
|
||
<code>!fact random</code> – random factoid<br>
|
||
<code>!fact stats</code> – statistics<br>
|
||
<code>!fact list [glob]</code> – list keys<br>
|
||
<code>!fact lock|unlock <key></code> – admin only<br>
|
||
<br>
|
||
<strong>Special values:</strong><br>
|
||
<code><reply> text</code> – replies with just "text"<br>
|
||
<code><action> text</code> – replies as /me<br>
|
||
<code>a | b | c</code> – picks one at random
|
||
</p>
|
||
</details>
|
||
"""
|
||
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__ = """
|
||
<details>
|
||
<summary><strong>!fact</strong> – Factoids (infobot/supybot clone)</summary>
|
||
<ul>
|
||
<li><code>!fact <key></code> – retrieve a factoid</li>
|
||
<li><code><key>?</code> – ask for a factoid inline</li>
|
||
<li><code>!learn <key> is <value></code> – teach</li>
|
||
<li><code>!forget <key></code> – delete</li>
|
||
<li><code>!also <key> is <value></code> – append</li>
|
||
<li><code>!no, <key> is <value></code> – replace</li>
|
||
<li><code>!fact search <query></code> – search</li>
|
||
<li><code>!fact random</code> / <code>!fact stats</code> / <code>!fact list</code></li>
|
||
<li>Special tags: <code><reply></code>, <code><action></code>, pipe (<code>|</code>) for random</li>
|
||
</ul>
|
||
</details>
|
||
"""
|