diff --git a/.env.example b/.env.example
index b46566b..628657a 100644
--- a/.env.example
+++ b/.env.example
@@ -5,3 +5,11 @@ MATRIX_DEVICE_ID=
BOT_PREFIX=!
ADMIN_USERS=@jared:matrix.lotusguild.org
LOG_LEVEL=INFO
+
+# Integrations
+OLLAMA_URL=http://10.10.10.157:11434
+OLLAMA_MODEL=lotusllm
+MINECRAFT_RCON_HOST=10.10.10.67
+MINECRAFT_RCON_PORT=25575
+MINECRAFT_RCON_PASSWORD=
+COOLDOWN_SECONDS=120
diff --git a/callbacks.py b/callbacks.py
index 3d4577f..2181224 100644
--- a/callbacks.py
+++ b/callbacks.py
@@ -4,7 +4,7 @@ from functools import wraps
from nio import AsyncClient, RoomMessageText
from config import BOT_PREFIX, MATRIX_USER_ID
-from commands import COMMANDS
+from commands import COMMANDS, metrics
logger = logging.getLogger("matrixbot")
@@ -16,6 +16,7 @@ def handle_command_errors(func):
return await func(client, room_id, sender, args)
except Exception as e:
logger.error(f"Error in command {func.__name__}: {e}", exc_info=True)
+ metrics.record_error(func.__name__)
try:
from utils import send_text
await send_text(client, room_id, "An unexpected error occurred. Please try again later.")
@@ -56,5 +57,6 @@ class Callbacks:
return
handler, _ = handler_entry
+ metrics.record_command(cmd_name)
wrapped = handle_command_errors(handler)
await wrapped(self.client, room.room_id, event.sender, args)
diff --git a/commands.py b/commands.py
index e02df7e..59563ef 100644
--- a/commands.py
+++ b/commands.py
@@ -1,11 +1,23 @@
+import asyncio
+import json
import random
import time
import logging
+from collections import Counter
+from datetime import datetime
+from functools import partial
+
+import aiohttp
from nio import AsyncClient
from utils import send_text, send_html, send_reaction, sanitize_input
-from config import MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX
+from config import (
+ MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX, ADMIN_USERS,
+ OLLAMA_URL, OLLAMA_MODEL, MAX_INPUT_LENGTH, COOLDOWN_SECONDS,
+ MINECRAFT_RCON_HOST, MINECRAFT_RCON_PORT, MINECRAFT_RCON_PASSWORD,
+ RCON_TIMEOUT, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
+)
logger = logging.getLogger("matrixbot")
@@ -20,6 +32,53 @@ def command(name, description=""):
return decorator
+# ==================== METRICS ====================
+
+
+class MetricsCollector:
+ def __init__(self):
+ self.command_counts = Counter()
+ self.error_counts = Counter()
+ self.start_time = datetime.now()
+
+ def record_command(self, command_name: str):
+ self.command_counts[command_name] += 1
+
+ def record_error(self, command_name: str):
+ self.error_counts[command_name] += 1
+
+ def get_stats(self) -> dict:
+ uptime = datetime.now() - self.start_time
+ return {
+ "uptime_seconds": uptime.total_seconds(),
+ "commands_executed": sum(self.command_counts.values()),
+ "top_commands": self.command_counts.most_common(5),
+ "error_count": sum(self.error_counts.values()),
+ }
+
+
+metrics = MetricsCollector()
+
+
+# ==================== COOLDOWNS ====================
+
+
+# sender -> {command: last_used_time}
+_cooldowns: dict[str, dict[str, float]] = {}
+
+
+def check_cooldown(sender: str, cmd_name: str, seconds: int = COOLDOWN_SECONDS) -> int:
+ """Return 0 if allowed, otherwise seconds remaining."""
+ now = time.monotonic()
+ user_cds = _cooldowns.setdefault(sender, {})
+ last = user_cds.get(cmd_name, 0)
+ remaining = seconds - (now - last)
+ if remaining > 0:
+ return int(remaining) + 1
+ user_cds[cmd_name] = now
+ return 0
+
+
# ==================== COMMANDS ====================
@@ -316,3 +375,245 @@ async def cmd_champion(client: AsyncClient, room_id: str, sender: str, args: str
f"Lane: {lane}"
)
await send_html(client, room_id, plain, html)
+
+
+@command("agent", "Random Valorant agent (optional: !agent duelist)")
+async def cmd_agent(client: AsyncClient, room_id: str, sender: str, args: str):
+ agents = {
+ "Duelists": ["Jett", "Phoenix", "Raze", "Reyna", "Yoru", "Neon", "Iso", "Waylay"],
+ "Controllers": ["Brimstone", "Viper", "Omen", "Astra", "Harbor", "Clove"],
+ "Initiators": ["Sova", "Breach", "Skye", "KAY/O", "Fade", "Gekko", "Tejo"],
+ "Sentinels": ["Killjoy", "Cypher", "Sage", "Chamber", "Deadlock", "Vyse", "Veto"],
+ }
+
+ role_arg = args.strip().capitalize() if args.strip() else ""
+ # Allow partial match: "duelist" -> "Duelists"
+ role = None
+ if role_arg:
+ for key in agents:
+ if key.lower().startswith(role_arg.lower()):
+ role = key
+ break
+ if role is None:
+ role = random.choice(list(agents.keys()))
+
+ selected = random.choice(agents[role])
+ plain = f"Valorant Agent Picker: {selected} ({role})"
+ html = (
+ f"Valorant Agent Picker
"
+ f"Agent: {selected}
"
+ f"Role: {role}"
+ )
+ await send_html(client, room_id, plain, html)
+
+
+@command("trivia", "Play a trivia game")
+async def cmd_trivia(client: AsyncClient, room_id: str, sender: str, args: str):
+ questions = [
+ {"q": "What year was the original Super Mario Bros. released?", "options": ["1983", "1985", "1987", "1990"], "answer": 1},
+ {"q": "Which game features the quote 'The cake is a lie'?", "options": ["Half-Life 2", "Portal", "BioShock", "Minecraft"], "answer": 1},
+ {"q": "What is the max level in League of Legends?", "options": ["16", "18", "20", "25"], "answer": 1},
+ {"q": "Which Valorant agent has the codename 'Deadeye'?", "options": ["Jett", "Sova", "Chamber", "Cypher"], "answer": 2},
+ {"q": "How many Ender Dragon eggs can exist in a vanilla Minecraft world?", "options": ["1", "2", "Unlimited", "0"], "answer": 0},
+ {"q": "What was the first battle royale game to hit mainstream popularity?", "options": ["Fortnite", "PUBG", "H1Z1", "Apex Legends"], "answer": 2},
+ {"q": "In Minecraft, what is the rarest ore?", "options": ["Diamond", "Emerald", "Ancient Debris", "Lapis Lazuli"], "answer": 1},
+ {"q": "What is the name of the main character in The Legend of Zelda?", "options": ["Zelda", "Link", "Ganondorf", "Epona"], "answer": 1},
+ {"q": "Which game has the most registered players of all time?", "options": ["Fortnite", "Minecraft", "League of Legends", "Roblox"], "answer": 1},
+ {"q": "What type of animal is Sonic?", "options": ["Fox", "Hedgehog", "Rabbit", "Echidna"], "answer": 1},
+ {"q": "In Among Us, what is the maximum number of impostors?", "options": ["1", "2", "3", "4"], "answer": 2},
+ {"q": "What does GG stand for in gaming?", "options": ["Get Good", "Good Game", "Go Go", "Great Going"], "answer": 1},
+ {"q": "Which company developed Valorant?", "options": ["Blizzard", "Valve", "Riot Games", "Epic Games"], "answer": 2},
+ {"q": "What is the highest rank in Valorant?", "options": ["Immortal", "Diamond", "Radiant", "Challenger"], "answer": 2},
+ {"q": "In League of Legends, what is Baron Nashor an anagram of?", "options": ["Baron Roshan", "Roshan", "Nashor Baron", "Nash Robot"], "answer": 1},
+ {"q": "What does HTTP stand for?", "options": ["HyperText Transfer Protocol", "High Tech Transfer Program", "HyperText Transmission Process", "Home Tool Transfer Protocol"], "answer": 0},
+ {"q": "What year was Discord founded?", "options": ["2013", "2015", "2017", "2019"], "answer": 1},
+ {"q": "What programming language has a logo that is a snake?", "options": ["Java", "Ruby", "Python", "Go"], "answer": 2},
+ {"q": "How many bits are in a byte?", "options": ["4", "8", "16", "32"], "answer": 1},
+ {"q": "What does 'RGB' stand for?", "options": ["Really Good Build", "Red Green Blue", "Red Gold Black", "Rapid Gaming Boost"], "answer": 1},
+ {"q": "What is the most subscribed YouTube channel?", "options": ["PewDiePie", "MrBeast", "T-Series", "Cocomelon"], "answer": 1},
+ {"q": "What does 'AFK' stand for?", "options": ["A Free Kill", "Away From Keyboard", "Always Fun Killing", "Another Fake Knockdown"], "answer": 1},
+ {"q": "What animal is the Linux mascot?", "options": ["Fox", "Penguin", "Cat", "Dog"], "answer": 1},
+ {"q": "What does 'NPC' stand for?", "options": ["Non-Player Character", "New Player Content", "Normal Playing Conditions", "Never Played Competitively"], "answer": 0},
+ {"q": "In what year was the first iPhone released?", "options": ["2005", "2006", "2007", "2008"], "answer": 2},
+ ]
+
+ labels = ["\U0001f1e6", "\U0001f1e7", "\U0001f1e8", "\U0001f1e9"] # A B C D regional indicators
+ label_letters = ["A", "B", "C", "D"]
+ question = random.choice(questions)
+
+ options_plain = "\n".join(f" {label_letters[i]}. {opt}" for i, opt in enumerate(question["options"]))
+ options_html = "".join(f"