admin plugin and roomstats plugin added. cron fixed and ddg fixed

This commit is contained in:
2026-05-07 15:28:50 -05:00
parent 4b10c13b29
commit 10a6028037
7 changed files with 1673 additions and 529 deletions
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""
plugins/roomstats.py — peruser room statistics (Limnoriastyle).
Commands: !roomstats, !rank, !stats
"""
import time
import re
import sqlite3
import logging
import nio
import simplematrixbotlib as botlib
logger = logging.getLogger("roomstats")
DB_PATH = "roomstats.db"
# ------------------------------------------------------------------
# Emoji / smiley regex (Unicode blocks)
# ------------------------------------------------------------------
EMOJI_RE = re.compile(
"["
"\U0001F600-\U0001F64F" # Emoticons
"\U0001F300-\U0001F5FF" # Symbols & pictographs
"\U0001F680-\U0001F6FF" # Transport & map
"\U0001F1E0-\U0001F1FF" # Flags
"\U00002702-\U000027B0" # Dingbats
"\U000024C2-\U0001F251" # Misc
"]+", re.UNICODE)
def count_smileys(text):
"""Return number of emoji occurrences."""
return len(EMOJI_RE.findall(text))
# ------------------------------------------------------------------
# Database init
# ------------------------------------------------------------------
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS user_room_stats (
room_id TEXT,
user_id TEXT,
msgs INTEGER DEFAULT 0,
chars INTEGER DEFAULT 0,
words INTEGER DEFAULT 0,
smileys INTEGER DEFAULT 0,
actions INTEGER DEFAULT 0,
joins INTEGER DEFAULT 0,
parts INTEGER DEFAULT 0,
kicks_given INTEGER DEFAULT 0,
kicked_received INTEGER DEFAULT 0,
topics_set INTEGER DEFAULT 0,
last_updated INTEGER,
PRIMARY KEY (room_id, user_id)
)
""")
conn.commit()
conn.close()
# ------------------------------------------------------------------
# Multiword user resolution helper
# ------------------------------------------------------------------
async def resolve_user_from_tokens(bot, room_id, tokens):
"""
Given a list of word tokens, find a matching display name.
Returns (mxid, display_name) or raises ValueError.
"""
# Build cache of (lowered display name → user_id) from joined members
resp = await bot.async_client.joined_members(room_id)
if resp.members is None:
raise ValueError("Could not fetch member list.")
# Create a dict: lower_display → (mxid, display_name)
# If duplicate display name, store None to signal ambiguity.
cache = {}
for member in resp.members:
display = (member.display_name or "").strip()
if not display:
continue
key = display.lower()
if key in cache:
cache[key] = None
else:
cache[key] = (member.user_id, display)
# Try progressively longer prefixes of the tokens
for end in range(len(tokens), 0, -1):
candidate = " ".join(tokens[:end]).strip().lower()
if candidate in cache:
entry = cache[candidate]
if entry is not None:
return entry # (mxid, display_name)
else:
# Ambiguous we need to fetch and check exactly
matches = []
for member in resp.members:
if (member.display_name or "").strip().lower() == candidate:
matches.append((member.user_id, member.display_name or member.user_id))
if len(matches) == 1:
return matches[0]
elif len(matches) > 1:
raise ValueError(
f"Multiple users have display name '{candidate}'. Use an MXID instead."
)
# if none, continue
raise ValueError(f"No member found for '{' '.join(tokens)}'.")
async def resolve_user(bot, room_id, name_or_tokens):
"""
Accept either a single string (MXID or single-token display name)
or a list of tokens. Returns (mxid, display_name).
"""
if isinstance(name_or_tokens, str):
if name_or_tokens.startswith("@"):
return name_or_tokens, None
# Single token try direct cache match or fallback to multiword
tokens = [name_or_tokens]
else:
tokens = name_or_tokens
return await resolve_user_from_tokens(bot, room_id, tokens)
# ------------------------------------------------------------------
# Setup: register custom event listeners for membership & topics
# ------------------------------------------------------------------
def setup(bot):
init_db()
@bot.listener.on_custom_event(nio.RoomMemberEvent)
async def member_event(room, event):
room_id = room.room_id
membership = event.content.get("membership")
state_key = event.state_key
sender = event.sender
# Ignore the bot's own membership changes
if state_key == bot.async_client.user_id:
return
if membership == "join":
_incr(room_id, state_key, "joins")
elif membership == "leave":
if sender != state_key: # kick
_incr(room_id, sender, "kicks_given")
_incr(room_id, state_key, "kicked_received")
else: # part
_incr(room_id, state_key, "parts")
@bot.listener.on_custom_event(nio.RoomTopicEvent)
async def topic_event(room, event):
room_id = room.room_id
sender = event.sender
_incr(room_id, sender, "topics_set")
def _incr(room_id, user_id, column):
"""Increment a stat column by 1, creating row if needed."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute(
"INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)",
(room_id, user_id)
)
c.execute(
f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
(int(time.time()), room_id, user_id)
)
conn.commit()
conn.close()
# ------------------------------------------------------------------
# Message handler silently records stats, and handles commands
# ------------------------------------------------------------------
async def handle_command(room, message, bot, prefix, config):
room_id = room.room_id
sender = message.sender
# ----- silently record stats for any nonbot message -----
if sender != bot.async_client.user_id: # <-- FIXED
body = message.body or ""
words = len(body.split())
chars = len(body)
smileys = count_smileys(body)
is_action = getattr(message, "msgtype", None) == "m.emote"
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender))
c.execute(
"""UPDATE user_room_stats
SET msgs = msgs + 1,
chars = chars + ?,
words = words + ?,
smileys = smileys + ?,
actions = actions + ?,
last_updated = ?
WHERE room_id = ? AND user_id = ?""",
(chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender)
)
conn.commit()
conn.close()
# ----- command matching -----
match = botlib.MessageMatch(room, message, bot, prefix)
if not match.is_not_from_this_bot() or not match.prefix():
return
cmd = match.command()
args = match.args()
# ===============================
# !roomstats
# ===============================
if cmd == "roomstats":
await _handle_roomstats(bot, room_id)
# ===============================
# !rank <expr>
# ===============================
elif cmd == "rank":
if not args:
await bot.api.send_text_message(
room_id,
"Usage: !rank <stat>\n"
"Stats: msgs, chars, words, smileys, actions, joins, parts, "
"kicks_given, kicked_received, topics_set"
)
return
col = args[0].lower()
await _handle_rank(bot, room_id, col)
# ===============================
# !stats [<name>]
# ===============================
elif cmd == "stats":
if args:
# Use all tokens as the display name (multiword)
try:
target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args)
except ValueError as e:
await bot.api.send_text_message(room_id, str(e))
return
else:
target_mxid = sender
await _handle_user_stats(bot, room_id, target_mxid, sender)
# ------------------------------------------------------------------
# Command implementations
# ------------------------------------------------------------------
VALID_STATS = {
"msgs": "Messages",
"chars": "Characters",
"words": "Words",
"smileys": "Smileys",
"actions": "Actions",
"joins": "Joins",
"parts": "Parts",
"kicks_given": "Kicks given",
"kicked_received": "Times kicked",
"topics_set": "Topics set",
}
async def _get_aggregate(room_id):
"""Return dict of aggregate stats for a room."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""SELECT
COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0),
COALESCE(SUM(words),0), COALESCE(SUM(smileys),0),
COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0),
COALESCE(SUM(kicked_received),0), COALESCE(SUM(topics_set),0)
FROM user_room_stats WHERE room_id=?""", (room_id,))
row = c.fetchone()
conn.close()
if not row or all(v == 0 for v in row):
return None
return {
"msgs": row[0], "chars": row[1], "words": row[2], "smileys": row[3],
"actions": row[4], "joins": row[5], "parts": row[6],
"kicks_given": row[7], "kicked_received": row[8], "topics_set": row[9]
}
async def _handle_roomstats(bot, room_id):
agg = await _get_aggregate(room_id)
if not agg:
await bot.api.send_text_message(room_id, "No stats collected yet.")
return
# Get top 10 by msgs
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""SELECT user_id, msgs FROM user_room_stats
WHERE room_id=? ORDER BY msgs DESC LIMIT 10""", (room_id,))
top = c.fetchall()
conn.close()
# Resolve display names for top users
top_lines = []
resp = await bot.async_client.joined_members(room_id)
for uid, cnt in top:
disp = uid
if resp.members:
for m in resp.members:
if m.user_id == uid:
disp = m.display_name or uid
break
top_lines.append(f"<li><code>{disp}</code> — {cnt} msgs</li>")
msg = f"""<details>
<summary><strong>Room Statistics</strong></summary>
<ul>
<li>📩 Messages: {agg['msgs']}</li>
<li>🔤 Characters: {agg['chars']}</li>
<li>📝 Words: {agg['words']}</li>
<li>😀 Smileys: {agg['smileys']}</li>
<li>🎭 Actions: {agg['actions']}</li>
<li>🚪 Joins: {agg['joins']}</li>
<li>👋 Parts: {agg['parts']}</li>
<li>👢 Kicks given: {agg['kicks_given']}</li>
<li>🥾 Times kicked: {agg['kicked_received']}</li>
<li>📌 Topics set: {agg['topics_set']}</li>
</ul>
<p><strong>Top 10 by messages:</strong></p>
<ol>
{''.join(top_lines)}
</ol>
</details>"""
await bot.api.send_markdown_message(room_id, msg)
async def _handle_rank(bot, room_id, col):
# Validate column
if col not in VALID_STATS:
await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}")
return
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Safe to use f-string because col is validated against a hardcoded set
c.execute(f"""SELECT user_id, {col} FROM user_room_stats
WHERE room_id=? AND {col} > 0 ORDER BY {col} DESC LIMIT 10""", (room_id,))
rows = c.fetchall()
conn.close()
if not rows:
await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.")
return
resp = await bot.async_client.joined_members(room_id)
items = []
for i, (uid, val) in enumerate(rows, 1):
disp = uid
if resp.members:
for m in resp.members:
if m.user_id == uid:
disp = m.display_name or uid
break
items.append(f"<li>{i}. <code>{disp}</code> — {val}</li>")
msg = f"""<details>
<summary><strong>Ranking by {VALID_STATS[col]}</strong></summary>
<ol>
{''.join(items)}
</ol>
</details>"""
await bot.api.send_markdown_message(room_id, msg)
async def _handle_user_stats(bot, room_id, user_id, sender):
# Fetch stats
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts,
kicks_given, kicked_received, topics_set
FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id))
row = c.fetchone()
conn.close()
if not row or all(v == 0 for v in row):
# No stats, maybe just joined get display name for the message
disp = user_id
resp = await bot.async_client.joined_members(room_id)
if resp.members:
for m in resp.members:
if m.user_id == user_id:
disp = m.display_name or user_id
break
await bot.api.send_text_message(room_id, f"No stats recorded for {disp}.")
return
# Get display name
disp = user_id
resp = await bot.async_client.joined_members(room_id)
if resp.members:
for m in resp.members:
if m.user_id == user_id:
disp = m.display_name or user_id
break
msg = f"""<details>
<summary><strong>Stats for {disp}</strong></summary>
<ul>
<li>📩 Messages: {row[0]}</li>
<li>🔤 Characters: {row[1]}</li>
<li>📝 Words: {row[2]}</li>
<li>😀 Smileys: {row[3]}</li>
<li>🎭 Actions: {row[4]}</li>
<li>🚪 Joins: {row[5]}</li>
<li>👋 Parts: {row[6]}</li>
<li>👢 Kicks given: {row[7]}</li>
<li>🥾 Times kicked: {row[8]}</li>
<li>📌 Topics set: {row[9]}</li>
</ul>
</details>"""
await bot.api.send_markdown_message(room_id, msg)
# ------------------------------------------------------------------
# Plugin metadata
# ------------------------------------------------------------------
__version__ = "1.0.1"
__author__ = "Funguy Roomstats"
__description__ = "Peruser room statistics (Limnoriastyle), with multiword name support"
__help__ = """
<details>
<summary><strong>Room Statistics Commands</strong></summary>
<ul>
<li><code>!roomstats</code> Aggregate room stats + top 10 users</li>
<li><code>!rank &lt;stat&gt;</code> Top 10 by a specific stat (msgs, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set)</li>
<li><code>!stats [name]</code> Show stats for a user (supports multiword names)</li>
</ul>
<p>All commands work in the current room; display names are automatically resolved.</p>
</details>
"""