diff --git a/matrixbot/commands.py b/matrixbot/commands.py
index 7e7a4d6..3512b62 100644
--- a/matrixbot/commands.py
+++ b/matrixbot/commands.py
@@ -15,7 +15,7 @@ from utils import send_text, send_html, send_reaction, sanitize_input
from wordle import handle_wordle
from config import (
MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX, ADMIN_USERS,
- OLLAMA_URL, OLLAMA_MODEL, COOLDOWN_SECONDS,
+ OLLAMA_URL, OLLAMA_MODEL, ASK_MODEL, COOLDOWN_SECONDS,
MINECRAFT_RCON_HOST, MINECRAFT_RCON_PORT, MINECRAFT_RCON_PASSWORD,
RCON_TIMEOUT, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
)
@@ -331,86 +331,119 @@ async def cmd_8ball(client: AsyncClient, room_id: str, sender: str, args: str):
await send_html(client, room_id, plain, html)
+_FORTUNE_FALLBACKS = [
+ "If you eat something & nobody sees you eat it, it has no calories",
+ "Your pet is plotting world domination",
+ "Error 404: Fortune not found. Try again after system reboot",
+ "The fortune you seek is in another cookie",
+ "A journey of a thousand miles begins with ordering delivery",
+ "You will find great fortune... in between your couch cushions",
+ "A true friend is someone who tells you when your stream is muted",
+ "Your next competitive match will be legendary",
+ "The cake is still a lie",
+ "Press Alt+F4 for instant success",
+ "You will not encounter any campers today",
+ "Your tank will have a healer",
+ "No one will steal your pentakill",
+ "Your random teammate will have a mic",
+ "You will find diamonds on your first dig",
+ "The boss will drop the rare loot",
+ "Your speedrun will be WR pace",
+ "No lag spikes in your next match",
+ "Your gaming chair will grant you powers",
+ "The RNG gods will bless you",
+ "You will not get third partied",
+ "Your squad will actually stick together",
+ "The enemy team will forfeit at 15",
+ "Your aim will be crispy today",
+ "You will escape the backrooms",
+ "The imposter will not sus you",
+ "Your Minecraft bed will remain unbroken",
+ "You will get Play of the Game",
+ "Your next meme will go viral",
+ "Someone is talking about you in their Discord server",
+ "Your FBI agent thinks you're hilarious",
+ "Your next TikTok will hit the FYP, if the government doesn't ban it first",
+ "Someone will actually read your Twitter thread",
+ "Your DMs will be blessed with quality memes today",
+ "Touch grass (respectfully)",
+ "The algorithm will be in your favor today",
+ "Your next Spotify shuffle will hit different",
+ "Someone saved your Instagram post",
+ "Your Reddit comment will get gold",
+ "POV: You're about to go viral",
+ "Main character energy detected",
+ "No cap, you're gonna have a great day fr fr",
+ "Your rizz levels are increasing",
+ "You will not get ratio'd today",
+ "Someone will actually use your custom emoji",
+ "Your next selfie will be iconic",
+ "Buy a dolphin - your life will have a porpoise",
+ "Stop procrastinating - starting tomorrow",
+ "Catch fire with enthusiasm - people will come for miles to watch you burn",
+ "Your code will compile on the first try today",
+ "A semicolon will save your day",
+ "The bug you've been hunting is just a typo",
+ "Your next Git commit will be perfect",
+ "You will find the solution on the first StackOverflow link",
+ "Your Docker container will build without errors",
+ "The cloud is just someone else's computer",
+ "Your backup strategy will soon prove its worth",
+ "A mechanical keyboard is in your future",
+ "You will finally understand regex... maybe",
+ "Your CSS will align perfectly on the first try",
+ "Someone will star your GitHub repo today",
+ "Your Linux installation will not break after updates",
+ "You will remember to push your changes before shutdown",
+ "Your code comments will actually make sense in 6 months",
+ "The missing curly brace is on line 247",
+ "Have you tried turning it off and on again?",
+ "Your next pull request will be merged without comments",
+ "Your keyboard RGB will sync perfectly today",
+ "You will find that memory leak",
+ "Your next algorithm will have O(1) complexity",
+ "The force quit was strong with this one",
+ "Ctrl+S will save you today",
+ "Your next Python script will need no debugging",
+ "Your next API call will return 200 OK",
+]
+
+
@command("fortune", "Get a fortune cookie message")
async def cmd_fortune(client: AsyncClient, room_id: str, sender: str, args: str):
- fortunes = [
- "If you eat something & nobody sees you eat it, it has no calories",
- "Your pet is plotting world domination",
- "Error 404: Fortune not found. Try again after system reboot",
- "The fortune you seek is in another cookie",
- "A journey of a thousand miles begins with ordering delivery",
- "You will find great fortune... in between your couch cushions",
- "A true friend is someone who tells you when your stream is muted",
- "Your next competitive match will be legendary",
- "The cake is still a lie",
- "Press Alt+F4 for instant success",
- "You will not encounter any campers today",
- "Your tank will have a healer",
- "No one will steal your pentakill",
- "Your random teammate will have a mic",
- "You will find diamonds on your first dig",
- "The boss will drop the rare loot",
- "Your speedrun will be WR pace",
- "No lag spikes in your next match",
- "Your gaming chair will grant you powers",
- "The RNG gods will bless you",
- "You will not get third partied",
- "Your squad will actually stick together",
- "The enemy team will forfeit at 15",
- "Your aim will be crispy today",
- "You will escape the backrooms",
- "The imposter will not sus you",
- "Your Minecraft bed will remain unbroken",
- "You will get Play of the Game",
- "Your next meme will go viral",
- "Someone is talking about you in their Discord server",
- "Your FBI agent thinks you're hilarious",
- "Your next TikTok will hit the FYP, if the government doesn't ban it first",
- "Someone will actually read your Twitter thread",
- "Your DMs will be blessed with quality memes today",
- "Touch grass (respectfully)",
- "The algorithm will be in your favor today",
- "Your next Spotify shuffle will hit different",
- "Someone saved your Instagram post",
- "Your Reddit comment will get gold",
- "POV: You're about to go viral",
- "Main character energy detected",
- "No cap, you're gonna have a great day fr fr",
- "Your rizz levels are increasing",
- "You will not get ratio'd today",
- "Someone will actually use your custom emoji",
- "Your next selfie will be iconic",
- "Buy a dolphin - your life will have a porpoise",
- "Stop procrastinating - starting tomorrow",
- "Catch fire with enthusiasm - people will come for miles to watch you burn",
- "Your code will compile on the first try today",
- "A semicolon will save your day",
- "The bug you've been hunting is just a typo",
- "Your next Git commit will be perfect",
- "You will find the solution on the first StackOverflow link",
- "Your Docker container will build without errors",
- "The cloud is just someone else's computer",
- "Your backup strategy will soon prove its worth",
- "A mechanical keyboard is in your future",
- "You will finally understand regex... maybe",
- "Your CSS will align perfectly on the first try",
- "Someone will star your GitHub repo today",
- "Your Linux installation will not break after updates",
- "You will remember to push your changes before shutdown",
- "Your code comments will actually make sense in 6 months",
- "The missing curly brace is on line 247",
- "Have you tried turning it off and on again?",
- "Your next pull request will be merged without comments",
- "Your keyboard RGB will sync perfectly today",
- "You will find that memory leak",
- "Your next algorithm will have O(1) complexity",
- "The force quit was strong with this one",
- "Ctrl+S will save you today",
- "Your next Python script will need no debugging",
- "Your next API call will return 200 OK",
- ]
+ fortune = None
+ try:
+ timeout = aiohttp.ClientTimeout(total=15)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.post(
+ f"{OLLAMA_URL}/api/chat",
+ json={
+ "model": OLLAMA_MODEL,
+ "stream": False,
+ "messages": [
+ {
+ "role": "system",
+ "content": (
+ "You are a fortune cookie. Generate exactly one short, witty fortune. "
+ "One or two sentences max. No preamble, no explanation, no quotation marks — "
+ "just the fortune itself. Be clever, funny, or unexpectedly wise. "
+ "Gaming, tech, and internet culture references are welcome."
+ ),
+ },
+ {"role": "user", "content": "Give me a fortune."},
+ ],
+ },
+ ) as response:
+ data = await response.json()
+ text = data.get("message", {}).get("content", "").strip().strip('"')
+ if text and len(text) > 5:
+ fortune = text
+ except Exception:
+ pass
+
+ if not fortune:
+ fortune = random.choice(_FORTUNE_FALLBACKS)
- fortune = random.choice(fortunes)
plain = f"Fortune Cookie: {fortune}"
html = f"Fortune Cookie
{fortune}"
await send_html(client, room_id, plain, html)
@@ -607,46 +640,111 @@ async def cmd_agent(client: AsyncClient, room_id: str, sender: str, args: str):
await send_html(client, room_id, plain, html)
-@command("trivia", "Play a trivia game")
+_TRIVIA_CATEGORIES = {
+ "gaming": "video games, gaming history, game mechanics, esports",
+ "tech": "technology, programming, computers, the internet, software",
+ "general": "general knowledge, world facts, history, science, geography",
+ "movies": "movies, film history, actors, directors, pop culture",
+ "music": "music, bands, songs, music history, artists",
+ "science": "science, biology, physics, chemistry, space",
+}
+
+_TRIVIA_FALLBACKS = [
+ {"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": "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": "What type of animal is Sonic?", "options": ["Fox", "Hedgehog", "Rabbit", "Echidna"], "answer": 1},
+ {"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": "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 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},
+]
+
+
+async def _generate_trivia_question(category: str) -> dict | None:
+ """Ask the LLM to generate a trivia question. Returns None on failure."""
+ topic = _TRIVIA_CATEGORIES.get(category, _TRIVIA_CATEGORIES["general"])
+ prompt = (
+ f"Generate a trivia question about {topic}. "
+ "Respond with ONLY a JSON object, no markdown, no explanation. "
+ 'Format: {"q": "question text", "options": ["A text", "B text", "C text", "D text"], "answer": 0} '
+ "where answer is the 0-based index of the correct option. "
+ "The question should be clear, factual, and have exactly one correct answer."
+ )
+ try:
+ timeout = aiohttp.ClientTimeout(total=20)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.post(
+ f"{OLLAMA_URL}/api/chat",
+ json={
+ "model": ASK_MODEL,
+ "stream": False,
+ "messages": [
+ {
+ "role": "system",
+ "content": "You are a trivia question generator. Respond with only valid JSON, nothing else.",
+ },
+ {"role": "user", "content": prompt},
+ ],
+ },
+ ) as response:
+ data = await response.json()
+ text = data.get("message", {}).get("content", "").strip()
+ # Strip markdown code fences if present
+ if text.startswith("```"):
+ text = text.split("```")[1]
+ if text.startswith("json"):
+ text = text[4:]
+ parsed = json.loads(text)
+ # Validate structure
+ if (
+ isinstance(parsed.get("q"), str)
+ and isinstance(parsed.get("options"), list)
+ and len(parsed["options"]) == 4
+ and isinstance(parsed.get("answer"), int)
+ and 0 <= parsed["answer"] <= 3
+ ):
+ return parsed
+ except Exception:
+ pass
+ return None
+
+
+@command("trivia", "Play a trivia game (!trivia [gaming|tech|general|movies|music|science])")
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},
- ]
+ category = args.strip().lower() if args.strip().lower() in _TRIVIA_CATEGORIES else "general"
+ if args.strip() and args.strip().lower() not in _TRIVIA_CATEGORIES:
+ cats = ", ".join(_TRIVIA_CATEGORIES.keys())
+ await send_text(client, room_id, f"Unknown category. Choose from: {cats}")
+ return
+
+ question = await _generate_trivia_question(category)
+ if question is None:
+ # Fallback to static list (gaming questions only in fallback)
+ question = random.choice(_TRIVIA_FALLBACKS)
labels = ["\U0001f1e6", "\U0001f1e7", "\U0001f1e8", "\U0001f1e9"] # A B C D regional indicators
label_letters = ["A", "B", "C", "D"]
- question = random.choice(questions)
+ cat_label = category.capitalize()
options_plain = "\n".join(f" {label_letters[i]}. {opt}" for i, opt in enumerate(question["options"]))
options_html = "".join(f"