From 637b2a4b20fc515ab1eb562c6294819cefb28b36 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 20 Apr 2026 17:07:01 -0400 Subject: [PATCH] Upgrade fortune, ask, and trivia commands to use Ollama LLM fortune: generates a fresh witty one-liner via Ollama on every call, falls back to static list if LLM is unavailable. ask: switched to /api/chat endpoint with a system prompt for better conversational quality; now uses ASK_MODEL (default: gemma3:latest) separately from the 8ball OLLAMA_MODEL so each can be tuned independently. trivia: LLM generates a fresh question each time (no more repeating the same 25 questions); supports !trivia with six categories (gaming, tech, general, movies, music, science); falls back to static questions if JSON generation fails. Co-Authored-By: Claude Sonnet 4.6 --- matrixbot/commands.py | 352 +++++++++++++++++++++++++++--------------- matrixbot/config.py | 1 + 2 files changed, 230 insertions(+), 123 deletions(-) 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"
  • {label_letters[i]}. {opt}
  • " for i, opt in enumerate(question["options"])) - plain = f"Trivia Time!\n{question['q']}\n{options_plain}\n\nReact with A/B/C/D — answer revealed in 30s!" + plain = f"Trivia Time! [{cat_label}]\n{question['q']}\n{options_plain}\n\nReact with A/B/C/D — answer revealed in 30s!" html = ( - f"Trivia Time!
    " + f"Trivia Time! [{cat_label}]
    " f"{question['q']}
    " f"
      {options_html}
    " f"React with A/B/C/D — answer revealed in 30s!" @@ -657,7 +755,6 @@ async def cmd_trivia(client: AsyncClient, room_id: str, sender: str, args: str): for emoji in labels: await send_reaction(client, room_id, resp.event_id, emoji) - # Reveal answer after 30 seconds async def reveal(): await asyncio.sleep(30) correct = question["answer"] @@ -693,27 +790,36 @@ async def cmd_ask(client: AsyncClient, room_id: str, sender: str, args: str): await send_text(client, room_id, "Thinking...") try: - timeout = aiohttp.ClientTimeout(total=60) + timeout = aiohttp.ClientTimeout(total=90) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post( - f"{OLLAMA_URL}/api/generate", - json={"model": OLLAMA_MODEL, "prompt": question, "stream": True}, + f"{OLLAMA_URL}/api/chat", + json={ + "model": ASK_MODEL, + "stream": False, + "messages": [ + { + "role": "system", + "content": ( + "You are LotusBot, a helpful assistant in a Matrix chat room for a small gaming community. " + "Answer questions clearly and concisely. Keep responses reasonably brief — " + "a few sentences to a short paragraph unless the question genuinely needs more detail. " + "Be friendly and conversational." + ), + }, + {"role": "user", "content": question}, + ], + }, ) as response: - full_response = "" - async for line in response.content: - try: - chunk = json.loads(line) - if "response" in chunk: - full_response += chunk["response"] - except json.JSONDecodeError: - pass + data = await response.json() + full_response = data.get("message", {}).get("content", "").strip() if not full_response: full_response = "No response received from server." - plain = f"Lotus LLM\nQ: {question}\nA: {full_response}" + plain = f"LotusBot\nQ: {question}\nA: {full_response}" html = ( - f"Lotus LLM
    " + f"LotusBot
    " f"Q: {question}
    " f"A: {full_response}" ) diff --git a/matrixbot/config.py b/matrixbot/config.py index a298eb2..499dea8 100644 --- a/matrixbot/config.py +++ b/matrixbot/config.py @@ -19,6 +19,7 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") # Integrations OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.10.10.157:11434") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "lotusllm") +ASK_MODEL = os.getenv("ASK_MODEL", "gemma3:latest") MINECRAFT_RCON_HOST = os.getenv("MINECRAFT_RCON_HOST", "10.10.10.67") MINECRAFT_RCON_PORT = int(os.getenv("MINECRAFT_RCON_PORT", "25575")) MINECRAFT_RCON_PASSWORD = os.getenv("MINECRAFT_RCON_PASSWORD", "")