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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,7 +1,3 @@
|
|||||||
.env
|
|
||||||
nio_store/
|
|
||||||
logs/
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
credentials.json
|
.env
|
||||||
wordle_stats.json
|
|
||||||
|
|||||||
204
bot.py
204
bot.py
@@ -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())
|
|
||||||
114
callbacks.py
114
callbacks.py
@@ -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
17
cinny/config.json
Normal 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
59
cinny/dev-update.sh
Normal 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 ==="
|
||||||
630
commands.py
630
commands.py
@@ -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)
|
|
||||||
46
config.py
46
config.py
@@ -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
43
draupnir/production.yaml
Normal 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
11
hookshot/bazarr.js
Normal 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
54
hookshot/deploy.sh
Normal 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
32
hookshot/grafana.js
Normal 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
37
hookshot/lidarr.js
Normal 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
21
hookshot/owncast.js
Normal 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
18
hookshot/proxmox.js
Normal 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
35
hookshot/radarr.js
Normal 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
37
hookshot/readarr.js
Normal 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
29
hookshot/seerr.js
Normal 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
41
hookshot/sonarr.js
Normal 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' };
|
||||||
|
}
|
||||||
31
hookshot/tinker-tickets.js
Normal file
31
hookshot/tinker-tickets.js
Normal 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
18
hookshot/uptime-kuma.js
Normal 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
623
landing/index.html
Normal 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> — 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> — <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 — hosted for you, no setup required</span>
|
||||||
|
<span class="tag-row">
|
||||||
|
<span class="tag">Desktop & Web</span>
|
||||||
|
<span class="tag voice">Voice & 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 →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="clients-section">
|
||||||
|
<h3>Other Clients</h3>
|
||||||
|
|
||||||
|
<div class="client-group">
|
||||||
|
<p class="client-group-label">Mobile — iOS & 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 →</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 & Setup Guide</a>
|
||||||
|
<span>·</span>
|
||||||
|
<a href="https://wiki.lotusguild.org/en/Legal/privacy-policy" target="_blank" rel="noopener">Privacy Policy</a>
|
||||||
|
<span>·</span>
|
||||||
|
<a href="https://wiki.lotusguild.org/en/Legal/terms-of-service" target="_blank" rel="noopener">Terms of Service</a>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Powered by Matrix · E2E Encrypted</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
matrix-nio[e2e]
|
|
||||||
python-dotenv
|
|
||||||
aiohttp
|
|
||||||
markdown
|
|
||||||
mcrcon
|
|
||||||
1
systemd/cinny-dev-update.cron
Normal file
1
systemd/cinny-dev-update.cron
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0 3 * * * root /usr/local/bin/cinny-dev-update.sh
|
||||||
22
systemd/draupnir.service
Normal file
22
systemd/draupnir.service
Normal 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
|
||||||
3
systemd/livekit-clear-port.sh
Normal file
3
systemd/livekit-clear-port.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#\!/bin/bash
|
||||||
|
pkill -x livekit-server 2>/dev/null && sleep 1
|
||||||
|
exit 0
|
||||||
15
systemd/livekit-server.service
Normal file
15
systemd/livekit-server.service
Normal 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
132
utils.py
@@ -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
|
|
||||||
150
welcome.py
150
welcome.py
@@ -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
822
wordle.py
@@ -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 <word></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 <word></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()
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
2568
wordlist_valid.py
2568
wordlist_valid.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user