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
+136 -30
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,9 +331,7 @@ async def cmd_8ball(client: AsyncClient, room_id: str, sender: str, args: str):
await send_html(client, room_id, plain, html)
@command("fortune", "Get a fortune cookie message")
async def cmd_fortune(client: AsyncClient, room_id: str, sender: str, args: str):
fortunes = [
_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",
@@ -410,7 +408,42 @@ async def cmd_fortune(client: AsyncClient, room_id: str, sender: str, args: str)
"Your next API call will return 200 OK",
]
fortune = random.choice(fortunes)
@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)
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")
async def cmd_trivia(client: AsyncClient, room_id: str, sender: str, args: str):
questions = [
_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": "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},
]
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
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", "")