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
+1 -1
View File
@@ -19,7 +19,7 @@ from collections import defaultdict
from plugins.config import FunguyConfig
# Rate limiter settings
RATE_LIMIT_WINDOW = 5.0 # seconds
RATE_LIMIT_WINDOW = 15.0 # seconds
MAX_COMMANDS_PER_WINDOW = 3
+183 -46
View File
@@ -2,10 +2,15 @@
"""
plugins/admin.py Full room moderation commands.
Supports multiword display names, standalone commands (!op, !kick, etc.)
Automatic flood detection:
message flood (5 msgs in 3s) → autoban + kick
join flood (5 joins in 3s, any domain) → room locked to inviteonly
"""
import time
import logging
import re
from collections import defaultdict, deque
import simplematrixbotlib as botlib
logger = logging.getLogger("admin")
@@ -17,6 +22,17 @@ _pending_resolution = {} # room_id → {"matches": [...], "expires": timestamp}
_name_cache = {} # room_id → {display_name.lower(): mxid}
RESOLUTION_TIMEOUT = 60
# Flood detection settings
FLOOD_MAX_MESSAGES = 15
FLOOD_TIME_WINDOW = 3.0 # seconds
JOIN_FLOOD_MAX = 5
JOIN_FLOOD_WINDOW = 3.0 # seconds
# Per-room per-user message timestamps
_flood_tracker: dict[str, dict[str, deque[float]]] = defaultdict(lambda: defaultdict(deque))
# Per-room join event timestamps (any domain)
_join_flood_tracker: dict[str, deque[float]] = defaultdict(deque)
def _cleanup_resolutions():
now = time.time()
expired = [r for r, v in _pending_resolution.items() if v["expires"] < now]
@@ -28,7 +44,6 @@ class UserResolutionError(Exception):
self.matches = matches # list of {"mxid": ..., "display_name": ...}
async def _populate_name_cache(bot, room_id):
"""Fetch the full member list and cache display names."""
if room_id in _name_cache:
return
try:
@@ -39,7 +54,6 @@ async def _populate_name_cache(bot, room_id):
for member in resp.members:
display = (member.display_name or "").strip().lower()
if display:
# If duplicate display name, store None to indicate ambiguity
if display in cache:
cache[display] = None
else:
@@ -50,23 +64,17 @@ async def _populate_name_cache(bot, room_id):
logger.error(f"Could not cache members: {e}")
async def _resolve_multiword(bot, room_id, tokens):
"""
Given a list of word tokens, try to find a matching display name
by testing progressively longer prefixes of the token list.
Returns (mxid, display_name) or raises ValueError if no match.
"""
clean_tokens = [re.sub(r'<[^>]+>', '', t).strip() for t in tokens]
await _populate_name_cache(bot, room_id)
cache = _name_cache.get(room_id, {})
# Build candidates from 1 token up to all tokens
for end in range(len(tokens), 0, -1):
candidate = " ".join(tokens[:end]).strip().lower()
for end in range(len(clean_tokens), 0, -1):
candidate = " ".join(clean_tokens[:end]).strip().lower()
if candidate in cache:
mxid = cache[candidate]
if mxid is not None:
return mxid, candidate
else:
# Duplicate display name → fall through to ambiguity handling
resp = await bot.async_client.joined_members(room_id)
matches = []
for member in resp.members:
@@ -76,23 +84,15 @@ async def _resolve_multiword(bot, room_id, tokens):
return matches[0]["mxid"], matches[0]["display_name"]
elif len(matches) > 1:
raise UserResolutionError(matches)
# else: not found (unlikely) → continue
raise ValueError(f"No member with display name '{' '.join(tokens)}' found.")
raise ValueError(f"No member with display name '{' '.join(clean_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 (multiword), or number
(referring to a previous ambiguous resolution).
Returns (mxid, display_name_or_None).
Raises ValueError or UserResolutionError.
"""
target = re.sub(r'<[^>]+>', '', target).strip()
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:
@@ -106,16 +106,12 @@ async def resolve_user_from_target(bot, room_id, target):
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:
@@ -183,11 +179,61 @@ async def get_banned_users(bot, room_id):
logger.error(f"Failed to fetch bans: {e}")
return []
# ------------------------------------------------------------------
# Flood detection (message + global join)
# ------------------------------------------------------------------
def _check_flood(room_id, user_id) -> bool:
now = time.monotonic()
q = _flood_tracker[room_id][user_id]
while q and q[0] < now - FLOOD_TIME_WINDOW:
q.popleft()
q.append(now)
if len(q) >= FLOOD_MAX_MESSAGES:
q.clear()
return True
return False
def _check_join_flood(room_id) -> bool:
now = time.monotonic()
q = _join_flood_tracker[room_id]
while q and q[0] < now - JOIN_FLOOD_WINDOW:
q.popleft()
q.append(now)
if len(q) >= JOIN_FLOOD_MAX:
q.clear()
return True
return False
async def _kick_user(bot, room_id, user_id, reason):
try:
await bot.async_client.room_kick(room_id, user_id, reason)
logger.info(f"Kicked {user_id} from {room_id}: {reason}")
except Exception as e:
logger.error(f"Failed to kick {user_id}: {e}")
async def _ban_user(bot, room_id, user_id, reason):
try:
await bot.async_client.room_ban(room_id, user_id, reason)
logger.info(f"Banned {user_id} from {room_id}: {reason}")
except Exception as e:
logger.error(f"Failed to ban {user_id}: {e}")
async def _lock_room(bot, room_id):
"""Set room join rule to 'invite'."""
try:
await bot.async_client.room_put_state(
room_id,
"m.room.join_rules",
{"join_rule": "invite"}
)
logger.info(f"Room {room_id} locked to inviteonly (join flood detected).")
except Exception as e:
logger.error(f"Failed to lock room {room_id}: {e}")
# ------------------------------------------------------------------
# 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
@@ -200,7 +246,7 @@ async def handle_command(room, message, bot, prefix, config):
"ban": "ban",
"unban": "unban",
"invite": "invite",
"userinfo": "whois", # <-- renamed from "whois" to "userinfo"
"userinfo": "whois",
"op": "op",
"deop": "deop",
"topic": "topic",
@@ -208,6 +254,8 @@ async def handle_command(room, message, bot, prefix, config):
"avatar": "avatar",
"members": "members",
"bans": "bans",
"mkick": "mkick",
"joinrule": "joinrule",
"modhelp": "help",
"admin": "admin",
}
@@ -216,7 +264,6 @@ async def handle_command(room, message, bot, prefix, config):
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(
@@ -226,7 +273,6 @@ async def handle_command(room, message, bot, prefix, config):
args = match.args()
# Determine action and sub_args
if cmd == "admin":
if not args:
await bot.api.send_text_message(room_id, "Usage: !admin <action> [args...]")
@@ -238,36 +284,76 @@ async def handle_command(room, message, bot, prefix, config):
sub_args = args
# ------------------------------------------------------------
# User-targeting actions (kick, ban, invite, userinfo, op, deop)
# Masskick by domain
# ------------------------------------------------------------
if action in ("kick", "ban", "invite", "userinfo", "op", "deop"):
if action == "mkick":
if not sub_args:
await bot.api.send_text_message(room_id, "Usage: !mkick <domain>\nExample: !mkick evilbots.net")
return
domain = sub_args[0].strip().lower()
if ':' in domain:
domain = domain.split(':')[-1]
try:
resp = await bot.async_client.joined_members(room_id)
if not resp.members:
await bot.api.send_text_message(room_id, "Could not fetch member list.")
return
targets = [m for m in resp.members if m.user_id.endswith(f":{domain}")]
if not targets:
await bot.api.send_text_message(room_id, f"No users found from domain '{domain}'.")
return
reason = f"Masskick of domain {domain}"
count = 0
for member in targets:
await _kick_user(bot, room_id, member.user_id, reason)
count += 1
await bot.api.send_text_message(room_id, f"👢 Kicked {count} user(s) from {domain}.")
except Exception as e:
await bot.api.send_text_message(room_id, f"❌ Masskick failed: {e}")
# ------------------------------------------------------------
# Join rule toggle
# ------------------------------------------------------------
elif action == "joinrule":
if not sub_args or sub_args[0] not in ("public", "invite"):
await bot.api.send_text_message(room_id, "Usage: !joinrule <public|invite>")
return
new_rule = sub_args[0].lower()
try:
await bot.async_client.room_put_state(
room_id,
"m.room.join_rules",
{"join_rule": new_rule}
)
await bot.api.send_text_message(room_id, f"🔐 Join rule set to **{new_rule}**.")
except Exception as e:
await bot.api.send_text_message(room_id, f"❌ Failed to set join rule: {e}")
# ------------------------------------------------------------
# User-targeting actions
# ------------------------------------------------------------
elif action in ("kick", "ban", "invite", "userinfo", "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)
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, userinfo
name_tokens = sub_args # entire args is the name
name_tokens = sub_args
power = None
# Resolve the multi-word name
try:
target_mxid, target_display = await _resolve_multiword(bot, room_id, name_tokens)
except UserResolutionError as e:
@@ -279,7 +365,6 @@ async def handle_command(room, message, bot, prefix, config):
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)
@@ -287,7 +372,6 @@ async def handle_command(room, message, bot, prefix, config):
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
@@ -321,7 +405,6 @@ async def handle_command(room, message, bot, prefix, config):
await bot.api.send_text_message(room_id, f"❌ Failed to set power: {e}")
else:
# For kick/ban/invite/userinfo: 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":
@@ -345,7 +428,7 @@ async def handle_command(room, message, bot, prefix, config):
except Exception as e:
await bot.api.send_text_message(room_id, f"❌ Failed to invite: {e}")
elif action == "userinfo": # <-- was "whois", now "userinfo"
elif action == "userinfo":
try:
resp = await bot.async_client.joined_members(room_id)
member_info = None
@@ -385,7 +468,6 @@ async def handle_command(room, message, bot, prefix, config):
# ------------------------------------------------------------
# TOPIC, ROOMNAME, AVATAR, MEMBERS, BANS, HELP ...
# (unchanged)
# ------------------------------------------------------------
elif action == "topic":
if not sub_args:
@@ -477,6 +559,8 @@ async def handle_command(room, message, bot, prefix, config):
- `!ban <@user|name> [reason]` Ban a user
- `!unban <@user:domain>` Unban (full MXID required)
- `!invite <@user|name>` Invite a user
- `!mkick <domain>` Kick all users from the given domain
- `!joinrule <public|invite>` Manually set the room join rule
- `!userinfo <@user|name>` Show user details & power level (was `!whois`)
- `!op <@user|name> [pl=50]` Promote user (max 50, moderator)
- `!deop <@user|name>` Demote user to power level 0
@@ -497,18 +581,65 @@ If the name is ambiguous you'll be asked to choose from a numbered list.
room_id, f"Unknown action: {action}. Use `!modhelp`."
)
# ------------------------------------------------------------------
# Plugin setup register flood detectors
# ------------------------------------------------------------------
def setup(bot):
"""Initialize the admin plugin and register flood detectors."""
# Message flood detector (bans + kicks)
@bot.listener.on_message_event
async def _message_flood(room, message):
room_id = room.room_id
sender = message.sender
if sender == bot.async_client.user_id:
return
if _check_flood(room_id, sender):
disp = sender
try:
resp = await bot.async_client.joined_members(room_id)
if resp.members:
for m in resp.members:
if m.user_id == sender:
disp = m.display_name or sender
break
except:
pass
reason = f"Autoban for flooding ({FLOOD_MAX_MESSAGES} messages in {FLOOD_TIME_WINDOW}s)"
await _ban_user(bot, room_id, sender, reason)
await _kick_user(bot, room_id, sender, reason)
# Join flood detector (any domain)
@bot.listener.on_custom_event(botlib.nio.RoomMemberEvent)
async def _join_flood(room, event):
room_id = room.room_id
if event.membership != "join":
return
sender = event.state_key
if sender == bot.async_client.user_id:
return
if _check_join_flood(room_id):
await _lock_room(bot, room_id)
await bot.api.send_text_message(
room_id,
"🔐 Join flood detected room locked to inviteonly. Use `!joinrule public` to reopen."
)
logger.info("Admin plugin flood detectors registered")
# ------------------------------------------------------------------
# Plugin metadata
# ------------------------------------------------------------------
__version__ = "1.1.1"
__version__ = "1.2.3"
__author__ = "Funguy Admin"
__description__ = "Full room moderation multiword name support"
__description__ = "Full room moderation multiword name support + flood detection + mass domain kick"
__help__ = """
<details>
<summary><strong>Admin / Moderator Commands</strong></summary>
<ul>
<li><code>!kick</code>, <code>!ban</code>, <code>!unban</code>, <code>!invite</code></li>
<li><code>!userinfo</code> Show user details & power level (was !whois)</li>
<li><code>!mkick &lt;domain&gt;</code> Kick all users from a domain</li>
<li><code>!joinrule &lt;public|invite&gt;</code> Change room join rule</li>
<li><code>!userinfo</code> Show user details & power level</li>
<li><code>!op</code> (max PL 50), <code>!deop</code></li>
<li><code>!topic</code>, <code>!roomname</code>, <code>!avatar</code></li>
<li><code>!members</code>, <code>!bans</code></li>
@@ -516,5 +647,11 @@ __help__ = """
</ul>
<p>Power level ≥ 50 required (or global admin).</p>
<p>Multiword display names are automatically recognized.</p>
<p><strong>Flood detection:</strong>
<ul>
<li>Message flood: 5 messages in 3 seconds → autoban + kick</li>
<li>Join flood: 5 users in 3 seconds (any domain) → room locked to inviteonly</li>
</ul>
</p>
</details>
"""
+1194
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
"""
This plugin provides commands to interact with different AI models.
"""
import logging
import requests
import json
import simplematrixbotlib as botlib
import re
import markdown2
async def handle_command(room, message, bot, prefix, config):
"""
Function to handle AI 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():
logging.info(f"Received command: {match.command()}")
command = match.command()
conf = load_config()
if command in conf:
await handle_ai_command(room, bot, command, match.args(), conf)
async def handle_ai_command(room, bot, command, args, config):
"""
Function to handle AI commands.
Args:
room (Room): The Matrix room where the command was invoked.
bot (Bot): The bot object.
command (str): The name of the AI model command.
args (list): List of arguments provided with the command.
config (dict): Configuration parameters.
Returns:
None
"""
if len(args) < 1:
await bot.api.send_text_message(room.room_id, f"Usage: !{command} [prompt]")
logging.info("Sent usage message to the room")
return
prompt = ' '.join(args)
# Prepare data for the API request
url = "http://127.0.0.1:5000/v1/completions"
headers = {
"Content-Type": "application/json"
}
data = {
"prompt": f"<s>[INST]{config[command]['prompt']}{prompt}[/INST]",
"max_tokens": 4096,
"temperature": config[command]["temperature"],
"top_p": config[command]["top_p"],
"top_k": config[command]["top_k"],
"repetition_penalty": config[command]["repetition_penalty"],
"seed": -1,
"stream": False
}
# Make HTTP request to the API endpoint
try:
response = requests.post(url, headers=headers, json=data, verify=False, timeout=300)
response.raise_for_status() # Raise HTTPError for bad responses
payload = response.json()
new_text = payload['choices'][0]['text']
new_text = markdown_to_html(new_text)
if new_text.count('<p>') > 1 or new_text.count('<li>') > 1: # Check if new_text has more than one paragraph
new_text = f"<details><summary><strong>{config[command]['summary']}</strong></summary>{new_text}</details>"
await bot.api.send_markdown_message(room.room_id, new_text)
else:
await bot.api.send_markdown_message(room.room_id, new_text)
logging.info("Sent generated text to the room")
except requests.exceptions.RequestException as e:
logging.error(f"HTTP request failed for '{prompt}': {e}")
await bot.api.send_text_message(room.room_id, f"Error generating text: {e}")
def markdown_to_html(markdown_text):
"""
Convert Markdown text to HTML.
Args:
markdown_text (str): Markdown formatted text.
Returns:
str: HTML formatted text.
"""
html_content = markdown2.markdown(markdown_text)
return html_content
def load_config():
"""
Load configuration from ai.json file.
Returns:
dict: Configuration parameters.
"""
with open("plugins/ai.json", "r") as f:
config = json.load(f)
return config
CONFIG = load_config()
+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>
"""
+57 -12
View File
@@ -9,6 +9,7 @@ Features:
* View karma leaderboards (top/bottom)
* Rate limiting to prevent spam
* Room-specific karma tracking
* Pertarget throttle (max votes per target per minute)
Commands:
!karma - Show this help
@@ -43,9 +44,13 @@ import time
# Configuration
# ---------------------------------------------------------------------------
# Global cooldown: one karma point per hour per voter
# Pertarget cooldown: one karma point per hour per user
COOLDOWN_SECONDS = 3600
# Pertarget throttle: max votes a target can receive per minute
PER_TARGET_THROTTLE_COUNT = 5
PER_TARGET_THROTTLE_SECONDS = 3600
# Database file
DB_FILE = "karma.db"
@@ -56,6 +61,9 @@ display_name_cache = {}
# Last time we refreshed the cache (per room)
cache_timestamp = {}
# Pertarget throttle tracker: (room_id, user_id) -> list of monotonic timestamps
_target_vote_times: dict[tuple[str, str], list[float]] = {}
# ---------------------------------------------------------------------------
# Helper: pluralize "point" vs "points"
# ---------------------------------------------------------------------------
@@ -132,29 +140,25 @@ async def refresh_display_name_cache(bot, room_id):
try:
if hasattr(bot, 'async_client') and bot.async_client:
resp = await bot.async_client.joined_members(room_id)
if resp.members is not None:
if resp.members:
name_map = {}
for member in resp.members:
display_name = (member.display_name or "").strip()
if display_name:
name_map[display_name.lower()] = member.user_id
# Also store without emojis
# Also store without emojis for easier matching
clean_name = re.sub(r'[^\w\s]', '', display_name).strip().lower()
if clean_name and clean_name != display_name.lower():
name_map[clean_name] = member.user_id
display_name_cache[room_id] = name_map
cache_timestamp[room_id] = now
logging.info(f"Cached {len(name_map)} display names for room {room_id}")
# DEBUG: show first 5 names
sample = list(name_map.items())[:5]
logging.debug(f"Sample display names: {sample}")
return
else:
logging.warning(f"joined_members returned None members for room {room_id}")
except Exception as e:
logging.warning(f"Could not refresh display name cache: {e}")
# init empty cache on failure
# If we couldn't get members, initialize empty cache
display_name_cache[room_id] = {}
cache_timestamp[room_id] = now
@@ -173,7 +177,7 @@ def resolve_display_name(room_id, display_name, bot=None):
# Strip HTML tags (Matrix mention pills)
clean = re.sub(r'<[^>]+>', '', display_name).strip()
# Reject Matrix IDs outright (only if the raw input is an ID, not the cleaned one)
# Reject Matrix IDs outright
if is_matrix_id(display_name):
return None
@@ -181,7 +185,7 @@ def resolve_display_name(room_id, display_name, bot=None):
if room_id in display_name_cache:
name_map = display_name_cache[room_id]
# Try exact match (caseinsensitive)
# Try exact match (case-insensitive)
key = clean.lower()
if key in name_map:
return name_map[key]
@@ -451,6 +455,28 @@ def format_karma_display(display_name, points):
return f"⚖️ **{display_name}** has neutral karma (0)"
# ---------------------------------------------------------------------------
# Pertarget throttle helpers
# ---------------------------------------------------------------------------
def _is_target_throttled(room_id: str, user_id: str) -> bool:
"""Return True if the target user has received too many votes recently."""
key = (room_id, user_id)
now = time.monotonic()
times = _target_vote_times.get(key, [])
# Remove old entries
times = [t for t in times if now - t < PER_TARGET_THROTTLE_SECONDS]
_target_vote_times[key] = times
return len(times) >= PER_TARGET_THROTTLE_COUNT
def _record_target_vote(room_id: str, user_id: str):
"""Record that a vote was just cast for the target user."""
key = (room_id, user_id)
times = _target_vote_times.get(key, [])
times.append(time.monotonic())
_target_vote_times[key] = times
# ---------------------------------------------------------------------------
# Command Handlers
# ---------------------------------------------------------------------------
@@ -556,6 +582,14 @@ async def process_karma_vote(room, display_name, action, voter, bot):
await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma!")
return
# Pertarget throttle: limit how many votes a target can receive per minute
if _is_target_throttled(room_id, user_id):
await bot.api.send_markdown_message(
room.room_id,
f"{display_name} is receiving too many votes right now. Please try again later."
)
return
# Check cooldown
if is_on_cooldown(room_id, user_id, voter_str):
remaining = get_cooldown_remaining(room_id, user_id, voter_str)
@@ -573,6 +607,9 @@ async def process_karma_vote(room, display_name, action, voter, bot):
new_points = update_karma(room_id, user_id, change, voter_str)
update_cooldown(room_id, user_id, voter_str)
# Record target vote for throttle
_record_target_vote(room_id, user_id)
# Get display name for response
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
response = format_karma_display(display_name_resolved, new_points)
@@ -807,6 +844,11 @@ async def handle_inline_karma(room, message, bot):
logging.debug(f"Skipping self-modification: {sender} -> {display_name}")
continue
# Pertarget throttle for inline votes
if _is_target_throttled(room_id, user_id):
logging.debug(f"Inline target throttle active for {user_id}")
continue
# Check cooldown
if is_on_cooldown(room_id, user_id, sender):
logging.debug(f"Cooldown active for {sender} -> {user_id}")
@@ -817,6 +859,9 @@ async def handle_inline_karma(room, message, bot):
new_points = update_karma(room_id, user_id, change, sender)
update_cooldown(room_id, user_id, sender)
# Record target vote for throttle
_record_target_vote(room_id, user_id)
# Format response
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
arrow = "⬆️" if change > 0 else "⬇️"
@@ -855,7 +900,7 @@ def setup(bot):
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.1"
__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "Room karma tracking system (display names only, no Matrix IDs)"
__help__ = """
+95
View File
@@ -0,0 +1,95 @@
"""
Plugin for generating text using Ollama's Mistral 7B Instruct model and sending it to a Matrix chat room.
"""
import requests
from asyncio import Queue
import simplematrixbotlib as botlib
import argparse
# Queue to store pending commands
command_queue = Queue()
API_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "mistral:7b-instruct"
async def process_command(room, message, bot, prefix, config):
"""
Queue and process !text commands sequentially.
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if match.prefix() and match.command("text"):
if command_queue.empty():
await handle_command(room, message, bot, prefix, config)
else:
await command_queue.put((room, message, bot, prefix, config))
async def handle_command(room, message, bot, prefix, config):
"""
Send the prompt to Ollama API and return the generated text.
"""
match = botlib.MessageMatch(room, message, bot, prefix)
if not (match.prefix() and match.command("text")):
return
# Parse optional arguments
parser = argparse.ArgumentParser(description='Generate text using Ollama API')
parser.add_argument('--max_tokens', type=int, default=512, help='Maximum tokens to generate')
parser.add_argument('--temperature', type=float, default=0.7, help='Temperature for generation')
parser.add_argument('prompt', nargs='+', help='Prompt for the model')
try:
args = parser.parse_args(message.body.split()[1:]) # Skip command itself
prompt = ' '.join(args.prompt).strip()
if not prompt:
await bot.api.send_text_message(room.room_id, "Usage: !text <your prompt here>")
return
payload = {
"model": MODEL_NAME,
"prompt": prompt,
"max_tokens": args.max_tokens,
"temperature": args.temperature,
"stream": False
}
response = requests.post(API_URL, json=payload, timeout=60)
response.raise_for_status()
r = response.json()
generated_text = r.get("response", "").strip()
if not generated_text:
generated_text = "(No response from model)"
await bot.api.send_text_message(room.room_id, generated_text)
except argparse.ArgumentError as e:
await bot.api.send_text_message(room.room_id, f"Argument error: {e}")
except requests.exceptions.RequestException as e:
await bot.api.send_text_message(room.room_id, f"Error connecting to Ollama API: {e}")
except Exception as e:
await bot.api.send_text_message(room.room_id, f"Unexpected error: {e}")
finally:
# Process next command from the queue, if any
if not command_queue.empty():
next_command = await command_queue.get()
await handle_command(*next_command)
def print_help():
"""
Generates help text for the !text command.
"""
return """
<p>Generate text using Ollama's Mistral 7B Instruct model</p>
<p>Usage:</p>
<ul>
<li>!text <prompt> - Basic prompt for the model</li>
<li>Optional arguments:</li>
<ul>
<li>--max_tokens MAX_TOKENS - Maximum tokens to generate (default 512)</li>
<li>--temperature TEMPERATURE - Sampling temperature (default 0.7)</li>
</ul>
</ul>
"""
+3 -1
View File
@@ -23,4 +23,6 @@ PyYAML
wcwidth
markdown
python-cryptography-fernet-wrapper
zstandard
zstandard
requests
markdown2