2026-02-11 20:52:57 -05:00
import asyncio
import json
Add Matrix bot Phase 1: core setup + fun commands
Modular bot using matrix-nio[e2e] with E2EE support, deployed as
systemd service on Synapse LXC. Includes 10 commands: help, ping,
8ball, fortune, flip, roll, random, rps, poll, champion.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:42:28 -05:00
import random
import time
import logging
2026-02-11 20:52:57 -05:00
from collections import Counter
from datetime import datetime
from functools import partial
import aiohttp
Add Matrix bot Phase 1: core setup + fun commands
Modular bot using matrix-nio[e2e] with E2EE support, deployed as
systemd service on Synapse LXC. Includes 10 commands: help, ping,
8ball, fortune, flip, roll, random, rps, poll, champion.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:42:28 -05:00
from nio import AsyncClient
from utils import send_text , send_html , send_reaction , sanitize_input
2026-02-11 20:52:57 -05:00
from config import (
MAX_DICE_SIDES , MAX_DICE_COUNT , BOT_PREFIX , ADMIN_USERS ,
OLLAMA_URL , OLLAMA_MODEL , MAX_INPUT_LENGTH , COOLDOWN_SECONDS ,
MINECRAFT_RCON_HOST , MINECRAFT_RCON_PORT , MINECRAFT_RCON_PASSWORD ,
RCON_TIMEOUT , MIN_USERNAME_LENGTH , MAX_USERNAME_LENGTH ,
)
Add Matrix bot Phase 1: core setup + fun commands
Modular bot using matrix-nio[e2e] with E2EE support, deployed as
systemd service on Synapse LXC. Includes 10 commands: help, ping,
8ball, fortune, flip, roll, random, rps, poll, champion.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:42:28 -05:00
logger = logging . getLogger ( " matrixbot " )
# Registry: name -> (handler, description)
COMMANDS = { }
def command ( name , description = " " ) :
def decorator ( func ) :
COMMANDS [ name ] = ( func , description )
return func
return decorator
2026-02-11 20:52:57 -05:00
# ==================== METRICS ====================
class MetricsCollector :
def __init__ ( self ) :
self . command_counts = Counter ( )
self . error_counts = Counter ( )
self . start_time = datetime . now ( )
def record_command ( self , command_name : str ) :
self . command_counts [ command_name ] + = 1
def record_error ( self , command_name : str ) :
self . error_counts [ command_name ] + = 1
def get_stats ( self ) - > dict :
uptime = datetime . now ( ) - self . start_time
return {
" uptime_seconds " : uptime . total_seconds ( ) ,
" commands_executed " : sum ( self . command_counts . values ( ) ) ,
" top_commands " : self . command_counts . most_common ( 5 ) ,
" error_count " : sum ( self . error_counts . values ( ) ) ,
}
metrics = MetricsCollector ( )
# ==================== COOLDOWNS ====================
# sender -> {command: last_used_time}
_cooldowns : dict [ str , dict [ str , float ] ] = { }
def check_cooldown ( sender : str , cmd_name : str , seconds : int = COOLDOWN_SECONDS ) - > int :
""" Return 0 if allowed, otherwise seconds remaining. """
now = time . monotonic ( )
user_cds = _cooldowns . setdefault ( sender , { } )
last = user_cds . get ( cmd_name , 0 )
remaining = seconds - ( now - last )
if remaining > 0 :
return int ( remaining ) + 1
user_cds [ cmd_name ] = now
return 0
Add Matrix bot Phase 1: core setup + fun commands
Modular bot using matrix-nio[e2e] with E2EE support, deployed as
systemd service on Synapse LXC. Includes 10 commands: help, ping,
8ball, fortune, flip, roll, random, rps, poll, champion.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:42:28 -05:00
# ==================== COMMANDS ====================
@command ( " help " , " Show all available commands " )
async def cmd_help ( client : AsyncClient , room_id : str , sender : str , args : str ) :
lines_plain = [ " Commands: " ]
lines_html = [ " <h4>Commands</h4><ul> " ]
for cmd_name , ( _ , desc ) in sorted ( COMMANDS . items ( ) ) :
lines_plain . append ( f " { BOT_PREFIX } { cmd_name } - { desc } " )
lines_html . append ( f " <li><strong> { BOT_PREFIX } { cmd_name } </strong> — { desc } </li> " )
lines_html . append ( " </ul> " )
await send_html ( client , room_id , " \n " . join ( lines_plain ) , " \n " . join ( lines_html ) )
@command ( " ping " , " Check bot latency " )
async def cmd_ping ( client : AsyncClient , room_id : str , sender : str , args : str ) :
start = time . monotonic ( )
resp = await send_text ( client , room_id , " Pong! " )
elapsed = ( time . monotonic ( ) - start ) * 1000
# Edit isn't straightforward in Matrix, so just send a follow-up if slow
if elapsed > 500 :
await send_text ( client , room_id , f " (round-trip: { elapsed : .0f } ms) " )
@command ( " 8ball " , " Ask the magic 8-ball a question " )
async def cmd_8ball ( client : AsyncClient , room_id : str , sender : str , args : str ) :
if not args :
await send_text ( client , room_id , f " Usage: { BOT_PREFIX } 8ball <question> " )
return
responses = [
" It is certain " , " Without a doubt " , " You may rely on it " ,
" Yes definitely " , " It is decidedly so " , " As I see it, yes " ,
" Most likely " , " Yes sir! " , " Hell yeah my dude " , " 100 % e asily " ,
" Reply hazy try again " , " Ask again later " , " Better not tell you now " ,
" Cannot predict now " , " Concentrate and ask again " , " Idk bro " ,
" Don ' t count on it " , " My reply is no " , " My sources say no " ,
" Outlook not so good " , " Very doubtful " , " Hell no " , " Prolly not " ,
]
answer = random . choice ( responses )
plain = f " Question: { args } \n Answer: { answer } "
html = (
f " <strong>Magic 8-Ball</strong><br> "
f " <em>Q:</em> { args } <br> "
f " <em>A:</em> { answer } "
)
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 = [
" 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 = random . choice ( fortunes )
plain = f " Fortune Cookie: { fortune } "
html = f " <strong>Fortune Cookie</strong><br> { fortune } "
await send_html ( client , room_id , plain , html )
@command ( " flip " , " Flip a coin " )
async def cmd_flip ( client : AsyncClient , room_id : str , sender : str , args : str ) :
result = random . choice ( [ " Heads " , " Tails " ] )
plain = f " Coin Flip: { result } "
html = f " <strong>Coin Flip:</strong> { result } "
await send_html ( client , room_id , plain , html )
@command ( " roll " , " Roll dice (e.g. !roll 2d6) " )
async def cmd_roll ( client : AsyncClient , room_id : str , sender : str , args : str ) :
dice_str = args . strip ( ) if args . strip ( ) else " 1d6 "
try :
num , sides = map ( int , dice_str . lower ( ) . split ( " d " ) )
except ValueError :
await send_text ( client , room_id , f " Usage: { BOT_PREFIX } roll NdS (example: 2d6) " )
return
if num < 1 or num > MAX_DICE_COUNT :
await send_text ( client , room_id , f " Number of dice must be 1- { MAX_DICE_COUNT } " )
return
if sides < 2 or sides > MAX_DICE_SIDES :
await send_text ( client , room_id , f " Sides must be 2- { MAX_DICE_SIDES } " )
return
results = [ random . randint ( 1 , sides ) for _ in range ( num ) ]
total = sum ( results )
plain = f " Dice Roll ( { dice_str } ): { results } = { total } "
html = (
f " <strong>Dice Roll</strong> ( { dice_str } )<br> "
f " Rolls: { results } <br> "
f " Total: <strong> { total } </strong> "
)
await send_html ( client , room_id , plain , html )
@command ( " random " , " Random number (e.g. !random 1 100) " )
async def cmd_random ( client : AsyncClient , room_id : str , sender : str , args : str ) :
parts = args . split ( )
try :
lo = int ( parts [ 0 ] ) if len ( parts ) > = 1 else 1
hi = int ( parts [ 1 ] ) if len ( parts ) > = 2 else 100
except ValueError :
await send_text ( client , room_id , f " Usage: { BOT_PREFIX } random <min> <max> " )
return
if lo > hi :
lo , hi = hi , lo
result = random . randint ( lo , hi )
plain = f " Random ( { lo } - { hi } ): { result } "
html = f " <strong>Random Number</strong> ( { lo } \u2013 { hi } ): <strong> { result } </strong> "
await send_html ( client , room_id , plain , html )
@command ( " rps " , " Rock Paper Scissors " )
async def cmd_rps ( client : AsyncClient , room_id : str , sender : str , args : str ) :
choices = [ " rock " , " paper " , " scissors " ]
choice = args . strip ( ) . lower ( )
if choice not in choices :
await send_text ( client , room_id , f " Usage: { BOT_PREFIX } rps <rock|paper|scissors> " )
return
bot_choice = random . choice ( choices )
if choice == bot_choice :
result = " It ' s a tie! "
elif (
( choice == " rock " and bot_choice == " scissors " )
or ( choice == " paper " and bot_choice == " rock " )
or ( choice == " scissors " and bot_choice == " paper " )
) :
result = " You win! "
else :
result = " Bot wins! "
plain = f " RPS: You= { choice } , Bot= { bot_choice } -> { result } "
html = (
f " <strong>Rock Paper Scissors</strong><br> "
f " You: { choice . capitalize ( ) } | Bot: { bot_choice . capitalize ( ) } <br> "
f " <strong> { result } </strong> "
)
await send_html ( client , room_id , plain , html )
@command ( " poll " , " Create a yes/no poll " )
async def cmd_poll ( client : AsyncClient , room_id : str , sender : str , args : str ) :
if not args :
await send_text ( client , room_id , f " Usage: { BOT_PREFIX } poll <question> " )
return
plain = f " Poll: { args } "
html = f " <strong>Poll</strong><br> { args } "
resp = await send_html ( client , room_id , plain , html )
if hasattr ( resp , " event_id " ) :
await send_reaction ( client , room_id , resp . event_id , " \U0001f44d " )
await send_reaction ( client , room_id , resp . event_id , " \U0001f44e " )
@command ( " champion " , " Random LoL champion (optional: !champion top) " )
async def cmd_champion ( client : AsyncClient , room_id : str , sender : str , args : str ) :
champions = {
" Top " : [
" Aatrox " , " Ambessa " , " Aurora " , " Camille " , " Cho ' Gath " , " Darius " ,
" Dr. Mundo " , " Fiora " , " Gangplank " , " Garen " , " Gnar " , " Gragas " ,
" Gwen " , " Illaoi " , " Irelia " , " Jax " , " Jayce " , " K ' Sante " , " Kennen " ,
" Kled " , " Malphite " , " Mordekaiser " , " Nasus " , " Olaf " , " Ornn " ,
" Poppy " , " Quinn " , " Renekton " , " Riven " , " Rumble " , " Sett " , " Shen " ,
" Singed " , " Sion " , " Teemo " , " Trundle " , " Tryndamere " , " Urgot " ,
" Vladimir " , " Volibear " , " Wukong " , " Yone " , " Yorick " ,
] ,
" Jungle " : [
" Amumu " , " Bel ' Veth " , " Briar " , " Diana " , " Ekko " , " Elise " ,
" Evelynn " , " Fiddlesticks " , " Graves " , " Hecarim " , " Ivern " ,
" Jarvan IV " , " Kayn " , " Kha ' Zix " , " Kindred " , " Lee Sin " , " Lillia " ,
" Maokai " , " Master Yi " , " Nidalee " , " Nocturne " , " Nunu " , " Olaf " ,
" Rek ' Sai " , " Rengar " , " Sejuani " , " Shaco " , " Skarner " , " Taliyah " ,
" Udyr " , " Vi " , " Viego " , " Warwick " , " Xin Zhao " , " Zac " ,
] ,
" Mid " : [
" Ahri " , " Akali " , " Akshan " , " Annie " , " Aurelion Sol " , " Azir " ,
" Cassiopeia " , " Corki " , " Ekko " , " Fizz " , " Galio " , " Heimerdinger " ,
" Hwei " , " Irelia " , " Katarina " , " LeBlanc " , " Lissandra " , " Lux " ,
" Malzahar " , " Mel " , " Naafiri " , " Neeko " , " Orianna " , " Qiyana " ,
" Ryze " , " Sylas " , " Syndra " , " Talon " , " Twisted Fate " , " Veigar " ,
" Vex " , " Viktor " , " Vladimir " , " Xerath " , " Yasuo " , " Yone " , " Zed " ,
" Zoe " ,
] ,
" Bot " : [
" Aphelios " , " Ashe " , " Caitlyn " , " Draven " , " Ezreal " , " Jhin " ,
" Jinx " , " Kai ' Sa " , " Kalista " , " Kog ' Maw " , " Lucian " ,
" Miss Fortune " , " Nilah " , " Samira " , " Sivir " , " Smolder " ,
" Tristana " , " Twitch " , " Varus " , " Vayne " , " Xayah " , " Zeri " ,
] ,
" Support " : [
" Alistar " , " Bard " , " Blitzcrank " , " Brand " , " Braum " , " Janna " ,
" Karma " , " Leona " , " Lulu " , " Lux " , " Milio " , " Morgana " , " Nami " ,
" Nautilus " , " Pyke " , " Rakan " , " Rell " , " Renata Glasc " , " Senna " ,
" Seraphine " , " Sona " , " Soraka " , " Swain " , " Taric " , " Thresh " ,
" Yuumi " , " Zilean " , " Zyra " ,
] ,
}
lane_arg = args . strip ( ) . capitalize ( ) if args . strip ( ) else " "
if lane_arg and lane_arg in champions :
lane = lane_arg
else :
lane = random . choice ( list ( champions . keys ( ) ) )
champ = random . choice ( champions [ lane ] )
plain = f " Champion Picker: { champ } ( { lane } ) "
html = (
f " <strong>League Champion Picker</strong><br> "
f " Champion: <strong> { champ } </strong><br> "
f " Lane: { lane } "
)
await send_html ( client , room_id , plain , html )
2026-02-11 20:52:57 -05:00
@command ( " agent " , " Random Valorant agent (optional: !agent duelist) " )
async def cmd_agent ( client : AsyncClient , room_id : str , sender : str , args : str ) :
agents = {
" Duelists " : [ " Jett " , " Phoenix " , " Raze " , " Reyna " , " Yoru " , " Neon " , " Iso " , " Waylay " ] ,
" Controllers " : [ " Brimstone " , " Viper " , " Omen " , " Astra " , " Harbor " , " Clove " ] ,
" Initiators " : [ " Sova " , " Breach " , " Skye " , " KAY/O " , " Fade " , " Gekko " , " Tejo " ] ,
" Sentinels " : [ " Killjoy " , " Cypher " , " Sage " , " Chamber " , " Deadlock " , " Vyse " , " Veto " ] ,
}
role_arg = args . strip ( ) . capitalize ( ) if args . strip ( ) else " "
# Allow partial match: "duelist" -> "Duelists"
role = None
if role_arg :
for key in agents :
if key . lower ( ) . startswith ( role_arg . lower ( ) ) :
role = key
break
if role is None :
role = random . choice ( list ( agents . keys ( ) ) )
selected = random . choice ( agents [ role ] )
plain = f " Valorant Agent Picker: { selected } ( { role } ) "
html = (
f " <strong>Valorant Agent Picker</strong><br> "
f " Agent: <strong> { selected } </strong><br> "
f " Role: { role } "
)
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 = [
{ " 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 } ,
]
labels = [ " \U0001f1e6 " , " \U0001f1e7 " , " \U0001f1e8 " , " \U0001f1e9 " ] # A B C D regional indicators
label_letters = [ " A " , " B " , " C " , " D " ]
question = random . choice ( questions )
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 \n React with A/B/C/D — answer revealed in 30s! "
html = (
f " <strong>Trivia Time!</strong><br> "
f " <em> { question [ ' q ' ] } </em><br> "
f " <ul> { options_html } </ul> "
f " React with A/B/C/D — answer revealed in 30s! "
)
resp = await send_html ( client , room_id , plain , html )
if hasattr ( resp , " event_id " ) :
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 " ]
answer_text = f " { label_letters [ correct ] } . { question [ ' options ' ] [ correct ] } "
await send_html (
client , room_id ,
f " Trivia Answer: { answer_text } " ,
f " <strong>Trivia Answer:</strong> { answer_text } " ,
)
asyncio . create_task ( reveal ( ) )
# ==================== INTEGRATIONS ====================
@command ( " ask " , " Ask Lotus LLM a question (2min cooldown) " )
async def cmd_ask ( client : AsyncClient , room_id : str , sender : str , args : str ) :
if not args :
await send_text ( client , room_id , f " Usage: { BOT_PREFIX } ask <question> " )
return
remaining = check_cooldown ( sender , " ask " )
if remaining :
await send_text ( client , room_id , f " Command on cooldown. Try again in { remaining } s. " )
return
question = sanitize_input ( args )
if not question :
await send_text ( client , room_id , " Please provide a valid question. " )
return
await send_text ( client , room_id , " Thinking... " )
try :
timeout = aiohttp . ClientTimeout ( total = 60 )
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 } ,
) 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
if not full_response :
full_response = " No response received from server. "
plain = f " Lotus LLM \n Q: { question } \n A: { full_response } "
html = (
f " <strong>Lotus LLM</strong><br> "
f " <em>Q:</em> { question } <br> "
f " <em>A:</em> { full_response } "
)
await send_html ( client , room_id , plain , html )
except asyncio . TimeoutError :
await send_text ( client , room_id , " LLM request timed out. Try again later. " )
except Exception as e :
logger . error ( f " Ollama error: { e } " , exc_info = True )
await send_text ( client , room_id , " Failed to reach Lotus LLM. It may be offline. " )
@command ( " minecraft " , " Whitelist a player on the Minecraft server " )
async def cmd_minecraft ( client : AsyncClient , room_id : str , sender : str , args : str ) :
username = args . strip ( )
if not username :
await send_text ( client , room_id , f " Usage: { BOT_PREFIX } minecraft <username> " )
return
if not username . replace ( " _ " , " " ) . isalnum ( ) :
await send_text ( client , room_id , " Invalid username. Use only letters, numbers, and underscores. " )
return
if not ( MIN_USERNAME_LENGTH < = len ( username ) < = MAX_USERNAME_LENGTH ) :
await send_text ( client , room_id , f " Username must be { MIN_USERNAME_LENGTH } - { MAX_USERNAME_LENGTH } characters. " )
return
if not MINECRAFT_RCON_PASSWORD :
await send_text ( client , room_id , " Minecraft server is not configured. " )
return
await send_text ( client , room_id , f " Whitelisting { username } ... " )
try :
from mcrcon import MCRcon
def _rcon ( ) :
with MCRcon ( MINECRAFT_RCON_HOST , MINECRAFT_RCON_PASSWORD , port = MINECRAFT_RCON_PORT , timeout = 3 ) as mcr :
return mcr . command ( f " whitelist add { username } " )
loop = asyncio . get_running_loop ( )
response = await asyncio . wait_for ( loop . run_in_executor ( None , _rcon ) , timeout = RCON_TIMEOUT )
logger . info ( f " RCON response: { response } " )
plain = f " Minecraft \n You have been whitelisted on the SMP! \n Server: minecraft.lotusguild.org \n Username: { username } "
html = (
f " <strong>Minecraft</strong><br> "
f " You have been whitelisted on the SMP!<br> "
f " Server: <strong>minecraft.lotusguild.org</strong><br> "
f " Username: <strong> { username } </strong> "
)
await send_html ( client , room_id , plain , html )
except ImportError :
await send_text ( client , room_id , " mcrcon is not installed. Ask an admin to install it. " )
except asyncio . TimeoutError :
await send_text ( client , room_id , " Minecraft server timed out. It may be offline. " )
except Exception as e :
logger . error ( f " RCON error: { e } " , exc_info = True )
await send_text ( client , room_id , " Failed to whitelist. The server may be offline (let jared know). " )
# ==================== ADMIN COMMANDS ====================
@command ( " health " , " Bot status and health (admin only) " )
async def cmd_health ( client : AsyncClient , room_id : str , sender : str , args : str ) :
if sender not in ADMIN_USERS :
await send_text ( client , room_id , " You don ' t have permission to use this command. " )
return
stats = metrics . get_stats ( )
uptime_hours = stats [ " uptime_seconds " ] / 3600
top_cmds = " "
if stats [ " top_commands " ] :
top_cmds = " , " . join ( f " { name } ( { count } ) " for name , count in stats [ " top_commands " ] )
services = [ ]
if OLLAMA_URL :
services . append ( " Ollama: configured " )
else :
services . append ( " Ollama: N/A " )
if MINECRAFT_RCON_PASSWORD :
services . append ( " RCON: configured " )
else :
services . append ( " RCON: N/A " )
plain = (
f " Bot Status \n "
f " Uptime: { uptime_hours : .1f } h \n "
f " Commands run: { stats [ ' commands_executed ' ] } \n "
f " Errors: { stats [ ' error_count ' ] } \n "
f " Top commands: { top_cmds or ' none ' } \n "
f " Services: { ' , ' . join ( services ) } "
)
html = (
f " <strong>Bot Status</strong><br> "
f " <strong>Uptime:</strong> { uptime_hours : .1f } h<br> "
f " <strong>Commands run:</strong> { stats [ ' commands_executed ' ] } <br> "
f " <strong>Errors:</strong> { stats [ ' error_count ' ] } <br> "
f " <strong>Top commands:</strong> { top_cmds or ' none ' } <br> "
f " <strong>Services:</strong> { ' , ' . join ( services ) } "
)
await send_html ( client , room_id , plain , html )
Add Wordle, welcome system, integrations, and update roadmap
- Add Wordle game engine with daily puzzles, hard mode, stats, and share
- Add welcome module (react-to-join onboarding, Space join DMs)
- Add Ollama LLM integration (!ask), Minecraft RCON whitelist (!minecraft)
- Add !trivia, !champion, !agent, !health commands
- Add DM routing for Wordle (games in DMs, share to public room)
- Update README: reflect Phase 4 completion, hookshot webhook setup,
infrastructure migration (LXC 151/109 to large1), Spam and Stuff room,
all 12 webhook connections with UUIDs and transform notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 10:29:36 -05:00
# ---------------------------------------------------------------------------
# Wordle
# ---------------------------------------------------------------------------
from wordle import handle_wordle
@command ( " wordle " , " Play Wordle! (!wordle help for details) " )
async def cmd_wordle ( client : AsyncClient , room_id : str , sender : str , args : str ) :
await handle_wordle ( client , room_id , sender , args )