Upgrade fortune, ask, and trivia commands to use Ollama LLM
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 1m36s
Lint / Secret scan (gitleaks) (push) Successful in 5s

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 <category> 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:07:01 -04:00
parent 86cb78d74d
commit 637b2a4b20
2 changed files with 230 additions and 123 deletions
+138 -32
View File
@@ -15,7 +15,7 @@ from utils import send_text, send_html, send_reaction, sanitize_input
from wordle import handle_wordle from wordle import handle_wordle
from config import ( from config import (
MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX, ADMIN_USERS, 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, MINECRAFT_RCON_HOST, MINECRAFT_RCON_PORT, MINECRAFT_RCON_PASSWORD,
RCON_TIMEOUT, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH, RCON_TIMEOUT, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
) )
@@ -331,9 +331,7 @@ async def cmd_8ball(client: AsyncClient, room_id: str, sender: str, args: str):
await send_html(client, room_id, plain, html) await send_html(client, room_id, plain, html)
@command("fortune", "Get a fortune cookie message") _FORTUNE_FALLBACKS = [
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", "If you eat something & nobody sees you eat it, it has no calories",
"Your pet is plotting world domination", "Your pet is plotting world domination",
"Error 404: Fortune not found. Try again after system reboot", "Error 404: Fortune not found. Try again after system reboot",
@@ -408,9 +406,44 @@ async def cmd_fortune(client: AsyncClient, room_id: str, sender: str, args: str)
"Ctrl+S will save you today", "Ctrl+S will save you today",
"Your next Python script will need no debugging", "Your next Python script will need no debugging",
"Your next API call will return 200 OK", "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):
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}" plain = f"Fortune Cookie: {fortune}"
html = f"<strong>Fortune Cookie</strong><br>{fortune}" html = f"<strong>Fortune Cookie</strong><br>{fortune}"
await send_html(client, room_id, plain, html) 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) await send_html(client, room_id, plain, html)
@command("trivia", "Play a trivia game") _TRIVIA_CATEGORIES = {
async def cmd_trivia(client: AsyncClient, room_id: str, sender: str, args: str): "gaming": "video games, gaming history, game mechanics, esports",
questions = [ "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": "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": "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": "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": "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": "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": "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 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": "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": "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": "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 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 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 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": "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": "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 '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 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 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": "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}, {"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):
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 labels = ["\U0001f1e6", "\U0001f1e7", "\U0001f1e8", "\U0001f1e9"] # A B C D regional indicators
label_letters = ["A", "B", "C", "D"] 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_plain = "\n".join(f" {label_letters[i]}. {opt}" for i, opt in enumerate(question["options"]))
options_html = "".join(f"<li><strong>{label_letters[i]}</strong>. {opt}</li>" for i, opt in enumerate(question["options"])) options_html = "".join(f"<li><strong>{label_letters[i]}</strong>. {opt}</li>" 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 = ( html = (
f"<strong>Trivia Time!</strong><br>" f"<strong>Trivia Time!</strong> <em>[{cat_label}]</em><br>"
f"<em>{question['q']}</em><br>" f"<em>{question['q']}</em><br>"
f"<ul>{options_html}</ul>" f"<ul>{options_html}</ul>"
f"React with A/B/C/D — answer revealed in 30s!" 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: for emoji in labels:
await send_reaction(client, room_id, resp.event_id, emoji) await send_reaction(client, room_id, resp.event_id, emoji)
# Reveal answer after 30 seconds
async def reveal(): async def reveal():
await asyncio.sleep(30) await asyncio.sleep(30)
correct = question["answer"] 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...") await send_text(client, room_id, "Thinking...")
try: try:
timeout = aiohttp.ClientTimeout(total=60) timeout = aiohttp.ClientTimeout(total=90)
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post( async with session.post(
f"{OLLAMA_URL}/api/generate", f"{OLLAMA_URL}/api/chat",
json={"model": OLLAMA_MODEL, "prompt": question, "stream": True}, 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: ) as response:
full_response = "" data = await response.json()
async for line in response.content: full_response = data.get("message", {}).get("content", "").strip()
try:
chunk = json.loads(line)
if "response" in chunk:
full_response += chunk["response"]
except json.JSONDecodeError:
pass
if not full_response: if not full_response:
full_response = "No response received from server." 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 = ( html = (
f"<strong>Lotus LLM</strong><br>" f"<strong>LotusBot</strong><br>"
f"<em>Q:</em> {question}<br>" f"<em>Q:</em> {question}<br>"
f"<em>A:</em> {full_response}" f"<em>A:</em> {full_response}"
) )
+1
View File
@@ -19,6 +19,7 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
# Integrations # Integrations
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.10.10.157:11434") OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.10.10.157:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "lotusllm") 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_HOST = os.getenv("MINECRAFT_RCON_HOST", "10.10.10.67")
MINECRAFT_RCON_PORT = int(os.getenv("MINECRAFT_RCON_PORT", "25575")) MINECRAFT_RCON_PORT = int(os.getenv("MINECRAFT_RCON_PORT", "25575"))
MINECRAFT_RCON_PASSWORD = os.getenv("MINECRAFT_RCON_PASSWORD", "") MINECRAFT_RCON_PASSWORD = os.getenv("MINECRAFT_RCON_PASSWORD", "")