Files
FunguyBot/plugins/factoids.py
T
2026-05-21 14:07:11 -05:00

548 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)
Inline 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 &lt;key&gt;</code> retrieve a factoid<br>
<code>&lt;key&gt;?</code> ask for a factoid inline<br>
<code>!learn &lt;key&gt; is &lt;value&gt;</code> teach the bot<br>
<code>!forget &lt;key&gt;</code> delete a factoid<br>
<code>!also &lt;key&gt; is &lt;value&gt;</code> append to a factoid<br>
<code>!no, &lt;key&gt; is &lt;value&gt;</code> replace a factoid<br>
<code>!fact change &lt;key&gt; is &lt;value&gt;</code> change a factoid<br>
<code>!fact search &lt;query&gt;</code> search factoids<br>
<code>!fact info &lt;key&gt;</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 &lt;key&gt;</code> admin only<br>
<br>
<strong>Special values:</strong><br>
<code>&lt;reply&gt; text</code> replies with just "text"<br>
<code>&lt;action&gt; 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 &lt;key&gt;</code> retrieve a factoid</li>
<li><code>&lt;key&gt;?</code> ask for a factoid inline</li>
<li><code>!learn &lt;key&gt; is &lt;value&gt;</code> teach</li>
<li><code>!forget &lt;key&gt;</code> delete</li>
<li><code>!also &lt;key&gt; is &lt;value&gt;</code> append</li>
<li><code>!no, &lt;key&gt; is &lt;value&gt;</code> replace</li>
<li><code>!fact search &lt;query&gt;</code> search</li>
<li><code>!fact random</code> / <code>!fact stats</code> / <code>!fact list</code></li>
<li>Special tags: <code>&lt;reply&gt;</code>, <code>&lt;action&gt;</code>, pipe (<code>|</code>) for random</li>
</ul>
</details>
"""