refactor: replace old bot code with Matrix infra configs and scripts

- Remove obsolete Python bot (Wordle, commands, callbacks, welcome)
- Add hookshot/ — all 11 webhook transformation functions + deploy.sh
- Add cinny/ — config.json and dev-update.sh (nightly dev branch build)
- Add landing/ — matrix.lotusguild.org landing page HTML
- Add systemd/ — livekit-server, draupnir, cinny cron unit files
- Add draupnir/ — production config (access token redacted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 10:36:51 -04:00
parent e6b1030b04
commit 0e275d725e
31 changed files with 1148 additions and 5087 deletions

6
.gitignore vendored
View File

@@ -1,7 +1,3 @@
.env
nio_store/
logs/
__pycache__/ __pycache__/
*.pyc *.pyc
credentials.json .env
wordle_stats.json

204
bot.py
View File

@@ -1,204 +0,0 @@
import asyncio
import json
import logging
import signal
import sys
from pathlib import Path
from nio import (
AsyncClient,
AsyncClientConfig,
InviteMemberEvent,
LoginResponse,
RoomMemberEvent,
RoomMessageText,
UnknownEvent,
)
from config import (
MATRIX_HOMESERVER,
MATRIX_USER_ID,
MATRIX_ACCESS_TOKEN,
MATRIX_DEVICE_ID,
MATRIX_PASSWORD,
LOG_LEVEL,
ConfigValidator,
)
from callbacks import Callbacks
from utils import setup_logging
from welcome import post_welcome_message
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)
async def trust_devices(client: AsyncClient):
"""Query keys and trust all devices for all users we share rooms with."""
if not client.olm:
logger.warning("Olm not loaded, skipping device trust")
return
# Collect all users across all joined rooms
users = set()
for room in client.rooms.values():
for user_id in room.users:
users.add(user_id)
# Fetch device keys so the store is complete
if users:
await client.keys_query()
# Trust every device
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 (%d users)", len(users))
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 or None,
config=client_config,
store_path=str(STORE_PATH),
)
# Try saved credentials first, then .env token, then password login
logged_in = False
has_creds = False
if CREDENTIALS_FILE.exists():
creds = json.loads(CREDENTIALS_FILE.read_text())
client.access_token = creds["access_token"]
client.user_id = creds["user_id"]
client.device_id = creds["device_id"]
has_creds = True
logger.info("Loaded credentials from %s", CREDENTIALS_FILE)
elif MATRIX_ACCESS_TOKEN and MATRIX_DEVICE_ID:
client.access_token = MATRIX_ACCESS_TOKEN
client.user_id = MATRIX_USER_ID
client.device_id = MATRIX_DEVICE_ID
has_creds = True
logger.info("Using access token from .env")
# Load the olm/e2ee store only if we have a device_id
if has_creds:
client.load_store()
# Test the token with a sync; if it fails, fall back to password login
if has_creds and client.access_token:
logger.info("Testing existing access token...")
sync_resp = await client.sync(timeout=30000, full_state=True)
if hasattr(sync_resp, "next_batch"):
logged_in = True
logger.info("Existing token is valid")
else:
logger.warning("Existing token is invalid, will try password login")
client.access_token = ""
if not logged_in:
if not MATRIX_PASSWORD:
logger.error("No valid token and no MATRIX_PASSWORD set — cannot authenticate")
await client.close()
sys.exit(1)
logger.info("Logging in with password...")
login_resp = await client.login(MATRIX_PASSWORD, device_name="LotusBot")
if isinstance(login_resp, LoginResponse):
logger.info("Password login successful, device_id=%s", login_resp.device_id)
save_credentials(login_resp, MATRIX_HOMESERVER)
client.load_store()
sync_resp = await client.sync(timeout=30000, full_state=True)
else:
logger.error("Password login failed: %s", login_resp)
await client.close()
sys.exit(1)
callbacks = Callbacks(client)
client.add_event_callback(callbacks.message, RoomMessageText)
client.add_event_callback(callbacks.reaction, UnknownEvent)
client.add_event_callback(callbacks.member, RoomMemberEvent)
# Auto-accept room invites
async def _auto_accept_invite(room, event):
if event.membership == "invite" and event.state_key == MATRIX_USER_ID:
logger.info("Auto-accepting invite to %s", room.room_id)
await client.join(room.room_id)
client.add_event_callback(_auto_accept_invite, InviteMemberEvent)
# 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)
# Mark startup complete from the initial sync
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
await trust_devices(client)
# Post welcome message (idempotent — only posts if not already stored)
await post_welcome_message(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())

View File

@@ -1,114 +0,0 @@
import logging
from functools import wraps
from nio import AsyncClient, RoomMessageText, UnknownEvent
from config import BOT_PREFIX, MATRIX_USER_ID
from commands import COMMANDS, metrics
from welcome import handle_welcome_reaction, handle_space_join, SPACE_ROOM_ID
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)
metrics.record_error(func.__name__)
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
metrics.record_command(cmd_name)
wrapped = handle_command_errors(handler)
await wrapped(self.client, room.room_id, event.sender, args)
async def reaction(self, room, event):
"""Handle m.reaction events (sent as UnknownEvent by matrix-nio)."""
# Ignore events from before startup
if self.startup_sync_token is None:
return
# Ignore our own reactions
if event.sender == MATRIX_USER_ID:
return
# m.reaction events come as UnknownEvent with type "m.reaction"
if not hasattr(event, "source"):
return
content = event.source.get("content", {})
relates_to = content.get("m.relates_to", {})
if relates_to.get("rel_type") != "m.annotation":
return
reacted_event_id = relates_to.get("event_id", "")
key = relates_to.get("key", "")
await handle_welcome_reaction(
self.client, room.room_id, event.sender, reacted_event_id, key
)
async def member(self, room, event):
"""Handle m.room.member events — watch for Space joins."""
# Ignore events from before startup
if self.startup_sync_token is None:
return
# Only care about the Space
if room.room_id != SPACE_ROOM_ID:
return
# Ignore our own membership changes
if event.state_key == MATRIX_USER_ID:
return
# Only trigger on joins (not leaves, bans, etc.)
if event.membership != "join":
return
# Check if this is a new join (prev was not "join")
prev = event.prev_membership if hasattr(event, "prev_membership") else None
if prev == "join":
return # Already was a member, this is a profile update or similar
await handle_space_join(self.client, event.state_key)

17
cinny/config.json Normal file
View File

@@ -0,0 +1,17 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}

59
cinny/dev-update.sh Normal file
View File

@@ -0,0 +1,59 @@
#!/bin/bash
# Cinny dev branch nightly build and deploy
set -e
REPO_DIR="/opt/cinny-dev"
WEB_ROOT="/var/www/html"
CONFIG_SAVED="/opt/cinny-dev/.cinny-config.json"
BUILD_DIR="/opt/cinny-dev/dist"
LOG="/var/log/cinny-dev-update.log"
exec >> "$LOG" 2>&1
echo "=== $(date) === Starting Cinny dev update ==="
# Save config from live site (skip if web root is already broken)
if [ -f "$WEB_ROOT/config.json" ]; then
cp "$WEB_ROOT/config.json" "$CONFIG_SAVED"
fi
# Pull latest dev
cd "$REPO_DIR"
git fetch --depth=1 origin dev
PREV=$(git rev-parse HEAD)
git reset --hard origin/dev
NEW=$(git rev-parse HEAD)
if [ "$PREV" = "$NEW" ]; then
echo "No changes since last build ($NEW), skipping."
exit 0
fi
echo "New commits since $PREV, building $NEW..."
# Clean previous dist so stale files don't get deployed on partial build
rm -rf "$BUILD_DIR"
# Build — cap at 896MB to stay within 1GB RAM + swap headroom
export NODE_OPTIONS='--max_old_space_size=896'
npm ci 2>&1 | tail -5
npm run build 2>&1 | tail -10
# Verify build actually produced output before touching web root
if [ ! -f "$BUILD_DIR/index.html" ]; then
echo "ERROR: Build failed — dist/index.html missing. Web root untouched."
exit 1
fi
# Deploy only now that we know the build succeeded
rm -rf "$WEB_ROOT"/*
cp -r "$BUILD_DIR"/* "$WEB_ROOT/"
# Restore config
if [ -f "$CONFIG_SAVED" ]; then
cp "$CONFIG_SAVED" "$WEB_ROOT/config.json"
else
echo "WARNING: No saved config found — default config from build will be used."
fi
nginx -s reload
echo "=== Deploy complete: $NEW ==="

View File

@@ -1,630 +0,0 @@
import asyncio
import json
import random
import time
import logging
from collections import Counter
from datetime import datetime
from functools import partial
import aiohttp
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, 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,
)
logger = logging.getLogger("matrixbot")
# Registry: name -> (handler, description)
COMMANDS = {}
def command(name, description=""):
def decorator(func):
COMMANDS[name] = (func, description)
return func
return decorator
# ==================== 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
# ==================== 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)
@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\nReact 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\nQ: {question}\nA: {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\nYou have been whitelisted on the SMP!\nServer: minecraft.lotusguild.org\nUsername: {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)
# ---------------------------------------------------------------------------
# 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)

View File

@@ -1,46 +0,0 @@
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", "")
MATRIX_PASSWORD = os.getenv("MATRIX_PASSWORD", "")
# 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")
# Integrations
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.10.10.157:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "lotusllm")
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", "")
# Constants
MAX_INPUT_LENGTH = 500
MAX_DICE_SIDES = 100
MAX_DICE_COUNT = 20
COOLDOWN_SECONDS = int(os.getenv("COOLDOWN_SECONDS", "120"))
RCON_TIMEOUT = 5.0
MIN_USERNAME_LENGTH = 3
MAX_USERNAME_LENGTH = 16
class ConfigValidator:
REQUIRED = ["MATRIX_HOMESERVER", "MATRIX_USER_ID"]
@classmethod
def validate(cls):
errors = []
for var in cls.REQUIRED:
if not os.getenv(var):
errors.append(f"Missing required: {var}")
return errors

43
draupnir/production.yaml Normal file
View File

@@ -0,0 +1,43 @@
homeserverUrl: "https://matrix.lotusguild.org"
rawHomeserverUrl: "https://matrix.lotusguild.org"
accessToken: "REDACTED"
pantalaimon:
use: false
username: draupnir
password: ""
experimentalRustCrypto: false
dataPath: "/data/storage"
autojoinOnlyIfManager: true
recordIgnoredInvites: false
managementRoom: "!mEvR5fe3jMmzwd-FwNygD72OY_yu8H3UP_N-57oK7MI"
logLevel: "INFO"
verifyPermissionsOnStartup: true
noop: false
# Don't apply server ACLs (trust local Synapse admin decisions)
disableServerACL: true
# Protect all rooms the bot is joined to by default
protectAllJoinedRooms: false
# Synapse admin API access
admin:
enableMakeRoomAdminCommand: true
# Don't send verbose join/leave notifications
verboseLogging: false
# Background task interval for checking bans
backgroundDelayMS: 500
# Safe redaction limit per sync
redactionLimit: 100

11
hookshot/bazarr.js Normal file
View File

@@ -0,0 +1,11 @@
var title = data.title || 'Bazarr';
var msg = data.message || data.body || '';
var type = (data.type || 'info').toLowerCase();
var emoji = type === 'success' ? '✅' : (type === 'warning' ? '⚠️' : (type === 'failure' ? '❌' : '📝'));
var lines = [emoji + ' ' + title];
var htmlParts = ['<b>' + emoji + ' ' + title + '</b>'];
if (msg) {
var msgLines = msg.split(/\r?\n/).filter(function(l){ return l.trim(); });
for (var i = 0; i < msgLines.length; i++) { lines.push(msgLines[i]); htmlParts.push(msgLines[i]); }
}
result = { version: 'v2', plain: lines.join('\n'), html: htmlParts.join('<br>'), msgtype: 'm.notice' };

54
hookshot/deploy.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/bin/bash
# Deploy hookshot transformation functions to Matrix room state.
# Each .js file in this directory maps to a webhook by the same name (case-sensitive).
#
# Usage:
# ./deploy.sh # deploy all hooks
# ./deploy.sh proxmox.js # deploy one hook
#
# Requirements:
# MATRIX_TOKEN - access token with power level >= 50 in the target room
# MATRIX_SERVER - homeserver URL (default: https://matrix.lotusguild.org)
# MATRIX_ROOM - room ID where hooks are registered
MATRIX_SERVER="${MATRIX_SERVER:-https://matrix.lotusguild.org}"
MATRIX_ROOM="${MATRIX_ROOM:-!GttT4QYd1wlGlkHU3qTmq_P3gbyYKKeSSN6R7TPcJHg}"
if [ -z "$MATRIX_TOKEN" ]; then
echo "Error: MATRIX_TOKEN is not set." >&2
exit 1
fi
DIR="$(cd "$(dirname "$0")" && pwd)"
deploy_hook() {
local file="$1"
local filename="$(basename "$file" .js)"
# Capitalize first letter to match state_key (e.g. proxmox -> Proxmox)
local state_key="$(echo "$filename" | sed 's/\b\(.\)/\u\1/g' | sed 's/-\(.\)/\u\1/g')"
local fn="$(cat "$file")"
local encoded_room="$(python3 -c "import urllib.parse; print(urllib.parse.quote('$MATRIX_ROOM'))")"
local encoded_key="$(python3 -c "import urllib.parse; print(urllib.parse.quote('$state_key'))")"
local response
response=$(curl -sf -X PUT \
"$MATRIX_SERVER/_matrix/client/v3/rooms/$encoded_room/state/uk.half-shot.matrix-hookshot.generic.hook/$encoded_key" \
-H "Authorization: Bearer $MATRIX_TOKEN" \
-H "Content-Type: application/json" \
--data-binary "$(python3 -c "import json; print(json.dumps({'name': '$state_key', 'transformationFunction': open('$file').read()}))")" \
2>&1)
if echo "$response" | python3 -c "import json,sys; d=json.load(sys.stdin); exit(0 if 'event_id' in d else 1)" 2>/dev/null; then
echo "$state_key"
else
echo "$state_key: $response"
fi
}
if [ -n "$1" ]; then
deploy_hook "$DIR/$1"
else
for f in "$DIR"/*.js; do
deploy_hook "$f"
done
fi

32
hookshot/grafana.js Normal file
View File

@@ -0,0 +1,32 @@
var status = data.status || 'unknown';
var emoji = status === 'firing' ? '🔴' : (status === 'resolved' ? '🟢' : '🟡');
var title = data.title || ('[' + status.toUpperCase() + '] Grafana Alert');
var alerts = data.alerts || [];
var lines = [emoji + ' ' + title];
var htmlParts = ['<b>' + emoji + ' ' + title + '</b>'];
for (var i = 0; i < alerts.length && i < 5; i++) {
var a = alerts[i];
var labels = a.labels || {};
var ann = a.annotations || {};
var name = labels.alertname || '';
var summary = ann.summary || ann.description || '';
var instance = labels.instance || labels.job || '';
var severity = labels.severity || '';
var aStatus = a.status || '';
var genURL = a.generatorURL || '';
var aEmoji = aStatus === 'resolved' ? '🟢' : '🔴';
if (alerts.length > 1) {
var alertLine = aEmoji + ' ' + (name || 'Alert') + (severity ? ' [' + severity + ']' : '') + (instance ? ' \u2014 ' + instance : '') + (summary ? ': ' + summary : '');
lines.push(alertLine);
htmlParts.push(alertLine + (genURL ? ' <a href="' + genURL + '">\u2197</a>' : ''));
} else {
if (name && name !== title) { lines.push('Alert: ' + name); htmlParts.push('Alert: ' + name); }
if (severity) { lines.push('Severity: ' + severity); htmlParts.push('Severity: ' + severity); }
if (instance) { lines.push('Instance: ' + instance); htmlParts.push('Instance: ' + instance); }
if (summary) { lines.push(summary); htmlParts.push(summary); }
if (genURL) { lines.push('View: ' + genURL); htmlParts.push('<a href="' + genURL + '">View in Grafana</a>'); }
}
}
if (alerts.length === 0 && data.message) { lines.push(data.message); htmlParts.push(data.message); }
if (alerts.length > 5) { var more = '(+' + (alerts.length - 5) + ' more alerts)'; lines.push(more); htmlParts.push('<i>' + more + '</i>'); }
result = { version: 'v2', plain: lines.join('\n'), html: htmlParts.join('<br>'), msgtype: 'm.notice' };

37
hookshot/lidarr.js Normal file
View File

@@ -0,0 +1,37 @@
var ev = data.eventType || 'Unknown';
if (ev === 'Test') {
result = { version: 'v2', plain: '🧪 Lidarr: Connection test successful', msgtype: 'm.notice' };
} else {
var artist = (data.artist && data.artist.name) || 'Unknown Artist';
var albums = data.albums || (data.album ? [data.album] : []);
var albumStr = albums.map(function(a){ return a.title || ''; }).filter(Boolean).join(', ');
var quality = (data.release && data.release.quality) || (data.trackFiles && data.trackFiles[0] && data.trackFiles[0].quality) || '';
var releaseGroup = (data.release && data.release.releaseGroup) || '';
var client = data.downloadClient || '';
var upgrade = data.isUpgrade ? ' \u2191upgrade' : '';
var healthMsg = data.message || '';
var healthWiki = data.wikiUrl || '';
var prevVer = data.previousVersion || '';
var newVer = data.newVersion || '';
var emojiMap = { 'Grab':'📥','Download':'✅','Rename':'✏️','ArtistAdd':'','ArtistDelete':'🗑️','AlbumAdd':'','AlbumDelete':'🗑️','TrackFileDelete':'🗑️','HealthIssue':'⚠️','HealthRestored':'💚','ApplicationUpdate':'🔄' };
var emoji = emojiMap[ev] || '🎵';
var plain, html;
if (ev === 'HealthIssue' || ev === 'HealthRestored') {
plain = emoji + ' Lidarr ' + ev + ': ' + healthMsg + (healthWiki ? '\n' + healthWiki : '');
html = '<b>' + emoji + ' Lidarr ' + ev + '</b>: ' + healthMsg + (healthWiki ? '<br><a href="' + healthWiki + '">Wiki</a>' : '');
} else if (ev === 'ApplicationUpdate') {
plain = emoji + ' Lidarr updated: ' + prevVer + ' \u2192 ' + newVer;
html = '<b>' + emoji + ' Lidarr updated</b>: ' + prevVer + ' \u2192 ' + newVer;
} else if (ev === 'ArtistAdd' || ev === 'ArtistDelete') {
plain = emoji + ' Lidarr ' + ev + ': ' + artist;
html = '<b>' + emoji + ' Lidarr ' + ev + '</b>: ' + artist;
} else {
var albumPart = albumStr ? ' \u2014 ' + albumStr : '';
var qualPart = quality ? ' [' + quality + ']' : '';
var groupPart = releaseGroup ? ' {' + releaseGroup + '}' : '';
var clientPart = client ? ' via ' + client : '';
plain = emoji + ' Lidarr ' + ev + ': ' + artist + albumPart + qualPart + groupPart + upgrade + clientPart;
html = '<b>' + emoji + ' Lidarr ' + ev + '</b>: ' + artist + (albumStr ? ' \u2014 <i>' + albumStr + '</i>' : '') + qualPart + groupPart + upgrade + clientPart;
}
result = { version: 'v2', plain: plain, html: html, msgtype: 'm.notice' };
}

21
hookshot/owncast.js Normal file
View File

@@ -0,0 +1,21 @@
var evtype = data.type || 'EVENT';
var ed = data.eventData || {};
var streamName = ed.name || ed.streamerName || '';
var title = ed.streamTitle || ed.title || '';
var viewers = ed.viewerCount !== undefined ? String(ed.viewerCount) : (ed.viewers !== undefined ? String(ed.viewers) : '');
var url = ed.externalURL || ed.url || ed.serverURL || '';
var chatUser = (ed.user && (ed.user.displayName || ed.user.username)) || '';
var chatMsg = ed.body || '';
var emoji, label;
if (evtype === 'STREAM_STARTED') { emoji = '🔴'; label = 'Now Live'; }
else if (evtype === 'STREAM_STOPPED') { emoji = '⚫'; label = 'Stream Ended'; }
else if (evtype === 'USER_JOINED') { emoji = '👤'; label = 'Viewer Joined'; }
else if (evtype === 'CHAT') { emoji = '💬'; label = 'Chat'; }
else { emoji = '📡'; label = evtype.replace(/_/g, ' '); }
var lines = [emoji + ' ' + label + (streamName ? ' \u2014 ' + streamName : '')];
var htmlParts = ['<b>' + emoji + ' ' + label + '</b>' + (streamName ? ': ' + streamName : '')];
if (title) { lines.push(title); htmlParts.push('<i>' + title + '</i>'); }
if (viewers) { lines.push(viewers + ' viewers'); htmlParts.push(viewers + ' viewers'); }
if (chatUser && chatMsg) { lines.push(chatUser + ': ' + chatMsg); htmlParts.push('<b>' + chatUser + '</b>: ' + chatMsg); }
if (url && evtype === 'STREAM_STARTED') { lines.push(url); htmlParts.push('<a href="' + url + '">' + url + '</a>'); }
result = { version: 'v2', plain: lines.join('\n'), html: htmlParts.join('<br>'), msgtype: 'm.notice' };

18
hookshot/proxmox.js Normal file
View File

@@ -0,0 +1,18 @@
var embed = (data.embeds && data.embeds[0]) || {};
var title = embed.title || 'Proxmox Notification';
var description = embed.description || '';
var fields = embed.fields || [];
var fieldMap = {};
fields.forEach(function(f) { fieldMap[f.name] = f.value; });
var severity = (fieldMap['Severity'] || 'info').toLowerCase();
var node = fieldMap['Node'] || '';
var type = fieldMap['Type'] || '';
var vmid = fieldMap['VM/CT ID'] || '';
var emoji = severity === 'error' ? '🔴' : (severity === 'warning' ? '🟡' : '');
var lines = [emoji + ' ' + title];
var htmlParts = ['<b>' + emoji + ' ' + title + '</b>'];
if (node) { lines.push('\uD83D\uDDA5\uFE0F Node: ' + node); htmlParts.push('\uD83D\uDDA5\uFE0F Node: <b>' + node + '</b>'); }
if (type) { lines.push('\uD83D\uDCCB Type: ' + type); htmlParts.push('\uD83D\uDCCB Type: ' + type); }
if (vmid && vmid !== 'N/A') { lines.push('\uD83D\uDD32 VM/CT: ' + vmid); htmlParts.push('\uD83D\uDD32 VM/CT: ' + vmid); }
if (description) { lines.push(description); htmlParts.push(description); }
result = { version: 'v2', plain: lines.join('\n'), html: htmlParts.join('<br>'), msgtype: 'm.notice' };

35
hookshot/radarr.js Normal file
View File

@@ -0,0 +1,35 @@
var ev = data.eventType || 'Unknown';
if (ev === 'Test') {
result = { version: 'v2', plain: '🧪 Radarr: Connection test successful', msgtype: 'm.notice' };
} else {
var m = data.movie || {};
var movie = m.title ? m.title + (m.year ? ' (' + m.year + ')' : '') : 'Unknown Movie';
var quality = (data.release && data.release.quality) || (data.movieFile && data.movieFile.quality) || '';
var releaseGroup = (data.release && data.release.releaseGroup) || (data.movieFile && data.movieFile.releaseGroup) || '';
var client = data.downloadClient || '';
var upgrade = data.isUpgrade ? ' \u2191upgrade' : '';
var healthMsg = data.message || '';
var healthWiki = data.wikiUrl || '';
var prevVer = data.previousVersion || '';
var newVer = data.newVersion || '';
var emojiMap = { 'Grab':'📥','Download':'✅','Rename':'✏️','MovieAdded':'','MovieDelete':'🗑️','MovieFileDelete':'🗑️','HealthIssue':'⚠️','HealthRestored':'💚','ApplicationUpdate':'🔄','ManualInteractionRequired':'🔔' };
var emoji = emojiMap[ev] || '🎬';
var plain, html;
if (ev === 'HealthIssue' || ev === 'HealthRestored') {
plain = emoji + ' Radarr ' + ev + ': ' + healthMsg + (healthWiki ? '\n' + healthWiki : '');
html = '<b>' + emoji + ' Radarr ' + ev + '</b>: ' + healthMsg + (healthWiki ? '<br><a href="' + healthWiki + '">Wiki</a>' : '');
} else if (ev === 'ApplicationUpdate') {
plain = emoji + ' Radarr updated: ' + prevVer + ' \u2192 ' + newVer;
html = '<b>' + emoji + ' Radarr updated</b>: ' + prevVer + ' \u2192 ' + newVer;
} else if (ev === 'MovieAdded' || ev === 'MovieDelete') {
plain = emoji + ' Radarr ' + ev + ': ' + movie;
html = '<b>' + emoji + ' Radarr ' + ev + '</b>: ' + movie;
} else {
var qualPart = quality ? ' [' + quality + ']' : '';
var groupPart = releaseGroup ? ' {' + releaseGroup + '}' : '';
var clientPart = client ? ' via ' + client : '';
plain = emoji + ' Radarr ' + ev + ': ' + movie + qualPart + groupPart + upgrade + clientPart;
html = '<b>' + emoji + ' Radarr ' + ev + '</b>: ' + movie + qualPart + groupPart + upgrade + clientPart;
}
result = { version: 'v2', plain: plain, html: html, msgtype: 'm.notice' };
}

37
hookshot/readarr.js Normal file
View File

@@ -0,0 +1,37 @@
var ev = data.eventType || 'Unknown';
if (ev === 'Test') {
result = { version: 'v2', plain: '🧪 Readarr: Connection test successful', msgtype: 'm.notice' };
} else {
var author = (data.author && data.author.name) || 'Unknown Author';
var books = data.books || (data.book ? [data.book] : []);
var bookStr = books.map(function(b){ return b.title || ''; }).filter(Boolean).join(', ');
var quality = (data.release && data.release.quality) || (data.bookFile && data.bookFile.quality) || '';
var releaseGroup = (data.release && data.release.releaseGroup) || (data.bookFile && data.bookFile.releaseGroup) || '';
var client = data.downloadClient || '';
var upgrade = data.isUpgrade ? ' \u2191upgrade' : '';
var healthMsg = data.message || '';
var healthWiki = data.wikiUrl || '';
var prevVer = data.previousVersion || '';
var newVer = data.newVersion || '';
var emojiMap = { 'Grab':'📥','Download':'✅','Rename':'✏️','AuthorAdd':'','AuthorDelete':'🗑️','BookAdd':'','BookDelete':'🗑️','BookFileDelete':'🗑️','HealthIssue':'⚠️','HealthRestored':'💚','ApplicationUpdate':'🔄' };
var emoji = emojiMap[ev] || '📚';
var plain, html;
if (ev === 'HealthIssue' || ev === 'HealthRestored') {
plain = emoji + ' Readarr ' + ev + ': ' + healthMsg + (healthWiki ? '\n' + healthWiki : '');
html = '<b>' + emoji + ' Readarr ' + ev + '</b>: ' + healthMsg + (healthWiki ? '<br><a href="' + healthWiki + '">Wiki</a>' : '');
} else if (ev === 'ApplicationUpdate') {
plain = emoji + ' Readarr updated: ' + prevVer + ' \u2192 ' + newVer;
html = '<b>' + emoji + ' Readarr updated</b>: ' + prevVer + ' \u2192 ' + newVer;
} else if (ev === 'AuthorAdd' || ev === 'AuthorDelete') {
plain = emoji + ' Readarr ' + ev + ': ' + author;
html = '<b>' + emoji + ' Readarr ' + ev + '</b>: ' + author;
} else {
var titlePart = bookStr ? bookStr + ' by ' + author : author;
var qualPart = quality ? ' [' + quality + ']' : '';
var groupPart = releaseGroup ? ' {' + releaseGroup + '}' : '';
var clientPart = client ? ' via ' + client : '';
plain = emoji + ' Readarr ' + ev + ': ' + titlePart + qualPart + groupPart + upgrade + clientPart;
html = '<b>' + emoji + ' Readarr ' + ev + '</b>: ' + (bookStr ? '<i>' + bookStr + '</i> by ' + author : author) + qualPart + groupPart + upgrade + clientPart;
}
result = { version: 'v2', plain: plain, html: html, msgtype: 'm.notice' };
}

29
hookshot/seerr.js Normal file
View File

@@ -0,0 +1,29 @@
var evtype = data.notification_type || data.event || 'Notification';
var subject = data.subject || evtype;
var message = data.message || '';
var media = data.media || {};
var request = data.request || {};
var issue = data.issue || {};
var comment = data.comment || {};
var user = request.requestedBy_username || request.requestedBy_email || (data.account && data.account.username) || '';
var mt = media.media_type || '';
var status4k = media.status4k || 0;
var typeEmoji = mt === 'movie' ? '🎬' : (mt === 'tv' ? '📺' : '📋');
var statusEmoji;
if (evtype.indexOf('PENDING') >= 0) statusEmoji = '🟡';
else if (evtype.indexOf('APPROVED') >= 0 || evtype.indexOf('AVAILABLE') >= 0) statusEmoji = '✅';
else if (evtype.indexOf('DECLINED') >= 0 || evtype.indexOf('FAILED') >= 0) statusEmoji = '❌';
else if (evtype.indexOf('ISSUE') >= 0) statusEmoji = '⚠️';
else if (evtype.indexOf('COMMENT') >= 0) statusEmoji = '💬';
else statusEmoji = typeEmoji;
var lines = [statusEmoji + ' ' + subject];
var htmlParts = ['<b>' + statusEmoji + ' ' + subject + '</b>'];
if (user) { lines.push('Requested by: ' + user); htmlParts.push('Requested by: ' + user); }
if (message && message !== subject) { lines.push(message); htmlParts.push(message); }
if (status4k) { lines.push('4K request'); htmlParts.push('4K request'); }
if (issue.issue_type) {
var issueLine = 'Issue: ' + issue.issue_type + (issue.message ? ' \u2014 ' + issue.message : '');
lines.push(issueLine); htmlParts.push(issueLine);
}
if (comment.message) { lines.push('Comment: ' + comment.message); htmlParts.push('Comment: ' + comment.message); }
result = { version: 'v2', plain: lines.join('\n'), html: htmlParts.join('<br>'), msgtype: 'm.notice' };

41
hookshot/sonarr.js Normal file
View File

@@ -0,0 +1,41 @@
var ev = data.eventType || 'Unknown';
if (ev === 'Test') {
result = { version: 'v2', plain: '🧪 Sonarr: Connection test successful', msgtype: 'm.notice' };
} else {
var series = (data.series && data.series.title) || 'Unknown Series';
var network = (data.series && data.series.network) || '';
var eps = data.episodes || [];
var epStrs = eps.map(function(ep) {
var s = 'S' + ('0'+(ep.seasonNumber||0)).slice(-2) + 'E' + ('0'+(ep.episodeNumber||0)).slice(-2);
return ep.title ? s + ' \u2013 ' + ep.title : s;
});
var quality = (data.release && data.release.quality) || (data.episodeFile && data.episodeFile.quality) || '';
var releaseGroup = (data.release && data.release.releaseGroup) || (data.episodeFile && data.episodeFile.releaseGroup) || '';
var client = data.downloadClient || '';
var upgrade = data.isUpgrade ? ' \u2191upgrade' : '';
var healthMsg = data.message || '';
var healthWiki = data.wikiUrl || '';
var prevVer = data.previousVersion || '';
var newVer = data.newVersion || '';
var emojiMap = { 'Grab':'📥','Download':'✅','Rename':'✏️','SeriesAdd':'','SeriesDelete':'🗑️','EpisodeFileDelete':'🗑️','HealthIssue':'⚠️','HealthRestored':'💚','ApplicationUpdate':'🔄','ManualInteractionRequired':'🔔' };
var emoji = emojiMap[ev] || '📺';
var plain, html;
if (ev === 'HealthIssue' || ev === 'HealthRestored') {
plain = emoji + ' Sonarr ' + ev + ': ' + healthMsg + (healthWiki ? '\n' + healthWiki : '');
html = '<b>' + emoji + ' Sonarr ' + ev + '</b>: ' + healthMsg + (healthWiki ? '<br><a href="' + healthWiki + '">Wiki</a>' : '');
} else if (ev === 'ApplicationUpdate') {
plain = emoji + ' Sonarr updated: ' + prevVer + ' \u2192 ' + newVer;
html = '<b>' + emoji + ' Sonarr updated</b>: ' + prevVer + ' \u2192 ' + newVer;
} else if (ev === 'SeriesAdd' || ev === 'SeriesDelete' || ev === 'Rename') {
plain = emoji + ' Sonarr ' + ev + ': ' + series + (network ? ' (' + network + ')' : '');
html = '<b>' + emoji + ' Sonarr ' + ev + '</b>: ' + series + (network ? ' (' + network + ')' : '');
} else {
var epPart = epStrs.length ? ' \u2014 ' + epStrs.join(', ') : '';
var qualPart = quality ? ' [' + quality + ']' : '';
var groupPart = releaseGroup ? ' {' + releaseGroup + '}' : '';
var clientPart = client ? ' via ' + client : '';
plain = emoji + ' Sonarr ' + ev + ': ' + series + epPart + qualPart + groupPart + upgrade + clientPart;
html = '<b>' + emoji + ' Sonarr ' + ev + '</b>: ' + series + (epStrs.length ? ' \u2014 <i>' + epStrs.join(', ') + '</i>' : '') + qualPart + groupPart + upgrade + clientPart;
}
result = { version: 'v2', plain: plain, html: html, msgtype: 'm.notice' };
}

View File

@@ -0,0 +1,31 @@
var id = data.ticket_id || '?';
var title = data.title || 'Untitled';
var priority = parseInt(data.priority) || 4;
var category = data.category || 'General';
var type = data.type || 'Issue';
var source = data.source || '';
var url = data.url || '';
var trigger = data.trigger || 'manual';
var notifyUsers = data.notify_users || [];
var priorityEmojis = ['', '🔴', '🟠', '🔵', '🟢', '⚫'];
var priorityLabels = ['', 'P1 Critical', 'P2 High', 'P3 Medium', 'P4 Low', 'P5 Info'];
var emoji = (priority >= 1 && priority <= 5) ? priorityEmojis[priority] : '⚫';
var pLabel = (priority >= 1 && priority <= 5) ? priorityLabels[priority] : 'P' + priority;
var tLabel = trigger === 'automated' ? 'Automated' : 'Manual';
var meta = pLabel + ' \u00b7 ' + category + ' \u00b7 ' + type + (source ? ' \u00b7 ' + source : '') + ' [' + tLabel + ']';
var mentionPlain = '';
var mentionHtml = '';
if (notifyUsers.length > 0) {
var pParts = [], hParts = [];
for (var i = 0; i < notifyUsers.length; i++) {
var uid = notifyUsers[i];
var disp = uid.replace(/^@/, '').split(':')[0];
pParts.push(uid);
hParts.push('<a href="https://matrix.to/#/' + uid + '">' + disp + '</a>');
}
mentionPlain = '\n' + pParts.join(' ');
mentionHtml = '<br>' + hParts.join(' ');
}
var plain = emoji + ' New Ticket #' + id + ': ' + title + '\n' + meta + (url ? '\n' + url : '') + mentionPlain;
var html = '<b>' + emoji + ' <a href="' + url + '">#' + id + '</a>: ' + title + '</b><br>' + meta + mentionHtml;
result = { version: 'v2', plain: plain, html: html, msgtype: 'm.text' };

18
hookshot/uptime-kuma.js Normal file
View File

@@ -0,0 +1,18 @@
var monitor = data.monitor || {};
var hb = data.heartbeat || {};
var name = monitor.name || monitor.url || 'Monitor';
var url = monitor.url || '';
var status = hb.status;
var emoji = status === 1 ? '🟢' : (status === 0 ? '🔴' : '🟡');
var state = status === 1 ? 'UP' : (status === 0 ? 'DOWN' : 'Unknown');
var reason = hb.msg || '';
var ping = (hb.ping !== undefined && hb.ping !== null) ? hb.ping + 'ms' : '';
var duration = hb.duration ? hb.duration + 's' : '';
if (reason === 'OK' || reason === '200 - OK' || reason === '200') reason = '';
var lines = [emoji + ' ' + name + ' is ' + state];
var htmlParts = ['<b>' + emoji + ' ' + name + ' is ' + state + '</b>'];
if (url) { lines.push(url); htmlParts.push('<a href="' + url + '">' + url + '</a>'); }
if (reason) { lines.push('Reason: ' + reason); htmlParts.push('Reason: ' + reason); }
if (ping && status === 1) { lines.push('Ping: ' + ping); htmlParts.push('Ping: ' + ping); }
if (duration && status === 0) { lines.push('Down for: ' + duration); htmlParts.push('Down for: ' + duration); }
result = { version: 'v2', plain: lines.join('\n'), html: htmlParts.join('<br>'), msgtype: 'm.notice' };

623
landing/index.html Normal file
View File

@@ -0,0 +1,623 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lotus Guild Matrix</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #e0e0e0;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
body::before {
content: '';
position: fixed;
top: 50%;
left: 50%;
width: 900px;
height: 900px;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(152, 0, 0, 0.07) 0%, transparent 65%);
pointer-events: none;
z-index: 0;
}
.container {
position: relative;
z-index: 1;
text-align: center;
padding: 20px;
max-width: 560px;
width: 100%;
}
.logo {
width: 140px;
height: 140px;
margin: 0 auto 28px;
border-radius: 50%;
filter: drop-shadow(0 0 24px rgba(152, 0, 0, 0.35));
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
h1 {
font-size: 2rem;
font-weight: 300;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #fff;
margin-bottom: 4px;
}
h1 span {
color: #980000;
font-weight: 600;
}
.subtitle {
font-size: 0.85rem;
color: #555;
letter-spacing: 0.3em;
text-transform: uppercase;
margin-bottom: 36px;
}
.card {
background: linear-gradient(145deg, rgba(22, 22, 22, 0.95), rgba(14, 14, 14, 0.98));
border: 1px solid rgba(152, 0, 0, 0.2);
border-radius: 16px;
padding: 32px;
}
.card h2 {
font-size: 0.9rem;
font-weight: 500;
color: #980000;
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 20px;
}
.steps {
list-style: none;
text-align: left;
margin-bottom: 28px;
}
.steps li {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.steps li:last-child { border-bottom: none; }
.step-num {
flex-shrink: 0;
width: 28px;
height: 28px;
background: rgba(152, 0, 0, 0.12);
border: 1px solid rgba(152, 0, 0, 0.35);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 600;
color: #c44;
}
.step-text {
padding-top: 3px;
font-size: 0.95rem;
line-height: 1.5;
color: #bbb;
}
.step-text strong { color: #e0e0e0; }
.homeserver {
display: inline-block;
background: rgba(152, 0, 0, 0.1);
border: 1px solid rgba(152, 0, 0, 0.25);
color: #e88;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.85rem;
padding: 3px 10px;
border-radius: 6px;
margin-top: 2px;
}
.step-text a {
color: #c44;
text-decoration: none;
border-bottom: 1px solid rgba(204, 68, 68, 0.3);
transition: border-color 0.2s;
}
.step-text a:hover {
border-bottom-color: #c44;
}
.or-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 10px 42px;
color: #444;
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.or-divider::before, .or-divider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255,255,255,0.06);
}
.option-block {
margin-left: 42px;
padding: 12px 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
text-align: left;
font-size: 0.88rem;
color: #888;
line-height: 1.5;
}
.option-block a {
color: #c44;
text-decoration: none;
border-bottom: 1px solid rgba(204, 68, 68, 0.3);
transition: border-color 0.2s;
}
.option-block a:hover { border-bottom-color: #c44; }
.divider {
height: 1px;
background: rgba(152, 0, 0, 0.15);
margin: 24px 0;
}
.clients-section h3 {
font-size: 0.8rem;
font-weight: 500;
color: #666;
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: 14px;
}
.client-featured {
margin-bottom: 20px;
}
.client-featured a {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: linear-gradient(135deg, rgba(152, 0, 0, 0.25), rgba(120, 0, 0, 0.15));
border: 1px solid rgba(152, 0, 0, 0.55);
color: #fff;
text-decoration: none;
padding: 18px 24px;
border-radius: 12px;
transition: all 0.25s ease;
box-shadow: 0 0 20px rgba(152, 0, 0, 0.1);
}
.client-featured a:hover {
background: linear-gradient(135deg, rgba(152, 0, 0, 0.38), rgba(120, 0, 0, 0.25));
border-color: rgba(152, 0, 0, 0.8);
box-shadow: 0 0 32px rgba(152, 0, 0, 0.25);
transform: translateY(-2px);
}
.client-featured .client-name {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.client-featured .client-desc {
font-size: 0.82rem;
color: #ccc;
}
.client-featured .tag-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
margin-top: 2px;
}
.tag {
font-size: 0.65rem;
background: rgba(255,255,255,0.15);
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #eee;
}
.tag.voice {
background: rgba(0, 180, 120, 0.25);
border: 1px solid rgba(0, 180, 120, 0.4);
color: #5effc4;
}
.client-group {
margin-bottom: 14px;
}
.client-group-label {
font-size: 0.78rem;
color: #666;
margin-bottom: 8px;
line-height: 1.4;
}
.client-links {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.client-links a {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(152, 0, 0, 0.08);
border: 1px solid rgba(152, 0, 0, 0.2);
color: #ccc;
text-decoration: none;
padding: 9px 16px;
border-radius: 8px;
font-size: 0.85rem;
transition: all 0.25s ease;
}
.client-links a:hover {
background: rgba(152, 0, 0, 0.18);
border-color: rgba(152, 0, 0, 0.45);
color: #fff;
transform: translateY(-1px);
}
.space-join {
margin-top: 20px;
padding: 16px 20px;
background: rgba(152, 0, 0, 0.06);
border: 1px solid rgba(152, 0, 0, 0.2);
border-radius: 10px;
}
.space-join p {
font-size: 0.82rem;
color: #666;
margin-bottom: 10px;
}
.space-join a {
display: inline-block;
background: rgba(152, 0, 0, 0.15);
border: 1px solid rgba(152, 0, 0, 0.35);
color: #c44;
text-decoration: none;
padding: 8px 20px;
border-radius: 8px;
font-size: 0.88rem;
transition: all 0.25s ease;
}
.space-join a:hover {
background: rgba(152, 0, 0, 0.25);
color: #fff;
}
.all-clients {
margin-top: 14px;
}
.all-clients a {
font-size: 0.78rem;
color: #555;
text-decoration: none;
border-bottom: 1px solid rgba(85, 85, 85, 0.3);
transition: color 0.2s, border-color 0.2s;
}
.all-clients a:hover {
color: #888;
border-bottom-color: #888;
}
.contact {
margin-top: 24px;
padding: 16px;
background: linear-gradient(145deg, rgba(22, 22, 22, 0.95), rgba(14, 14, 14, 0.98));
border: 1px solid rgba(152, 0, 0, 0.2);
border-radius: 12px;
text-align: center;
}
.footer {
margin-top: 20px;
font-size: 0.72rem;
color: #383838;
letter-spacing: 0.04em;
display: flex;
justify-content: center;
gap: 14px;
flex-wrap: wrap;
}
.footer a {
color: #444;
text-decoration: none;
transition: color 0.2s;
}
.footer a:hover { color: #777; }
.server-info {
margin-top: 24px;
background: linear-gradient(145deg, rgba(22, 22, 22, 0.95), rgba(14, 14, 14, 0.98));
border: 1px solid rgba(152, 0, 0, 0.2);
border-radius: 12px;
overflow: hidden;
}
.server-info-title {
font-size: 0.78rem;
font-weight: 500;
color: #980000;
text-transform: uppercase;
letter-spacing: 0.15em;
padding: 14px 20px 10px;
border-bottom: 1px solid rgba(152, 0, 0, 0.1);
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.info-item {
padding: 12px 18px;
border-bottom: 1px solid rgba(255,255,255,0.03);
border-right: 1px solid rgba(255,255,255,0.03);
text-align: left;
}
.info-item:nth-child(even) { border-right: none; }
.info-item:nth-last-child(-n+2) { border-bottom: none; }
.info-label {
font-size: 0.7rem;
color: #555;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 3px;
}
.info-value {
font-size: 0.88rem;
color: #ccc;
}
.info-value.green { color: #5effc4; }
.privacy-strip {
margin-top: 12px;
padding: 10px 18px;
border-top: 1px solid rgba(152, 0, 0, 0.1);
display: flex;
gap: 18px;
justify-content: center;
flex-wrap: wrap;
}
.privacy-badge {
font-size: 0.75rem;
color: #5effc4;
display: flex;
align-items: center;
gap: 5px;
}
.privacy-badge::before {
content: '✓';
font-weight: 700;
}
.legal-note {
margin-top: 24px;
padding: 12px 18px;
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 8px;
font-size: 0.75rem;
color: #444;
line-height: 1.6;
text-align: left;
}
.legal-note a {
color: #555;
text-decoration: none;
border-bottom: 1px solid rgba(85,85,85,0.3);
}
.legal-note a:hover { color: #888; }
@media (max-width: 480px) {
.logo { width: 110px; height: 110px; }
h1 { font-size: 1.5rem; }
.card { padding: 24px 18px; }
.client-links { flex-direction: column; }
.client-links a { justify-content: center; }
.or-divider, .option-block { margin-left: 0; }
}
</style>
</head>
<body>
<div class="container">
<img class="logo" src="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" alt="Lotus Guild">
<h1><span>Lotus</span> Guild</h1>
<p class="subtitle">Private Communications</p>
<div class="card">
<h2>How to Join</h2>
<ol class="steps">
<li>
<span class="step-num">1</span>
<span class="step-text">Open <a href="https://chat.lotusguild.org" target="_blank" rel="noopener">chat.lotusguild.org</a> &mdash; your homeserver is pre-configured</span>
</li>
<li>
<span class="step-num">2</span>
<span class="step-text">Register with a <strong>token from Jared</strong> &mdash; <code class="homeserver">@jared:matrix.lotusguild.org</code></span>
</li>
<li>
<span class="step-num">3</span>
<span class="step-text">Join the <a href="https://matrix.to/#/!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc?via=matrix.lotusguild.org&via=matrix.org" target="_blank" rel="noopener">Lotus Guild Space</a> to access all rooms</span>
</li>
</ol>
<div class="or-divider">or join from another server</div>
<div class="option-block">
Already have a Matrix account? Sign up free at <a href="https://app.element.io/#/register?hs_url=https://mozilla.modular.im" target="_blank" rel="noopener">mozilla.org</a> or <a href="https://app.element.io/#/register" target="_blank" rel="noopener">matrix.org</a>, then <a href="https://matrix.to/#/!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc?via=matrix.lotusguild.org&via=matrix.org" target="_blank" rel="noopener">join our space</a>.
</div>
<div class="divider"></div>
<div class="clients-section">
<h3>Recommended Client</h3>
<div class="client-featured">
<a href="https://chat.lotusguild.org" target="_blank" rel="noopener">
<span class="client-name">Lotus Guild Chat</span>
<span class="client-desc">Cinny &mdash; hosted for you, no setup required</span>
<span class="tag-row">
<span class="tag">Desktop &amp; Web</span>
<span class="tag voice">Voice &amp; Video Calls</span>
<span class="tag">Recommended</span>
</span>
</a>
</div>
<div class="space-join">
<p>Already signed in? Jump straight into the community:</p>
<a href="https://matrix.to/#/!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc?via=matrix.lotusguild.org&via=matrix.org" target="_blank" rel="noopener">Join Lotus Guild Space &rarr;</a>
</div>
<div class="divider"></div>
<div class="clients-section">
<h3>Other Clients</h3>
<div class="client-group">
<p class="client-group-label">Mobile &mdash; iOS &amp; Android</p>
<div class="client-links">
<a href="https://element.io/element-x" target="_blank" rel="noopener">Element X</a>
<a href="https://fluffychat.im/" target="_blank" rel="noopener">FluffyChat</a>
<a href="https://commet.chat/" target="_blank" rel="noopener">Commet <span class="tag" style="background:rgba(255,180,0,0.15);border:1px solid rgba(255,180,0,0.3);color:#ffcc55;">Beta</span></a>
</div>
</div>
<div class="client-group">
<p class="client-group-label">Desktop alternatives</p>
<div class="client-links">
<a href="https://element.io/download" target="_blank" rel="noopener">Element</a>
<a href="https://commet.chat/" target="_blank" rel="noopener">Commet <span class="tag" style="background:rgba(255,180,0,0.15);border:1px solid rgba(255,180,0,0.3);color:#ffcc55;">Beta</span></a>
<a href="https://schildi.chat/" target="_blank" rel="noopener">SchildiChat</a>
</div>
</div>
<p class="all-clients"><a href="https://matrix.org/ecosystem/clients/" target="_blank" rel="noopener">View all Matrix clients &rarr;</a></p>
</div>
</div>
</div>
<div class="server-info">
<p class="server-info-title">Server Details</p>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Access</div>
<div class="info-value">Invite-only</div>
</div>
<div class="info-item">
<div class="info-label">Max Upload</div>
<div class="info-value">200 MB / file</div>
</div>
<div class="info-item">
<div class="info-label">Message History</div>
<div class="info-value">Kept indefinitely</div>
</div>
<div class="info-item">
<div class="info-label">Media Retention</div>
<div class="info-value">3 yr local · 1 yr remote</div>
</div>
<div class="info-item">
<div class="info-label">Federation</div>
<div class="info-value">Fully federated</div>
</div>
<div class="info-item">
<div class="info-label">Minimum Age</div>
<div class="info-value">13+ (COPPA)</div>
</div>
</div>
<div class="privacy-strip">
<span class="privacy-badge">No ads or tracking</span>
<span class="privacy-badge">No data sold</span>
<span class="privacy-badge">E2EE — server cannot read encrypted rooms</span>
</div>
</div>
<div class="legal-note">
This service is provided "as-is" with no uptime guarantee. Not for emergency use — do not use to contact emergency services (e.g. 911). Use is governed by our <a href="https://wiki.lotusguild.org/en/Legal/terms-of-service" target="_blank" rel="noopener">Terms of Service</a> and <a href="https://wiki.lotusguild.org/en/Legal/privacy-policy" target="_blank" rel="noopener">Privacy Policy</a>. Governing law: State of Ohio, United States.
</div>
<div class="contact">
<p style="font-size: 0.85rem; color: #777; margin-bottom: 4px;">Questions or need a registration token?</p>
<p>Reach out to <code class="homeserver">@jared:matrix.lotusguild.org</code></p>
</div>
<p class="footer">
<a href="https://wiki.lotusguild.org/en/Services/service-matrix" target="_blank" rel="noopener">Wiki &amp; Setup Guide</a>
<span>&middot;</span>
<a href="https://wiki.lotusguild.org/en/Legal/privacy-policy" target="_blank" rel="noopener">Privacy Policy</a>
<span>&middot;</span>
<a href="https://wiki.lotusguild.org/en/Legal/terms-of-service" target="_blank" rel="noopener">Terms of Service</a>
<span>&middot;</span>
<span>Powered by Matrix &middot; E2E Encrypted</span>
</p>
</div>
</body>
</html>

View File

@@ -1,5 +0,0 @@
matrix-nio[e2e]
python-dotenv
aiohttp
markdown
mcrcon

View File

@@ -0,0 +1 @@
0 3 * * * root /usr/local/bin/cinny-dev-update.sh

22
systemd/draupnir.service Normal file
View File

@@ -0,0 +1,22 @@
[Unit]
Description=Draupnir Matrix Moderation Bot
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/draupnir
ExecStart=/usr/bin/node /opt/draupnir/lib/index.js --draupnir-config /opt/draupnir/config/production.yaml
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=draupnir
# Resource limits
MemoryMax=512M
CPUQuota=80%
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,3 @@
#\!/bin/bash
pkill -x livekit-server 2>/dev/null && sleep 1
exit 0

View File

@@ -0,0 +1,15 @@
[Unit]
Description=LiveKit SFU Server
After=network.target
[Service]
Type=simple
ExecStartPre=-/bin/bash -c 'pkill -x livekit-server; sleep 1'
ExecStart=/usr/local/bin/livekit-server --config /etc/livekit/config.yaml
Restart=on-failure
RestartSec=5
KillMode=control-group
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target

132
utils.py
View File

@@ -1,132 +0,0 @@
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from nio import AsyncClient, RoomSendResponse
from nio.exceptions import OlmUnverifiedDeviceError
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
def _trust_all(client: AsyncClient):
"""Trust all devices in the device store."""
if not client.olm:
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)
async def _room_send_trusted(client: AsyncClient, room_id: str, message_type: str, content: dict):
"""Send a message, auto-trusting devices on OlmUnverifiedDeviceError."""
try:
return await client.room_send(
room_id, message_type=message_type, content=content,
ignore_unverified_devices=True,
)
except OlmUnverifiedDeviceError:
_trust_all(client)
return await client.room_send(
room_id, message_type=message_type, content=content,
ignore_unverified_devices=True,
)
async def send_text(client: AsyncClient, room_id: str, text: str):
logger = logging.getLogger("matrixbot")
resp = await _room_send_trusted(
client, 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 _room_send_trusted(
client, 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 _room_send_trusted(
client, room_id,
message_type="m.reaction",
content={
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": event_id,
"key": emoji,
}
},
)
async def get_or_create_dm(client: AsyncClient, user_id: str) -> str | None:
"""Find an existing DM room with user_id, or create one. Returns room_id."""
logger = logging.getLogger("matrixbot")
# Check existing rooms for a DM with this user
for room_id, room in client.rooms.items():
if room.member_count == 2 and user_id in (m.user_id for m in room.users.values()):
return room_id
# Create a new DM room
from nio import RoomCreateResponse
resp = await client.room_create(
is_direct=True,
invite=[user_id],
)
if isinstance(resp, RoomCreateResponse):
logger.info("Created DM room %s with %s", resp.room_id, user_id)
# Sync so the new room appears in client.rooms before we try to send
await client.sync(timeout=5000)
return resp.room_id
logger.error("Failed to create DM room with %s: %s", user_id, resp)
return None
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

View File

@@ -1,150 +0,0 @@
"""Welcome module — DM new Space members.
When a user joins the Space, the bot sends them a DM with a welcome
message and a reaction button. When they react, the bot invites them
to the standard public channels (General, Commands, Memes).
"""
import json
import logging
from pathlib import Path
from nio import AsyncClient
from utils import send_html, send_reaction, get_or_create_dm
from config import MATRIX_USER_ID
logger = logging.getLogger("matrixbot")
# The Space room to watch for new members
SPACE_ROOM_ID = "!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc"
# Public channels to invite new members to (skip Management + Cool Kids)
INVITE_ROOMS = [
"!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0", # General (v12)
"!ou56mVZQ8ZB7AhDYPmBV5_BR28WMZ4x5zwZkPCqjq1s", # Commands (v12)
"!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U", # Memes (v12)
]
WELCOME_EMOJI = "\u2705" # checkmark
STATE_FILE = Path("welcome_state.json")
def _load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except (json.JSONDecodeError, OSError):
pass
return {}
def _save_state(state: dict):
try:
tmp = STATE_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2))
tmp.rename(STATE_FILE)
except OSError as e:
logger.error("Failed to save welcome state: %s", e)
async def handle_space_join(client: AsyncClient, sender: str):
"""Called when a new user joins the Space. DM them a welcome message."""
state = _load_state()
welcomed = state.get("welcomed_users", [])
if sender in welcomed:
return
logger.info("New Space member %s — sending welcome DM", sender)
dm_room = await get_or_create_dm(client, sender)
if not dm_room:
logger.error("Could not create DM with %s for welcome", sender)
return
plain = (
"Welcome to The Lotus Guild!\n\n"
f"React to this message with {WELCOME_EMOJI} to get invited to all channels.\n\n"
"You'll be added to General, Commands, and Memes."
)
html = (
"<h3>Welcome to The Lotus Guild!</h3>"
f"<p>React to this message with {WELCOME_EMOJI} to get invited to all channels.</p>"
"<p>You'll be added to <b>General</b>, <b>Commands</b>, and <b>Memes</b>.</p>"
)
resp = await send_html(client, dm_room, plain, html)
if hasattr(resp, "event_id"):
# Track the welcome message per user so we can match their reaction
dm_messages = state.get("dm_welcome_messages", {})
dm_messages[resp.event_id] = {"user": sender, "dm_room": dm_room}
state["dm_welcome_messages"] = dm_messages
_save_state(state)
# React to our own message to show what to click
await send_reaction(client, dm_room, resp.event_id, WELCOME_EMOJI)
logger.info("Sent welcome DM to %s (event %s)", sender, resp.event_id)
else:
logger.error("Failed to send welcome DM to %s: %s", sender, resp)
async def handle_welcome_reaction(
client: AsyncClient, room_id: str, sender: str, reacted_event_id: str, key: str
):
"""Handle a reaction to a welcome DM. Invite user to channels."""
if sender == MATRIX_USER_ID:
return
if key != WELCOME_EMOJI:
return
state = _load_state()
dm_messages = state.get("dm_welcome_messages", {})
entry = dm_messages.get(reacted_event_id)
if not entry:
return
if entry["user"] != sender:
return
logger.info("Welcome reaction from %s — sending invites", sender)
invited_count = 0
for invite_room_id in INVITE_ROOMS:
room = client.rooms.get(invite_room_id)
if room and sender in (m.user_id for m in room.users.values()):
logger.debug("%s already in %s, skipping", sender, invite_room_id)
continue
try:
resp = await client.room_invite(invite_room_id, sender)
logger.info("Invited %s to %s: %s", sender, invite_room_id, resp)
invited_count += 1
except Exception as e:
logger.error("Failed to invite %s to %s: %s", sender, invite_room_id, e)
# Mark user as welcomed
welcomed = state.get("welcomed_users", [])
if sender not in welcomed:
welcomed.append(sender)
state["welcomed_users"] = welcomed
# Remove the DM message entry (one-time use)
del dm_messages[reacted_event_id]
state["dm_welcome_messages"] = dm_messages
_save_state(state)
# Confirm in DM
from utils import send_text
if invited_count > 0:
await send_text(client, room_id, f"You've been invited to {invited_count} channel(s). Check your invites!")
else:
await send_text(client, room_id, "You're already in all the channels!")
async def post_welcome_message(client: AsyncClient):
"""No-op kept for backward compatibility with bot.py startup."""
logger.info("Welcome module ready — watching Space for new members")

822
wordle.py
View File

@@ -1,822 +0,0 @@
"""Wordle game for Matrix bot.
Full implementation with daily puzzles, statistics tracking,
hard mode, shareable results, and rich HTML rendering.
"""
import json
import logging
import time
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
import aiohttp
from nio import AsyncClient
from utils import send_text, send_html, get_or_create_dm
from config import BOT_PREFIX
from wordlist_answers import ANSWERS
from wordlist_valid import VALID_GUESSES
logger = logging.getLogger("matrixbot")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_WORDLE_EPOCH = date(2021, 6, 19)
# Cache: date string -> (word, puzzle_number)
_nyt_cache: dict[str, tuple[str, int]] = {}
# Build lookup sets at import time
_ANSWER_LIST = [w.upper() for w in ANSWERS]
_VALID_SET = frozenset(w.upper() for w in VALID_GUESSES) | frozenset(_ANSWER_LIST)
# Tile colors (Wordle official palette)
_TILE = {
2: {"bg": "#538d4e", "label": "correct"}, # Green
1: {"bg": "#b59f3b", "label": "present"}, # Yellow
0: {"bg": "#3a3a3c", "label": "absent"}, # Gray
}
_EMPTY_BG = "#121213"
_EMPTY_BORDER = "#3a3a3c"
# Emoji squares for plain-text fallback & share
_EMOJI = {2: "\U0001f7e9", 1: "\U0001f7e8", 0: "\u2b1b"}
# Keyboard layout
_KB_ROWS = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"]
# Stats file
STATS_FILE = Path("wordle_stats.json")
# Congratulations messages by guess number
_CONGRATS = {
1: "Genius!",
2: "Magnificent!",
3: "Impressive!",
4: "Splendid!",
5: "Great!",
6: "Phew!",
}
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class WordleGame:
player_id: str
room_id: str
target: str
guesses: list = field(default_factory=list)
results: list = field(default_factory=list)
hard_mode: bool = False
daily_number: int = 0
started_at: float = field(default_factory=time.time)
finished: bool = False
won: bool = False
origin_room_id: str = "" # Public room where game was started (for share)
# Module-level state
_active_games: dict[str, WordleGame] = {}
_all_stats: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# Stats persistence
# ---------------------------------------------------------------------------
def _load_stats():
global _all_stats
if STATS_FILE.exists():
try:
_all_stats = json.loads(STATS_FILE.read_text())
except (json.JSONDecodeError, OSError) as e:
logger.error("Failed to load wordle stats: %s", e)
_all_stats = {}
else:
_all_stats = {}
def _save_stats():
try:
tmp = STATS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_all_stats, indent=2))
tmp.rename(STATS_FILE)
except OSError as e:
logger.error("Failed to save wordle stats: %s", e)
def _get_player_stats(player_id: str) -> dict:
if player_id not in _all_stats:
_all_stats[player_id] = {
"games_played": 0,
"games_won": 0,
"current_streak": 0,
"max_streak": 0,
"guess_distribution": {str(i): 0 for i in range(1, 7)},
"last_daily": -1,
"hard_mode": False,
"last_daily_result": None,
"last_daily_guesses": None,
}
return _all_stats[player_id]
def _record_game_result(player_id: str, game: WordleGame):
stats = _get_player_stats(player_id)
stats["games_played"] += 1
if game.won:
stats["games_won"] += 1
stats["current_streak"] += 1
stats["max_streak"] = max(stats["max_streak"], stats["current_streak"])
num_guesses = str(len(game.guesses))
stats["guess_distribution"][num_guesses] = (
stats["guess_distribution"].get(num_guesses, 0) + 1
)
else:
stats["current_streak"] = 0
stats["last_daily"] = game.daily_number
stats["last_daily_result"] = game.results
stats["last_daily_guesses"] = game.guesses
stats["last_daily_won"] = game.won
stats["last_daily_hard"] = game.hard_mode
stats["last_origin_room"] = game.origin_room_id
_save_stats()
# ---------------------------------------------------------------------------
# Core algorithms
# ---------------------------------------------------------------------------
async def get_daily_word() -> tuple[str, int]:
"""Return (word, puzzle_number) for today's daily puzzle.
Fetches from the NYT Wordle API. Falls back to the local word list
if the request fails.
"""
today = date.today()
date_str = today.strftime("%Y-%m-%d")
if date_str in _nyt_cache:
return _nyt_cache[date_str]
try:
url = f"https://www.nytimes.com/svc/wordle/v2/{date_str}.json"
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
word = data["solution"].upper()
puzzle_number = int(data["id"])
_nyt_cache[date_str] = (word, puzzle_number)
logger.info("NYT Wordle #%d: %s", puzzle_number, word)
return word, puzzle_number
else:
logger.warning("NYT Wordle API returned %d, falling back to local list", resp.status)
except Exception as e:
logger.warning("Failed to fetch NYT Wordle word: %s — falling back to local list", e)
# Fallback: use local answer list
puzzle_number = (today - _WORDLE_EPOCH).days
word = _ANSWER_LIST[puzzle_number % len(_ANSWER_LIST)]
return word, puzzle_number
def evaluate_guess(guess: str, target: str) -> list[int]:
"""Evaluate a guess against the target. Returns list of 5 scores:
2 = correct position (green), 1 = wrong position (yellow), 0 = absent (gray).
Handles duplicate letters correctly with a two-pass approach.
"""
result = [0] * 5
target_remaining = list(target)
# Pass 1: mark exact matches (green)
for i in range(5):
if guess[i] == target[i]:
result[i] = 2
target_remaining[i] = None
# Pass 2: mark present-but-wrong-position (yellow)
for i in range(5):
if result[i] == 2:
continue
if guess[i] in target_remaining:
result[i] = 1
target_remaining[target_remaining.index(guess[i])] = None
return result
def validate_hard_mode(
guess: str,
previous_guesses: list[str],
previous_results: list[list[int]],
) -> str | None:
"""Return None if valid, or an error message if hard mode violated."""
for prev_guess, prev_result in zip(previous_guesses, previous_results):
for i, (letter, score) in enumerate(zip(prev_guess, prev_result)):
if score == 2 and guess[i] != letter:
return (
f"Hard mode: position {i + 1} must be "
f"'{letter}' (green from previous guess)"
)
if score == 1 and letter not in guess:
return (
f"Hard mode: guess must contain "
f"'{letter}' (yellow from previous guess)"
)
return None
# ---------------------------------------------------------------------------
# HTML rendering
# ---------------------------------------------------------------------------
def _tile_span(letter: str, bg: str) -> str:
"""Render a single letter tile using Matrix-compatible attributes."""
return (
f'<font data-mx-bg-color="{bg}" data-mx-color="#ffffff">'
f"<b>\u00a0{letter}\u00a0</b></font>"
)
def render_grid_html(game: WordleGame) -> str:
"""Render the Wordle grid as inline spans (compatible with Cinny)."""
rows = []
for row_idx in range(6):
tiles = []
if row_idx < len(game.guesses):
guess = game.guesses[row_idx]
result = game.results[row_idx]
for letter, score in zip(guess, result):
bg = _TILE[score]["bg"]
tiles.append(_tile_span(letter, bg))
else:
for _ in range(5):
tiles.append(_tile_span("\u00a0", _EMPTY_BG))
rows.append("".join(tiles))
return "<br>".join(rows)
def render_keyboard_html(game: WordleGame) -> str:
"""Render a virtual keyboard showing letter states."""
letter_states: dict[str, int] = {}
for guess, result in zip(game.guesses, game.results):
for letter, score in zip(guess, result):
letter_states[letter] = max(letter_states.get(letter, -1), score)
kb_rows = []
for row in _KB_ROWS:
keys = []
for letter in row:
state = letter_states.get(letter, -1)
if state == -1:
bg, color = "#818384", "#ffffff"
elif state == 0:
bg, color = "#3a3a3c", "#555555"
elif state == 1:
bg, color = "#b59f3b", "#ffffff"
else:
bg, color = "#538d4e", "#ffffff"
keys.append(
f'<font data-mx-bg-color="{bg}" data-mx-color="{color}">'
f"{letter}</font>"
)
kb_rows.append(" ".join(keys))
return "<br>" + "<br>".join(kb_rows)
def render_grid_plain(game: WordleGame) -> str:
"""Plain text grid with emoji squares and letter markers."""
_marker = {2: "!", 1: "?", 0: "."} # ! = correct, ? = wrong spot, . = absent
lines = []
for guess, result in zip(game.guesses, game.results):
emoji_row = "".join(_EMOJI[s] for s in result)
# Show each letter with a marker: [C!] = correct, [R?] = wrong spot, [A.] = absent
marked = " ".join(f"{letter}{_marker[score]}" for letter, score in zip(guess, result))
lines.append(f"{emoji_row} {marked}")
return "\n".join(lines)
def render_keyboard_plain(game: WordleGame) -> str:
"""Plain text keyboard status."""
letter_states: dict[str, int] = {}
for guess, result in zip(game.guesses, game.results):
for letter, score in zip(guess, result):
letter_states[letter] = max(letter_states.get(letter, -1), score)
lines = []
symbols = {-1: " ", 0: "\u2717", 1: "?", 2: "\u2713"}
for row in _KB_ROWS:
chars = []
for letter in row:
state = letter_states.get(letter, -1)
if state == 0:
chars.append("\u00b7") # dimmed
elif state >= 1:
chars.append(letter)
else:
chars.append(letter.lower())
lines.append(" ".join(chars))
return "\n".join(lines)
def render_stats_html(stats: dict) -> str:
"""Render player statistics as HTML (Matrix-compatible)."""
played = stats["games_played"]
won = stats["games_won"]
win_pct = (won / max(played, 1)) * 100
streak = stats["current_streak"]
max_streak = stats["max_streak"]
dist = stats["guess_distribution"]
max_count = max((int(v) for v in dist.values()), default=1) or 1
html = "<strong>Wordle Statistics</strong><br><br>"
html += (
f"<b>{played}</b> Played | "
f"<b>{win_pct:.0f}%</b> Win | "
f"<b>{streak}</b> Streak | "
f"<b>{max_streak}</b> Best<br><br>"
)
html += "<strong>Guess Distribution</strong><br>"
for i in range(1, 7):
count = int(dist.get(str(i), 0))
is_max = count == max_count and count > 0
bar_len = max(int((count / max_count) * 10), 1) if max_count > 0 else 1
bar = "\u2588" * bar_len # Block character for bar
if is_max and count > 0:
html += f'{i} <font data-mx-bg-color="#538d4e" data-mx-color="#ffffff"> {bar} {count} </font><br>'
else:
html += f'{i} <font data-mx-bg-color="#3a3a3c" data-mx-color="#ffffff"> {bar} {count} </font><br>'
return html
def render_stats_plain(stats: dict) -> str:
"""Plain text stats."""
played = stats["games_played"]
won = stats["games_won"]
win_pct = (won / max(played, 1)) * 100
streak = stats["current_streak"]
max_streak = stats["max_streak"]
dist = stats["guess_distribution"]
max_count = max((int(v) for v in dist.values()), default=1) or 1
lines = [
"Wordle Statistics",
f"Played: {played} | Win: {win_pct:.0f}% | Streak: {streak} | Max: {max_streak}",
"",
"Guess Distribution:",
]
for i in range(1, 7):
count = int(dist.get(str(i), 0))
bar_len = max(round((count / max_count) * 16), 1) if count > 0 else 0
bar = "\u2588" * bar_len
lines.append(f" {i}: {bar} {count}")
return "\n".join(lines)
def generate_share(stats: dict) -> str:
"""Generate the shareable emoji grid from last completed daily."""
results = stats.get("last_daily_result")
if not results:
return ""
won = stats.get("last_daily_won", False)
hard = stats.get("last_daily_hard", False)
daily_num = stats.get("last_daily", 0)
score = str(len(results)) if won else "X"
mode = "*" if hard else ""
header = f"Wordle {daily_num} {score}/6{mode}\n\n"
rows = []
for result in results:
rows.append("".join(_EMOJI[s] for s in result))
return header + "\n".join(rows)
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
async def wordle_help(client: AsyncClient, room_id: str):
"""Show help text with rules and commands."""
p = BOT_PREFIX
plain = (
f"Wordle - Guess the 5-letter word in 6 tries!\n\n"
f"Commands:\n"
f" {p}wordle Start today's daily puzzle (or show current game)\n"
f" {p}wordle <word> Submit a 5-letter guess\n"
f" {p}wordle stats View your statistics\n"
f" {p}wordle hard Toggle hard mode\n"
f" {p}wordle share Share your last daily result\n"
f" {p}wordle give up Forfeit current game\n"
f" {p}wordle help Show this help\n\n"
f"Rules:\n"
f" - Each guess must be a valid 5-letter English word\n"
f" - Green = correct letter, correct position\n"
f" - Yellow = correct letter, wrong position\n"
f" - Gray = letter not in the word\n"
f" - Hard mode: must use all revealed hints in subsequent guesses\n"
f" - Everyone gets the same daily word!"
)
html = (
"<h4>Wordle</h4>"
"<p>Guess the 5-letter word in 6 tries!</p>"
"<strong>Commands:</strong>"
"<ul>"
f"<li><code>{p}wordle</code> — Start today's daily puzzle</li>"
f"<li><code>{p}wordle &lt;word&gt;</code> — Submit a guess</li>"
f"<li><code>{p}wordle stats</code> — View your statistics</li>"
f"<li><code>{p}wordle hard</code> — Toggle hard mode</li>"
f"<li><code>{p}wordle share</code> — Share your last result</li>"
f"<li><code>{p}wordle give up</code> — Forfeit current game</li>"
"</ul>"
"<strong>How to play:</strong>"
"<ul>"
'<li><font data-mx-bg-color="#538d4e" data-mx-color="#ffffff"><b> G </b></font> '
"Green = correct letter, correct position</li>"
'<li><font data-mx-bg-color="#b59f3b" data-mx-color="#ffffff"><b> Y </b></font> '
"Yellow = correct letter, wrong position</li>"
'<li><font data-mx-bg-color="#3a3a3c" data-mx-color="#ffffff"><b> X </b></font> '
"Gray = letter not in the word</li>"
"</ul>"
"<p><em>Hard mode:</em> You must use all revealed hints in subsequent guesses.</p>"
"<p>Everyone gets the same daily word!</p>"
)
await send_html(client, room_id, plain, html)
async def wordle_start_or_status(client: AsyncClient, room_id: str, sender: str, origin_room_id: str = ""):
"""Start a new daily game or show current game status."""
# Check for active game
if sender in _active_games:
game = _active_games[sender]
if not game.finished:
guesses_left = 6 - len(game.guesses)
grid_plain = render_grid_plain(game)
kb_plain = render_keyboard_plain(game)
plain = (
f"Wordle {game.daily_number}"
f"Guess {len(game.guesses) + 1}/6\n\n"
f"{grid_plain}\n\n{kb_plain}"
)
grid_html = render_grid_html(game)
kb_html = render_keyboard_html(game)
mode = " (Hard Mode)" if game.hard_mode else ""
html = (
f''
f"<strong>Wordle {game.daily_number}{mode}</strong> — "
f"Guess {len(game.guesses) + 1}/6<br><br>"
f"{grid_html}{kb_html}"
)
await send_html(client, room_id, plain, html)
return
# Check if already completed today's puzzle
word, puzzle_number = await get_daily_word()
stats = _get_player_stats(sender)
if stats["last_daily"] == puzzle_number:
await send_text(
client, room_id,
f"You already completed today's Wordle (#{puzzle_number})! "
f"Use {BOT_PREFIX}wordle stats to see your results "
f"or {BOT_PREFIX}wordle share to share them."
)
return
# Start new game
hard_mode = stats.get("hard_mode", False)
game = WordleGame(
player_id=sender,
room_id=room_id,
target=word,
hard_mode=hard_mode,
daily_number=puzzle_number,
origin_room_id=origin_room_id or room_id,
)
_active_games[sender] = game
mode_str = " (Hard Mode)" if hard_mode else ""
grid_html = render_grid_html(game)
kb_html = render_keyboard_html(game)
plain = (
f"Wordle #{puzzle_number}{mode_str}\n"
f"Guess a 5-letter word! You have 6 attempts.\n"
f"Type {BOT_PREFIX}wordle <word> to guess."
)
html = (
f''
f"<strong>Wordle #{puzzle_number}{mode_str}</strong><br>"
f"Guess a 5-letter word! You have 6 attempts.<br>"
f"Type <code>{BOT_PREFIX}wordle &lt;word&gt;</code> to guess."
f"<br><br>{grid_html}{kb_html}"
)
await send_html(client, room_id, plain, html)
async def wordle_guess(
client: AsyncClient, room_id: str, sender: str, guess: str
):
"""Process a guess."""
if sender not in _active_games:
await send_text(
client, room_id,
f"No active game. Start one with {BOT_PREFIX}wordle"
)
return
game = _active_games[sender]
if game.finished:
await send_text(
client, room_id,
f"Your game is already finished! "
f"Use {BOT_PREFIX}wordle to start a new daily puzzle."
)
return
# Validate word
if guess not in _VALID_SET:
await send_text(client, room_id, f"'{guess.lower()}' is not in the word list. Try again.")
return
# Hard mode validation
if game.hard_mode and game.guesses:
violation = validate_hard_mode(guess, game.guesses, game.results)
if violation:
await send_text(client, room_id, violation)
return
# Evaluate
result = evaluate_guess(guess, game.target)
game.guesses.append(guess)
game.results.append(result)
# Check win
if all(s == 2 for s in result):
game.finished = True
game.won = True
origin = game.origin_room_id
_record_game_result(sender, game)
del _active_games[sender]
num = len(game.guesses)
congrats = _CONGRATS.get(num, "Nice!")
grid_plain = render_grid_plain(game)
plain = (
f"{congrats} Wordle {game.daily_number} {num}/6"
f"{'*' if game.hard_mode else ''}\n\n"
f"{grid_plain}"
)
grid_html = render_grid_html(game)
mode = "*" if game.hard_mode else ""
html = (
f''
f"<strong>{congrats}</strong> "
f"Wordle {game.daily_number} {num}/6{mode}<br><br>"
f"{grid_html}"
)
await send_html(client, room_id, plain, html)
if origin and origin != room_id:
await wordle_share(client, origin, sender)
return
# Check loss (6 guesses used)
if len(game.guesses) >= 6:
game.finished = True
game.won = False
origin = game.origin_room_id
_record_game_result(sender, game)
del _active_games[sender]
grid_plain = render_grid_plain(game)
plain = (
f"Wordle {game.daily_number} X/6"
f"{'*' if game.hard_mode else ''}\n\n"
f"{grid_plain}\n\n"
f"The word was: {game.target}\n"
f"Better luck tomorrow!"
)
grid_html = render_grid_html(game)
mode = "*" if game.hard_mode else ""
html = (
f''
f"<strong>Wordle {game.daily_number} X/6{mode}</strong><br><br>"
f"{grid_html}<br>"
f'The word was: <font data-mx-color="#538d4e"><strong>'
f"{game.target}</strong></font><br>"
f"<em>Better luck tomorrow!</em>"
)
await send_html(client, room_id, plain, html)
if origin and origin != room_id:
await wordle_share(client, origin, sender)
return
# Still playing — show grid + keyboard
guesses_left = 6 - len(game.guesses)
grid_plain = render_grid_plain(game)
kb_plain = render_keyboard_plain(game)
plain = (
f"Wordle {game.daily_number}"
f"Guess {len(game.guesses) + 1}/6\n\n"
f"{grid_plain}\n\n{kb_plain}"
)
grid_html = render_grid_html(game)
kb_html = render_keyboard_html(game)
mode = " (Hard Mode)" if game.hard_mode else ""
html = (
f''
f"<strong>Wordle {game.daily_number}{mode}</strong> — "
f"Guess {len(game.guesses) + 1}/6<br><br>"
f"{grid_html}{kb_html}"
)
await send_html(client, room_id, plain, html)
async def wordle_stats(client: AsyncClient, room_id: str, sender: str):
"""Show player statistics."""
stats = _get_player_stats(sender)
if stats["games_played"] == 0:
await send_text(
client, room_id,
f"No Wordle stats yet! Start a game with {BOT_PREFIX}wordle"
)
return
plain = render_stats_plain(stats)
html = render_stats_html(stats)
await send_html(client, room_id, plain, html)
async def wordle_toggle_hard(client: AsyncClient, room_id: str, sender: str):
"""Toggle hard mode for the player."""
stats = _get_player_stats(sender)
new_mode = not stats.get("hard_mode", False)
stats["hard_mode"] = new_mode
_save_stats()
# Also update active game if one exists
if sender in _active_games:
game = _active_games[sender]
if not game.guesses:
# Only allow toggling before first guess
game.hard_mode = new_mode
elif new_mode:
await send_text(
client, room_id,
"Hard mode enabled for future games. "
"Cannot enable mid-game after guessing."
)
return
else:
game.hard_mode = False
status = "enabled" if new_mode else "disabled"
plain = (
f"Hard mode {status}. "
+ ("You must use all revealed hints in subsequent guesses."
if new_mode else "Standard rules apply.")
)
await send_text(client, room_id, plain)
async def wordle_share(client: AsyncClient, room_id: str, sender: str):
"""Share the last completed daily result."""
stats = _get_player_stats(sender)
share_text = generate_share(stats)
if not share_text:
await send_text(
client, room_id,
f"No completed daily puzzle to share. Play one with {BOT_PREFIX}wordle"
)
return
await send_text(client, room_id, share_text)
async def wordle_give_up(client: AsyncClient, room_id: str, sender: str):
"""Forfeit the current game."""
if sender not in _active_games:
await send_text(
client, room_id,
f"No active game to give up. Start one with {BOT_PREFIX}wordle"
)
return
game = _active_games[sender]
if game.finished:
del _active_games[sender]
return
game.finished = True
game.won = False
origin = game.origin_room_id
_record_game_result(sender, game)
del _active_games[sender]
grid_plain = render_grid_plain(game)
plain = (
f"Game over! The word was: {game.target}\n\n"
f"{grid_plain}\n\n"
f"Better luck tomorrow!"
)
grid_html = render_grid_html(game)
html = (
f''
f"<strong>Game Over</strong><br><br>"
f"{grid_html}<br>"
f'The word was: <font data-mx-color="#538d4e"><strong>'
f"{game.target}</strong></font><br>"
f"<em>Better luck tomorrow!</em>"
)
await send_html(client, room_id, plain, html)
if origin and origin != room_id:
await wordle_share(client, origin, sender)
# ---------------------------------------------------------------------------
# Main router
# ---------------------------------------------------------------------------
async def _get_dm_room(client: AsyncClient, room_id: str, sender: str) -> tuple[str, str]:
"""Get or create DM room for the sender. Returns (dm_room_id, origin_room_id).
If already in a DM, returns (room_id, stored_origin or room_id).
If in a public room, creates/finds DM and returns (dm_room_id, room_id).
"""
# Check if this is already a DM (2 members)
room = client.rooms.get(room_id)
if room and room.member_count == 2:
# Already in DM — use origin from active game if available
game = _active_games.get(sender)
origin = game.origin_room_id if game and game.origin_room_id else room_id
return room_id, origin
# Public room — find/create DM
dm_room = await get_or_create_dm(client, sender)
if dm_room:
return dm_room, room_id
# Fallback to public room if DM creation fails
logger.warning("Could not create DM with %s, falling back to public room", sender)
return room_id, room_id
async def handle_wordle(
client: AsyncClient, room_id: str, sender: str, args: str
):
"""Main entry point — dispatches to subcommands."""
parts = args.strip().split(None, 1)
subcmd = parts[0].lower() if parts else ""
sub_args = parts[1] if len(parts) > 1 else ""
# Share always goes to the public room
if subcmd == "share":
await wordle_share(client, room_id, sender)
return
# All other commands route through DM
dm_room, origin = await _get_dm_room(client, room_id, sender)
# Silently redirect to DM — origin room will get an auto-share when the game ends
if subcmd == "help":
await wordle_help(client, dm_room)
elif subcmd == "stats":
await wordle_stats(client, dm_room, sender)
elif subcmd == "hard":
await wordle_toggle_hard(client, dm_room, sender)
elif subcmd == "give" and sub_args.lower().startswith("up"):
await wordle_give_up(client, dm_room, sender)
elif subcmd == "":
await wordle_start_or_status(client, dm_room, sender, origin)
elif len(subcmd) == 5 and subcmd.isalpha():
await wordle_guess(client, dm_room, sender, subcmd.upper())
else:
await send_text(
client, room_id,
f"Invalid wordle command or guess. "
f"Guesses must be exactly 5 letters. "
f"Try {BOT_PREFIX}wordle help"
)
# ---------------------------------------------------------------------------
# Load stats on module import
# ---------------------------------------------------------------------------
_load_stats()

View File

@@ -1,411 +0,0 @@
# Curated list of 2,309 five-letter words used as Wordle daily answers.
# Common, well-known English words only.
ANSWERS = [
"aback", "abase", "abate", "abbey", "abbot",
"abhor", "abide", "abled", "abode", "abort",
"about", "above", "abuse", "abyss", "acidy",
"acorn", "acrid", "actor", "acute", "adage",
"adapt", "adept", "admin", "admit", "adobe",
"adopt", "adore", "adorn", "adult", "aegis",
"afoot", "afoul", "after", "again", "agent",
"agile", "aging", "aglow", "agony", "agree",
"ahead", "aider", "aisle", "alarm", "album",
"alert", "algae", "alibi", "alien", "align",
"alike", "alive", "allay", "alley", "allot",
"allow", "alloy", "aloft", "alone", "along",
"aloof", "aloud", "alpha", "altar", "alter",
"amass", "amaze", "amber", "amble", "amend",
"amine", "amino", "amiss", "amity", "among",
"ample", "amply", "amuse", "angel", "anger",
"angle", "angry", "angst", "anime", "ankle",
"annex", "annoy", "annul", "anode", "antic",
"anvil", "aorta", "apart", "aphid", "aping",
"apnea", "apple", "apply", "apron", "aptly",
"arbor", "ardor", "arena", "argue", "arise",
"armor", "aroma", "arose", "array", "arrow",
"arson", "artsy", "ascot", "ashen", "aside",
"askew", "assay", "asset", "atoll", "atone",
"attic", "audio", "audit", "augur", "aunty",
"avian", "avoid", "await", "awake", "award",
"aware", "awash", "awful", "awoke", "axial",
"axiom", "azure", "bacon", "badge", "badly",
"bagel", "baggy", "baker", "balmy", "banal",
"banjo", "barge", "baron", "basal", "basic",
"basil", "basin", "basis", "baste", "batch",
"bathe", "baton", "batty", "bawdy", "bayou",
"beach", "beady", "beard", "beast", "beech",
"beefy", "befit", "began", "begat", "beget",
"begin", "begun", "being", "belch", "belly",
"below", "bench", "beret", "berry", "berth",
"beset", "betel", "bevel", "bible", "bicep",
"biddy", "bigot", "bilge", "billy", "binge",
"bingo", "biome", "birch", "birth", "black",
"blade", "blame", "bland", "blank", "blare",
"blast", "blaze", "bleak", "bleat", "bleed",
"blend", "bless", "blimp", "blind", "blini",
"bliss", "blitz", "bloat", "block", "bloke",
"blond", "blood", "bloom", "blown", "blues",
"bluff", "blunt", "blurb", "blurt", "blush",
"board", "boast", "bobby", "boney", "bonus",
"booby", "boost", "booth", "booty", "booze",
"boozy", "borax", "borne", "bosom", "bossy",
"botch", "bound", "bowel", "boxer", "brace",
"braid", "brain", "brake", "brand", "brash",
"brass", "brave", "bravo", "brawl", "brawn",
"bread", "break", "breed", "briar", "bribe",
"brick", "bride", "brief", "brine", "bring",
"brink", "briny", "brisk", "broad", "broil",
"broke", "brood", "brook", "broth", "brown",
"brush", "brunt", "brute", "buddy", "budge",
"buggy", "bugle", "build", "built", "bulge",
"bulky", "bully", "bunch", "bunny", "burly",
"burnt", "burst", "bushy", "butch", "butte",
"buyer", "bylaw", "cabal", "cabin", "cable",
"cadet", "camel", "cameo", "canal", "candy",
"canny", "canoe", "caper", "caput", "carat",
"cargo", "carol", "carry", "carve", "caste",
"catch", "cater", "cause", "cavil", "cease",
"cedar", "chain", "chair", "chalk", "champ",
"chant", "chaos", "chard", "charm", "chart",
"chase", "chasm", "cheap", "cheat", "check",
"cheek", "cheer", "chess", "chest", "chick",
"chide", "chief", "child", "chili", "chill",
"chime", "china", "chirp", "chock", "choir",
"choke", "chord", "chore", "chose", "chuck",
"chump", "chunk", "churn", "chute", "cider",
"cigar", "cinch", "civic", "civil", "claim",
"clamp", "clang", "clank", "clash", "clasp",
"class", "clean", "clear", "clerk", "click",
"cliff", "climb", "cling", "cloak", "clock",
"clone", "close", "cloth", "cloud", "clout",
"clown", "cluck", "clued", "clump", "clung",
"coach", "coast", "cobra", "cocoa", "colon",
"color", "comet", "comic", "comma", "conch",
"condo", "coney", "coral", "corny", "couch",
"could", "count", "coupe", "court", "cover",
"covet", "crack", "craft", "cramp", "crane",
"crank", "crash", "crass", "crate", "crave",
"crawl", "craze", "crazy", "creak", "cream",
"credo", "creed", "creek", "creep", "crest",
"crick", "cried", "crime", "crimp", "crisp",
"croak", "crock", "crone", "crony", "crook",
"cross", "crowd", "crown", "crude", "cruel",
"crush", "crust", "crypt", "cubic", "cumin",
"cupid", "curly", "curry", "curse", "curve",
"curvy", "cutie", "cycle", "cynic", "daddy",
"daily", "dairy", "daisy", "dally", "dance",
"dandy", "datum", "daunt", "dealt", "death",
"debut", "decay", "decal", "decor", "decoy",
"decry", "defer", "deign", "deity", "delay",
"delta", "delve", "demon", "demur", "denim",
"dense", "depot", "depth", "derby", "deter",
"detox", "deuce", "devil", "diary", "dicey",
"digit", "dilly", "dimly", "diner", "dingo",
"dingy", "diode", "dirge", "dirty", "disco",
"ditch", "ditto", "ditty", "diver", "dizzy",
"dodge", "dodgy", "dogma", "doing", "dolly",
"donor", "donut", "dopey", "doubt", "dough",
"dowdy", "dowel", "draft", "drain", "drake",
"drama", "drank", "drape", "drawl", "drawn",
"dread", "dream", "dress", "dried", "drift",
"drill", "drink", "drive", "droit", "droll",
"drone", "drool", "droop", "dross", "drove",
"drown", "drugs", "drunk", "dryer", "dryly",
"duchy", "dully", "dummy", "dunce", "dusty",
"duvet", "dwarf", "dwell", "dwelt", "dying",
"eager", "eagle", "early", "earth", "easel",
"eaten", "eater", "ebony", "eclat", "edict",
"edify", "eerie", "egret", "eight", "elder",
"elect", "elite", "elope", "elude", "email",
"ember", "emcee", "empty", "enact", "endow",
"enemy", "enjoy", "ennui", "ensue", "enter",
"entry", "envoy", "epoch", "equal", "equip",
"erase", "erode", "error", "erupt", "essay",
"ester", "ether", "ethic", "ethos", "evade",
"event", "every", "evict", "evoke", "exact",
"exalt", "excel", "exert", "exile", "exist",
"expat", "expel", "extol", "extra", "exude",
"exult", "fable", "facet", "fairy", "faith",
"false", "fancy", "fanny", "farce", "fatal",
"fatty", "fault", "fauna", "feast", "feign",
"feint", "fella", "felon", "femur", "fence",
"feral", "ferry", "fetal", "fetch", "fetid",
"fetus", "fever", "fiber", "fibre", "field",
"fiend", "fiery", "fifth", "fifty", "fight",
"filly", "filmy", "filth", "final", "finch",
"fishy", "fixer", "fizzy", "fjord", "flack",
"flail", "flair", "flake", "flaky", "flame",
"flank", "flare", "flash", "flask", "fleet",
"flesh", "flick", "flier", "fling", "flint",
"flirt", "float", "flock", "flood", "floor",
"flora", "floss", "flour", "flout", "flown",
"fluid", "fluke", "flung", "flunk", "flush",
"flute", "foamy", "focal", "focus", "foggy",
"folly", "foray", "force", "forge", "forgo",
"forte", "forth", "forty", "forum", "found",
"foyer", "frail", "frame", "frank", "fraud",
"freak", "freed", "fresh", "friar", "fried",
"frill", "frisk", "fritz", "frock", "frond",
"front", "frost", "frown", "froze", "fruit",
"frump", "fully", "fungi", "funky", "funny",
"furry", "fussy", "fuzzy", "gamma", "gamut",
"gassy", "gaudy", "gauge", "gaunt", "gauze",
"gavel", "gawky", "geeky", "genie", "genre",
"ghost", "giant", "giddy", "girth", "given",
"giver", "gland", "glare", "glass", "glaze",
"gleam", "glean", "glide", "glint", "gloat",
"globe", "gloom", "glory", "gloss", "glove",
"glyph", "gnash", "gnome", "godly", "going",
"golem", "golly", "gonad", "goner", "goody",
"gooey", "goofy", "goose", "gorge", "gouge",
"gourd", "grace", "grade", "graft", "grail",
"grain", "grand", "grant", "grape", "graph",
"grasp", "grass", "grate", "grave", "gravy",
"graze", "great", "greed", "green", "greet",
"grief", "grill", "grime", "grimy", "grind",
"gripe", "groan", "groat", "groin", "groom",
"grope", "gross", "group", "grout", "grove",
"growl", "grown", "gruel", "gruff", "grump",
"grunt", "guard", "guava", "guess", "guest",
"guide", "guild", "guilt", "guise", "gulch",
"gully", "gumbo", "gummy", "guppy", "gusto",
"gusty", "habit", "hairy", "halve", "handy",
"happy", "hardy", "harem", "harpy", "harry",
"harsh", "haste", "hasty", "hatch", "haunt",
"haven", "hazel", "heady", "heart", "heath",
"heavy", "hedge", "hefty", "heist", "helix",
"hello", "hence", "heron", "hilly", "hinge",
"hippo", "hippy", "hitch", "hoard", "hobby",
"hoist", "holly", "homer", "honey", "honor",
"hooey", "horde", "horny", "horse", "hotel",
"hotly", "hound", "house", "hover", "howdy",
"human", "humid", "humor", "humus", "hunch",
"hunky", "hurry", "husky", "hussy", "hutch",
"hyena", "hymen", "hyper", "icily", "icing",
"ideal", "idiom", "idiot", "idyll", "igloo",
"image", "imbue", "impel", "imply", "inane",
"inbox", "incur", "index", "inept", "inert",
"infer", "ingot", "inlay", "inlet", "inner",
"input", "inter", "intro", "ionic", "irate",
"irony", "islet", "issue", "itchy", "ivory",
"jazzy", "jelly", "jenny", "jerky", "jewel",
"jiffy", "jimmy", "joker", "jolly", "joust",
"judge", "juice", "juicy", "jumbo", "jumpy",
"juror", "karma", "kayak", "kebab", "khaki",
"kinky", "kiosk", "kitty", "knack", "knead",
"kneel", "knelt", "knife", "knock", "knoll",
"known", "koala", "kudos", "label", "labor",
"laden", "ladle", "lager", "lance", "lanky",
"lapel", "lapse", "large", "larva", "latch",
"later", "lathe", "latte", "laugh", "layer",
"leach", "leafy", "leaky", "leapt", "learn",
"lease", "leash", "least", "leave", "ledge",
"leech", "legal", "leggy", "lemon", "lemur",
"level", "lever", "libel", "light", "liken",
"lilac", "limbo", "linen", "liner", "lingo",
"lipid", "liter", "lithe", "liver", "livid",
"llama", "lobby", "local", "locus", "lodge",
"lofty", "logic", "login", "loopy", "loose",
"lorry", "loser", "lousy", "lover", "lower",
"lowly", "loyal", "lucid", "lucky", "lumen",
"lumpy", "lunar", "lunch", "lunge", "lupus",
"lusty", "lying", "lynch", "lyric", "macaw",
"macho", "macro", "madam", "madly", "magic",
"magma", "maize", "major", "maker", "mambo",
"mamma", "mango", "mangy", "mania", "manic",
"manly", "manor", "maple", "march", "marry",
"marsh", "mason", "match", "matey", "maxim",
"maybe", "mayor", "mealy", "meant", "meaty",
"media", "medic", "melee", "melon", "mercy",
"merge", "merit", "merry", "metal", "meter",
"midst", "might", "milky", "mimic", "mince",
"minor", "minus", "mirth", "miser", "missy",
"mocha", "modal", "model", "modem", "mogul",
"moist", "molar", "moldy", "money", "month",
"moody", "moose", "moral", "morph", "mossy",
"motel", "motif", "motor", "motto", "moult",
"mound", "mount", "mourn", "mouse", "mousy",
"mouth", "mover", "movie", "mower", "mucus",
"muddy", "mulch", "mummy", "mural", "murky",
"mushy", "music", "musty", "myrrh", "nadir",
"naive", "nanny", "nasal", "nasty", "natal",
"naval", "navel", "needy", "nerve", "never",
"newer", "newly", "nexus", "nicer", "niche",
"night", "ninja", "ninny", "ninth", "noble",
"nobly", "noise", "noisy", "nomad", "noose",
"north", "notch", "noted", "novel", "nudge",
"nurse", "nutty", "nylon", "nymph", "oaken",
"oasis", "occur", "ocean", "octet", "oddly",
"offal", "offer", "often", "olive", "omega",
"onset", "opera", "opium", "optic", "orbit",
"order", "organ", "other", "otter", "ought",
"ounce", "outdo", "outer", "ovary", "ovate",
"overt", "ovoid", "owing", "owner", "oxide",
"ozone", "paddy", "pagan", "paint", "paler",
"palsy", "panel", "panic", "pansy", "papal",
"paper", "parch", "parka", "parry", "parse",
"party", "pasta", "paste", "pasty", "patch",
"patio", "patsy", "patty", "pause", "payee",
"peace", "peach", "pearl", "pecan", "pedal",
"penal", "pence", "penny", "peppy", "perch",
"peril", "perky", "pesky", "petal", "petty",
"phase", "phone", "photo", "piano", "picky",
"piece", "piety", "piggy", "pilot", "pinch",
"piney", "pious", "piper", "pipit", "pixel",
"pixie", "pizza", "place", "plaid", "plain",
"plait", "plane", "plank", "plant", "plate",
"plaza", "plead", "pleat", "plied", "plier",
"pluck", "plumb", "plume", "plump", "plunk",
"plush", "poesy", "point", "poise", "poker",
"polar", "polka", "polyp", "pooch", "poppy",
"porch", "poser", "posit", "posse", "pouch",
"poult", "pound", "pouty", "power", "prank",
"prawn", "preen", "press", "price", "prick",
"pride", "pried", "prime", "primo", "print",
"prior", "prism", "privy", "prize", "probe",
"prone", "prong", "proof", "prose", "proud",
"prove", "prowl", "prude", "prune", "psalm",
"pubic", "pudgy", "pulse", "punch", "pupil",
"puppy", "puree", "purge", "purse", "pushy",
"putty", "pygmy", "quack", "quaff", "quail",
"quake", "qualm", "quart", "quasi", "queen",
"queer", "query", "quest", "queue", "quick",
"quiet", "quill", "quirk", "quite", "quota",
"quote", "quoth", "rabbi", "rabid", "racer",
"radar", "radii", "radio", "radon", "rally",
"ramen", "ranch", "randy", "range", "rapid",
"rarer", "raspy", "ratio", "raven", "rayon",
"razor", "reach", "react", "ready", "realm",
"rebel", "rebus", "rebut", "recap", "recur",
"reedy", "refer", "regal", "rehab", "reign",
"relax", "relay", "relic", "remit", "renal",
"renew", "repay", "repel", "reply", "rerun",
"reset", "resin", "retch", "retro", "retry",
"reuse", "revel", "rider", "ridge", "rifle",
"right", "rigid", "rigor", "rinse", "ripen",
"riper", "risen", "risky", "rival", "river",
"rivet", "roach", "roast", "robin", "robot",
"rocky", "rogue", "roomy", "roost", "rouge",
"rough", "round", "rouse", "route", "rover",
"rowdy", "rower", "royal", "ruddy", "rugby",
"ruler", "rumba", "rumor", "rupee", "rural",
"rusty", "sadly", "safer", "saint", "salad",
"sally", "salon", "salsa", "salty", "salve",
"salvo", "sandy", "saner", "sappy", "sassy",
"sauce", "saucy", "sauna", "saute", "savor",
"savoy", "savvy", "scald", "scale", "scalp",
"scaly", "scamp", "scant", "scare", "scarf",
"scary", "scene", "scent", "scion", "scoff",
"scold", "scone", "scoop", "scope", "score",
"scorn", "scout", "scowl", "scram", "scrap",
"scrub", "scrum", "sedan", "seedy", "segue",
"seize", "sense", "sepia", "serve", "setup",
"seven", "sever", "sewer", "shack", "shade",
"shady", "shaft", "shake", "shaky", "shall",
"shame", "shank", "shape", "shard", "share",
"shark", "sharp", "shave", "shawl", "shear",
"sheen", "sheep", "sheer", "sheet", "shelf",
"shell", "shift", "shine", "shiny", "shire",
"shirk", "shirt", "shoal", "shock", "shone",
"shook", "shoot", "shore", "shorn", "short",
"shout", "shove", "shown", "showy", "shrub",
"shrug", "shuck", "shunt", "siege", "sieve",
"sight", "sigma", "silky", "silly", "since",
"sinew", "siren", "sissy", "sixth", "sixty",
"sized", "skate", "skier", "skimp", "skirt",
"skull", "skunk", "slack", "slain", "slang",
"slant", "slash", "slate", "slave", "sleek",
"sleep", "sleet", "slept", "slice", "slide",
"slime", "slimy", "sling", "slink", "slope",
"sloth", "slump", "slung", "slunk", "slurp",
"smack", "small", "smart", "smash", "smear",
"smell", "smelt", "smile", "smirk", "smite",
"smith", "smock", "smoke", "smoky", "snack",
"snail", "snake", "snaky", "snare", "snarl",
"sneak", "sneer", "snide", "sniff", "snipe",
"snoop", "snore", "snort", "snout", "snowy",
"snuck", "snuff", "soapy", "sober", "solar",
"solid", "solve", "sonic", "sooth", "sooty",
"sorry", "sound", "south", "space", "spade",
"spank", "spare", "spark", "spasm", "spawn",
"speak", "spear", "speck", "speed", "spell",
"spend", "spent", "spice", "spicy", "spied",
"spike", "spiky", "spill", "spine", "spite",
"splat", "split", "spoil", "spoke", "spoof",
"spook", "spool", "spoon", "spore", "sport",
"spout", "spray", "spree", "sprig", "spunk",
"spurn", "squad", "squat", "squid", "stack",
"staff", "stage", "staid", "stain", "stair",
"stake", "stale", "stalk", "stall", "stamp",
"stand", "stank", "staph", "stare", "stark",
"start", "stash", "state", "stave", "stead",
"steak", "steal", "steam", "steel", "steep",
"steer", "stern", "stick", "stiff", "still",
"stilt", "sting", "stink", "stint", "stock",
"stoic", "stoke", "stole", "stomp", "stone",
"stony", "stood", "stool", "stoop", "store",
"stork", "storm", "story", "stout", "stove",
"strap", "straw", "stray", "strip", "strew",
"stuck", "study", "stuff", "stump", "stung",
"stunk", "stunt", "style", "suave", "sugar",
"suing", "suite", "sulky", "sunny", "super",
"surge", "surly", "sushi", "swamp", "swarm",
"swath", "swear", "sweat", "sweep", "sweet",
"swell", "swept", "swift", "swill", "swine",
"swing", "swipe", "swirl", "swish", "swoon",
"swoop", "sword", "swore", "sworn", "swung",
"synod", "syrup", "tabby", "table", "taboo",
"tacit", "tacky", "taffy", "taint", "taken",
"taker", "talon", "tamer", "tango", "tangy",
"taper", "tapir", "tardy", "tarot", "taste",
"tasty", "tatty", "taunt", "tawny", "teach",
"teary", "tease", "teddy", "teeth", "tempo",
"tenet", "tenor", "tense", "tenth", "tepee",
"tepid", "terra", "terse", "theft", "their",
"theme", "there", "thick", "thief", "thigh",
"thing", "think", "third", "thorn", "those",
"three", "threw", "throb", "throw", "thrum",
"thumb", "thump", "thyme", "tiara", "tidal",
"tiger", "tight", "timer", "timid", "tipsy",
"titan", "title", "toast", "today", "token",
"tonal", "tongs", "tonic", "tooth", "topaz",
"topic", "torch", "torso", "total", "totem",
"touch", "tough", "towel", "tower", "toxic",
"trace", "track", "trade", "trail", "train",
"trait", "tramp", "trash", "trawl", "tread",
"treat", "trend", "triad", "trial", "tribe",
"trick", "tried", "trill", "trite", "troll",
"troop", "trope", "troth", "trout", "truce",
"truck", "truly", "trump", "trunk", "truss",
"trust", "truth", "tryst", "tulip", "tumor",
"tuner", "tunic", "turbo", "tutor", "twain",
"twang", "tweak", "tweed", "tweet", "twice",
"twill", "twine", "twist", "tying", "udder",
"ulcer", "ultra", "umbra", "uncle", "uncut",
"under", "undid", "undue", "unfed", "unfit",
"unify", "union", "unite", "unity", "unlit",
"unmet", "unset", "untie", "until", "unwed",
"unzip", "upper", "upset", "urban", "usage",
"usher", "using", "usual", "usurp", "utero",
"utter", "vague", "valid", "valor", "valve",
"vapid", "vault", "vaunt", "vegan", "venue",
"verge", "verse", "vigor", "villa", "vinyl",
"viola", "viper", "viral", "virus", "visor",
"vista", "vital", "vivid", "vixen", "vocal",
"vodka", "vogue", "voice", "voila", "voter",
"vouch", "vowel", "vulva", "wacky", "wafer",
"wager", "wagon", "waist", "waltz", "watch",
"water", "waver", "waxen", "weary", "weave",
"wedge", "weedy", "weigh", "weird", "welch",
"whale", "wheat", "wheel", "where", "which",
"while", "whiff", "whine", "whiny", "whirl",
"whisk", "white", "whole", "whose", "widen",
"wider", "widow", "width", "wield", "wince",
"winch", "windy", "wiper", "wiser", "witch",
"witty", "woken", "woman", "women", "world",
"worry", "worse", "worst", "worth", "would",
"wound", "wrack", "wrath", "wreak", "wreck",
"wrest", "wring", "wrist", "write", "wrong",
"wrote", "yacht", "yearn", "yeast", "yield",
"young", "youth", "zebra", "zesty", "zonal",
]

File diff suppressed because it is too large Load Diff