Latest fixes.

This commit is contained in:
2026-05-21 14:07:11 -05:00
parent 15cf9e72bb
commit 0765aaa9f7
8 changed files with 2195 additions and 60 deletions
+547
View File
@@ -0,0 +1,547 @@
"""
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>
"""