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
+229 -123
View File
@@ -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"<strong>Fortune Cookie</strong><br>{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"<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 = (
f"<strong>Trivia Time!</strong><br>"
f"<strong>Trivia Time!</strong> <em>[{cat_label}]</em><br>"
f"<em>{question['q']}</em><br>"
f"<ul>{options_html}</ul>"
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"<strong>Lotus LLM</strong><br>"
f"<strong>LotusBot</strong><br>"
f"<em>Q:</em> {question}<br>"
f"<em>A:</em> {full_response}"
)
+1
View File
@@ -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", "")