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>
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
MATRIX_HOMESERVER=https://matrix.lotusguild.org
|
||||
MATRIX_USER_ID=@lotusbot:matrix.lotusguild.org
|
||||
MATRIX_ACCESS_TOKEN=
|
||||
MATRIX_DEVICE_ID=
|
||||
BOT_PREFIX=!
|
||||
ADMIN_USERS=@jared:matrix.lotusguild.org
|
||||
LOG_LEVEL=INFO
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
nio_store/
|
||||
logs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
credentials.json
|
||||
136
bot.py
Normal file
136
bot.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from nio import (
|
||||
AsyncClient,
|
||||
AsyncClientConfig,
|
||||
LoginResponse,
|
||||
RoomMessageText,
|
||||
)
|
||||
|
||||
from config import (
|
||||
MATRIX_HOMESERVER,
|
||||
MATRIX_USER_ID,
|
||||
MATRIX_ACCESS_TOKEN,
|
||||
MATRIX_DEVICE_ID,
|
||||
LOG_LEVEL,
|
||||
ConfigValidator,
|
||||
)
|
||||
from callbacks import Callbacks
|
||||
from utils import setup_logging
|
||||
|
||||
logger = setup_logging(LOG_LEVEL)
|
||||
|
||||
CREDENTIALS_FILE = Path("credentials.json")
|
||||
STORE_PATH = Path("nio_store")
|
||||
|
||||
|
||||
def save_credentials(resp, homeserver):
|
||||
data = {
|
||||
"homeserver": homeserver,
|
||||
"user_id": resp.user_id,
|
||||
"device_id": resp.device_id,
|
||||
"access_token": resp.access_token,
|
||||
}
|
||||
CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
|
||||
logger.info("Credentials saved to %s", CREDENTIALS_FILE)
|
||||
|
||||
|
||||
def trust_devices(client: AsyncClient):
|
||||
"""Auto-trust all devices for all users we share rooms with."""
|
||||
if not client.olm:
|
||||
logger.warning("Olm not loaded, skipping device trust")
|
||||
return
|
||||
for user_id, devices in client.device_store.items():
|
||||
for device_id, olm_device in devices.items():
|
||||
if not client.olm.is_device_verified(olm_device):
|
||||
client.verify_device(olm_device)
|
||||
logger.info("Trusted all known devices")
|
||||
|
||||
|
||||
async def main():
|
||||
errors = ConfigValidator.validate()
|
||||
if errors:
|
||||
for e in errors:
|
||||
logger.error(e)
|
||||
sys.exit(1)
|
||||
|
||||
STORE_PATH.mkdir(exist_ok=True)
|
||||
|
||||
client_config = AsyncClientConfig(
|
||||
store_sync_tokens=True,
|
||||
encryption_enabled=True,
|
||||
store_name="matrixbot",
|
||||
)
|
||||
|
||||
client = AsyncClient(
|
||||
MATRIX_HOMESERVER,
|
||||
MATRIX_USER_ID,
|
||||
device_id=MATRIX_DEVICE_ID,
|
||||
config=client_config,
|
||||
store_path=str(STORE_PATH),
|
||||
)
|
||||
|
||||
# Restore access token (no password login needed)
|
||||
client.access_token = MATRIX_ACCESS_TOKEN
|
||||
client.user_id = MATRIX_USER_ID
|
||||
client.device_id = MATRIX_DEVICE_ID
|
||||
|
||||
# Load the olm/e2ee store if it exists
|
||||
client.load_store()
|
||||
|
||||
callbacks = Callbacks(client)
|
||||
client.add_event_callback(callbacks.message, RoomMessageText)
|
||||
|
||||
# Graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def _signal_handler():
|
||||
logger.info("Shutdown signal received")
|
||||
shutdown_event.set()
|
||||
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
|
||||
logger.info("Starting initial sync...")
|
||||
|
||||
# Do a first sync to catch up, then mark startup complete so we only
|
||||
# process new messages going forward.
|
||||
sync_resp = await client.sync(timeout=30000, full_state=True)
|
||||
if hasattr(sync_resp, "next_batch"):
|
||||
callbacks.startup_sync_token = sync_resp.next_batch
|
||||
logger.info("Initial sync complete, token: %s", sync_resp.next_batch[:20])
|
||||
else:
|
||||
logger.error("Initial sync failed: %s", sync_resp)
|
||||
await client.close()
|
||||
sys.exit(1)
|
||||
|
||||
# Trust devices after initial sync loads the device store
|
||||
trust_devices(client)
|
||||
|
||||
logger.info("Bot ready as %s — listening for commands", MATRIX_USER_ID)
|
||||
|
||||
# Run sync_forever in a task so we can cancel on shutdown
|
||||
async def _sync_loop():
|
||||
await client.sync_forever(timeout=30000, full_state=False)
|
||||
|
||||
sync_task = asyncio.create_task(_sync_loop())
|
||||
|
||||
await shutdown_event.wait()
|
||||
sync_task.cancel()
|
||||
try:
|
||||
await sync_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await client.close()
|
||||
logger.info("Bot shut down cleanly")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
60
callbacks.py
Normal file
60
callbacks.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
from nio import AsyncClient, RoomMessageText
|
||||
|
||||
from config import BOT_PREFIX, MATRIX_USER_ID
|
||||
from commands import COMMANDS
|
||||
|
||||
logger = logging.getLogger("matrixbot")
|
||||
|
||||
|
||||
def handle_command_errors(func):
|
||||
@wraps(func)
|
||||
async def wrapper(client, room_id, sender, args):
|
||||
try:
|
||||
return await func(client, room_id, sender, args)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in command {func.__name__}: {e}", exc_info=True)
|
||||
try:
|
||||
from utils import send_text
|
||||
await send_text(client, room_id, "An unexpected error occurred. Please try again later.")
|
||||
except Exception as e2:
|
||||
logger.error(f"Failed to send error message: {e2}", exc_info=True)
|
||||
return wrapper
|
||||
|
||||
|
||||
class Callbacks:
|
||||
def __init__(self, client: AsyncClient):
|
||||
self.client = client
|
||||
# Track the sync token so we ignore old messages on startup
|
||||
self.startup_sync_token = None
|
||||
|
||||
async def message(self, room, event):
|
||||
# Ignore messages from before the bot started
|
||||
if self.startup_sync_token is None:
|
||||
return
|
||||
|
||||
# Ignore our own messages
|
||||
if event.sender == MATRIX_USER_ID:
|
||||
return
|
||||
|
||||
body = event.body.strip() if event.body else ""
|
||||
if not body.startswith(BOT_PREFIX):
|
||||
return
|
||||
|
||||
# Parse command and args
|
||||
without_prefix = body[len(BOT_PREFIX):]
|
||||
parts = without_prefix.split(None, 1)
|
||||
cmd_name = parts[0].lower() if parts else ""
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
logger.info(f"Command '{cmd_name}' from {event.sender} in {room.room_id}")
|
||||
|
||||
handler_entry = COMMANDS.get(cmd_name)
|
||||
if handler_entry is None:
|
||||
return
|
||||
|
||||
handler, _ = handler_entry
|
||||
wrapped = handle_command_errors(handler)
|
||||
await wrapped(self.client, room.room_id, event.sender, args)
|
||||
318
commands.py
Normal file
318
commands.py
Normal file
@@ -0,0 +1,318 @@
|
||||
import random
|
||||
import time
|
||||
import logging
|
||||
|
||||
from nio import AsyncClient
|
||||
|
||||
from utils import send_text, send_html, send_reaction, sanitize_input
|
||||
from config import MAX_DICE_SIDES, MAX_DICE_COUNT, BOT_PREFIX
|
||||
|
||||
logger = logging.getLogger("matrixbot")
|
||||
|
||||
# Registry: name -> (handler, description)
|
||||
COMMANDS = {}
|
||||
|
||||
|
||||
def command(name, description=""):
|
||||
def decorator(func):
|
||||
COMMANDS[name] = (func, description)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
# ==================== 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% easily",
|
||||
"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}\nAnswer: {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)
|
||||
34
config.py
Normal file
34
config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# Required
|
||||
MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://matrix.lotusguild.org")
|
||||
MATRIX_USER_ID = os.getenv("MATRIX_USER_ID", "@lotusbot:matrix.lotusguild.org")
|
||||
MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
MATRIX_DEVICE_ID = os.getenv("MATRIX_DEVICE_ID", "")
|
||||
|
||||
# Bot settings
|
||||
BOT_PREFIX = os.getenv("BOT_PREFIX", "!")
|
||||
ADMIN_USERS = [u.strip() for u in os.getenv("ADMIN_USERS", "").split(",") if u.strip()]
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# Constants
|
||||
MAX_INPUT_LENGTH = 500
|
||||
MAX_DICE_SIDES = 100
|
||||
MAX_DICE_COUNT = 20
|
||||
|
||||
|
||||
class ConfigValidator:
|
||||
REQUIRED = ["MATRIX_HOMESERVER", "MATRIX_USER_ID", "MATRIX_ACCESS_TOKEN", "MATRIX_DEVICE_ID"]
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
errors = []
|
||||
for var in cls.REQUIRED:
|
||||
if not os.getenv(var):
|
||||
errors.append(f"Missing required: {var}")
|
||||
return errors
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
matrix-nio[e2e]
|
||||
python-dotenv
|
||||
aiohttp
|
||||
markdown
|
||||
81
utils.py
Normal file
81
utils.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from nio import AsyncClient, RoomSendResponse
|
||||
|
||||
from config import MAX_INPUT_LENGTH
|
||||
|
||||
|
||||
def setup_logging(level="INFO"):
|
||||
Path("logs").mkdir(exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("matrixbot")
|
||||
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
"logs/matrixbot.log",
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
)
|
||||
file_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
||||
)
|
||||
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
||||
)
|
||||
|
||||
logger.addHandler(file_handler)
|
||||
logger.addHandler(stream_handler)
|
||||
return logger
|
||||
|
||||
|
||||
async def send_text(client: AsyncClient, room_id: str, text: str):
|
||||
logger = logging.getLogger("matrixbot")
|
||||
resp = await client.room_send(
|
||||
room_id,
|
||||
message_type="m.room.message",
|
||||
content={"msgtype": "m.text", "body": text},
|
||||
)
|
||||
if not isinstance(resp, RoomSendResponse):
|
||||
logger.error("send_text failed: %s", resp)
|
||||
return resp
|
||||
|
||||
|
||||
async def send_html(client: AsyncClient, room_id: str, plain: str, html: str):
|
||||
logger = logging.getLogger("matrixbot")
|
||||
resp = await client.room_send(
|
||||
room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.text",
|
||||
"body": plain,
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": html,
|
||||
},
|
||||
)
|
||||
if not isinstance(resp, RoomSendResponse):
|
||||
logger.error("send_html failed: %s", resp)
|
||||
return resp
|
||||
|
||||
|
||||
async def send_reaction(client: AsyncClient, room_id: str, event_id: str, emoji: str):
|
||||
return await client.room_send(
|
||||
room_id,
|
||||
message_type="m.reaction",
|
||||
content={
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
"event_id": event_id,
|
||||
"key": emoji,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sanitize_input(text: str, max_length: int = MAX_INPUT_LENGTH) -> str:
|
||||
text = text.strip()[:max_length]
|
||||
text = "".join(char for char in text if char.isprintable())
|
||||
return text
|
||||
Reference in New Issue
Block a user