Add Wordle, welcome system, integrations, and update roadmap

- Add Wordle game engine with daily puzzles, hard mode, stats, and share
- Add welcome module (react-to-join onboarding, Space join DMs)
- Add Ollama LLM integration (!ask), Minecraft RCON whitelist (!minecraft)
- Add !trivia, !champion, !agent, !health commands
- Add DM routing for Wordle (games in DMs, share to public room)
- Update README: reflect Phase 4 completion, hookshot webhook setup,
  infrastructure migration (LXC 151/109 to large1), Spam and Stuff room,
  all 12 webhook connections with UUIDs and transform notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 10:29:36 -05:00
parent 5723ac3581
commit dff2f0e2b1
11 changed files with 4324 additions and 167 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ logs/
__pycache__/ __pycache__/
*.pyc *.pyc
credentials.json credentials.json
wordle_stats.json

344
README.md
View File

@@ -1,183 +1,241 @@
# Lotus Matrix Bot & Server Roadmap # Lotus Matrix Bot & Server Roadmap
Matrix bot and server improvements for the Lotus Guild homeserver (`matrix.lotusguild.org`). Matrix bot and server infrastructure for the Lotus Guild homeserver (`matrix.lotusguild.org`).
**Repo**: https://code.lotusguild.org/LotusGuild/matrixBot **Repo**: https://code.lotusguild.org/LotusGuild/matrixBot
## Status: Phase 1 Complete (Database + Voice/Video) ## Status: Phase 4 — Webhooks & Integrations
--- ---
## Priority Order (suggested) ## Priority Order
1. ~~PostgreSQL migration (SQLite will bottleneck everything else)~~ 1. ~~PostgreSQL migration~~
2. ~~TURN server (makes voice/video actually reliable)~~ 2. ~~TURN server~~
3. Custom Element Web (chat.lotusguild.org with branding) 3. ~~Room structure + space setup~~
4. Discord bridge (lets people transition gradually) 4. ~~Matrix bot (core + commands)~~
5. Custom emoji packs (makes it feel like home) 5. ~~LiveKit / Element Call~~
6. Room structure + space setup 6. ~~SSO / OIDC (Authelia)~~
7. Moderation bot 7. ~~Webhook integrations (hookshot)~~
8. Matrix bot 8. Discord bridge (lets people transition gradually)
9. Everything else 9. Custom Element Web (chat.lotusguild.org with branding)
10. Custom emoji packs
11. Moderation bot
12. Everything else
--- ---
## Infrastructure ## Infrastructure
| Service | Host | IP | LXC | Notes | | Service | Host | IP | LXC | Notes |
|---------|------|-----|-----|-------| |---------|------|----|-----|-------|
| Synapse | storage-01 | 10.10.10.29 | 151 | Homeserver + coturn | | Synapse | large1 | 10.10.10.29 | 151 | Homeserver + coturn + LiveKit + hookshot |
| PostgreSQL 17 | storage-01 | 10.10.10.44 | 109 | Synapse database backend | | PostgreSQL 17 | large1 | 10.10.10.2 | 109 | Synapse database backend |
| NPM | large1 | 10.10.10.27 | 139 | Reverse proxy for matrix.lotusguild.org | | NPM | compute-storage-01 | 10.10.10.27 | 139 | Reverse proxy + landing page |
| Authelia | compute-storage-01 | 10.10.10.36 | 167 | SSO/OIDC provider |
| LLDAP | large1 | 10.10.10.39 | 147 | LDAP user directory |
**Key paths on Synapse LXC (10.10.10.29):** **Key paths on Synapse LXC:**
- Synapse config: `/etc/matrix-synapse/homeserver.yaml` - Synapse config: `/etc/matrix-synapse/homeserver.yaml`
- Synapse venv: `/opt/venvs/matrix-synapse/`
- coturn config: `/etc/turnserver.conf` - coturn config: `/etc/turnserver.conf`
- Synapse admin UI: `/var/www/synapse-admin/` (nginx on :8080) - LiveKit config: `/etc/livekit/config.yaml`
- SQLite backup: `/var/lib/matrix-synapse/homeserver.db` - LiveKit service: `livekit-server.service`
- Hookshot: `/opt/hookshot/`, service: `matrix-hookshot.service`
- Hookshot config: `/opt/hookshot/config.yml`
- Hookshot registration: `/etc/matrix-synapse/hookshot-registration.yaml`
- Landing page: `/var/www/matrix-landing/index.html` (on NPM LXC 139)
- Bot: `/opt/matrixbot/`, service: `matrixbot.service`
**Port forwarding (router -> 10.10.10.29):** **Port forwarding (router 10.10.10.29):**
- TCP+UDP 3478 (TURN signaling) - TCP+UDP 3478 (TURN/STUN signaling)
- UDP 49152-65535 (media relay) - TCP+UDP 5349 (TURNS/TLS)
- TCP 7881 (LiveKit ICE TCP fallback)
- TCP+UDP 49152-65535 (TURN relay + LiveKit WebRTC media)
--- ---
## Server - Quality of Life ## Rooms (all v12)
- [x] Migrate from SQLite to PostgreSQL (critical for performance at any real scale)
- [x] Set up TURN/STUN server (coturn) for reliable voice/video calls behind NAT
- [x] Enable URL previews in Synapse
- [x] Increase upload size limit for media/GIFs (200MB)
- [x] Enable message search (full-text search with PostgreSQL backend)
- [x] Configure media retention policy (remote: 1yr, local: 3yr)
- [x] Set up sliding sync (native in Synapse, no proxy needed)
- [ ] Enable push notifications gateway for mobile clients
## Server - Hardening | Room | Room ID | Join Rule |
- [x] Rate limiting configuration in Synapse |------|---------|-----------|
- [ ] Federation allow/deny lists (decide if you want open federation or Lotus-only) | The Lotus Guild (Space) | `!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc` | public |
- [ ] E2EE by default for private rooms | General | `!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0` | public |
- [ ] Regular Synapse version updates | Commands | `!ou56mVZQ8ZB7AhDYPmBV5_BR28WMZ4x5zwZkPCqjq1s` | restricted (Space members) |
- [ ] Monitoring with Prometheus + Grafana (you already have both at 10.10.10.x) | Memes | `!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U` | public |
- [ ] Synapse worker mode if performance becomes an issue | Management | `!mEvR5fe3jMmzwd-FwNygD72OY_yu8H3UP_N-57oK7MI` | invite |
| Cool Kids | `!R7DT3QZHG9P8QQvX6zsZYxjkKgmUucxDz_n31qNrC94` | invite |
| Welcome | `!Y-wvNosuytqBOWampH9k-ta7bYXW7okqwBQ7PuRVBWE` | public |
| Spam and Stuff | `!GttT4QYd1wlGlkHU3qTmq_P3gbyYKKeSSN6R7TPcJHg` | invite, **no E2EE** (hookshot) |
## Server - Admin & Moderation **Power level roles (Cinny tags):**
- [ ] Set up Mjolnir or Draupnir (moderation bot for ban lists, spam protection) - 100: Owner (jared)
- [ ] Configure proper power levels per room (mimic Discord role hierarchy) - 50: The Nerdy Council (enhuynh, lonely)
- [ ] Invite-only registration flow (already have token-based registration) - 48: Panel of Geeks
- [ ] Set up room ACLs for federation control (block known-bad servers) - 35: Cool Kids
- [x] Synapse admin API dashboard (synapse-admin v0.11.1 at http://10.10.10.29:8080) - 0: Member
- [ ] Automated backups of Synapse database and media
## Bridging (Transition Period) ---
- [ ] Set up mautrix-discord bridge so messages flow between Discord and Matrix
- [ ] Bridge key channels (general, gaming, memes, etc.)
- [ ] Bridge voice channels if possible (experimental, may not be worth it)
- [ ] Puppet bridging so Discord users appear as Matrix users and vice versa
## Room Structure ## Webhook Integrations (matrix-hookshot 7.3.2)
- [ ] Create room directory matching Discord channel layout
- [ ] Set up a Space for "Lotus Guild" as the top-level container Generic webhooks bridged into **Spam and Stuff** via [matrix-hookshot](https://github.com/matrix-org/matrix-hookshot).
- [ ] Sub-spaces for categories (General, Gaming, Media, Admin, etc.) Each service gets its own virtual user (`@hookshot_<service>`) with a unique avatar.
- [ ] Welcome room with pinned onboarding instructions Webhook URL format: `https://matrix.lotusguild.org/webhook/<uuid>`
| Service | Webhook UUID | Notes |
|---------|-------------|-------|
| Grafana | `df4a1302-2d62-4a01-b858-fb56f4d3781a` | Unified alerting contact point |
| Proxmox | `9b3eafe5-7689-4011-addd-c466e524661d` | Notification system (8.1+) |
| Sonarr | `aeffc311-0686-42cb-9eeb-6757140c072e` | All event types |
| Radarr | `34913454-c1ac-4cda-82ea-924d4a9e60eb` | All event types |
| Readarr | `e57ab4f3-56e6-4dc4-8b30-2f4fd4bbeb0b` | All event types |
| Lidarr | `66ac6fdd-69f6-4f47-bb00-b7f6d84d7c1c` | All event types |
| Uptime Kuma | `1a02e890-bb25-42f1-99fe-bba6a19f1811` | Status change notifications |
| Seerr | `555185af-90a1-42ff-aed5-c344e11955cf` | Request/approval events |
| Owncast | `9993e911-c68b-4271-a178-c2d65ca88499` | STREAM_STARTED / STREAM_STOPPED |
| Bazarr | `470fb267-3436-4dd3-a70c-e6e8db1721be` | Subtitle events |
| Huntarr | `78af735b-7802-4e6c-987b-db22f8636ceb` | Hunt/search events |
| Tinker-Tickets | `6e306faf-8eea-4ba5-83ef-bf8f421f929e` | Custom code needed |
**Hookshot notes:**
- Spam and Stuff is intentionally **unencrypted** — hookshot bridges cannot join E2EE rooms
- Webhook tokens stored in Synapse PostgreSQL `room_account_data` for `@hookshot`
- JS transformation functions use hookshot v2 API: set `result = { version: "v2", plain, html, msgtype }`
- The `result` variable must be assigned without `var`/`let`/`const` (needs implicit global scope in the QuickJS IIFE sandbox)
- NPM proxies `https://matrix.lotusguild.org/webhook/*``http://10.10.10.29:9003`
- Virtual user avatars: set via appservice token (`as_token` in hookshot-registration.yaml) impersonating each user
---
## Server Checklist
### Quality of Life
- [x] Migrate from SQLite to PostgreSQL
- [x] TURN/STUN server (coturn) for reliable voice/video
- [x] URL previews
- [x] Upload size limit 200MB
- [x] Full-text message search (PostgreSQL backend)
- [x] Media retention policy (remote: 1yr, local: 3yr)
- [x] Sliding sync (native Synapse)
- [x] LiveKit for Element Call video rooms
- [x] Default room version v12, all rooms upgraded
- [x] Landing page with client recommendations (Cinny, Commet, Element, Element X mobile)
- [ ] Push notifications gateway (Sygnal) for mobile clients
### Auth & SSO
- [x] Token-based registration
- [x] SSO/OIDC via Authelia
- [x] `allow_existing_users: true` for linking accounts to SSO
- [x] Password auth alongside SSO
### Webhooks & Integrations
- [x] matrix-hookshot 7.3.2 installed and running
- [x] Generic webhook bridge for 12 services
- [x] Per-service JS transformation functions (formatted messages with emoji)
- [x] Per-service virtual user avatars
- [x] NPM reverse proxy for `/webhook` path
- [ ] Bazarr native Matrix integration (hookshot connection exists as fallback)
- [ ] Huntarr webhook format investigation
- [ ] Tinker Tickets custom code
### Bridging
- [ ] mautrix-discord bridge
- [ ] Bridge key channels (general, gaming, memes)
- [ ] Puppet bridging
### Room Structure
- [x] The Lotus Guild space
- [x] All core rooms with correct power levels and join rules
- [x] Welcome room with react-to-join onboarding
- [x] Spam and Stuff room for service notifications (hookshot)
- [x] Custom room avatars
- [ ] Read-only announcements room - [ ] Read-only announcements room
- [ ] Audit/logging room (admin-only, for the bot)
- [ ] Off-topic / memes room
- [ ] Game-specific rooms (Minecraft, Valorant, League, Hytale, etc.)
- [ ] Bot commands room (keep bot spam contained)
- [ ] Voice/video call room
## Custom Emoji & Stickers ### Hardening
- [ ] Export all custom emojis from Discord server - [x] Rate limiting
- [ ] Create Matrix emoji packs (per-room or space-wide) - [x] E2EE on all rooms (except Spam and Stuff — intentional for hookshot)
- [ ] Set up sticker picker widget in Element - [ ] Federation allow/deny lists
- [ ] Import/create Lotus Guild sticker pack - [ ] Regular Synapse updates
- [ ] Look into maunium/stickerpicker for a self-hosted sticker server - [ ] Automated database + media backups
## Element/Client Customization ### Admin
- [ ] Custom Element Web instance (self-hosted on chat.lotusguild.org) - [x] Synapse admin API dashboard (synapse-admin at http://10.10.10.29:8080)
- [ ] Custom theme with #980000 branding (Element supports custom CSS themes) - [x] Power levels per room
- [ ] Custom welcome/home page in Element Web - [ ] Mjolnir/Draupnir moderation bot
- [ ] Set default room list to show Lotus Guild space on first login
- [ ] Configure .well-known to point clients to custom Element Web instance
- [ ] Custom app name and branding in Element Web config.json
## Widgets & Integrations
- [ ] Dimension integration manager (self-hosted, replaces Scalar)
- [ ] Jitsi widget for group voice/video calls (self-hosted)
- [ ] Etherpad widget for collaborative notes
- [ ] RSS bot for game news feeds (Minecraft updates, Valorant patches, etc.)
- [ ] GitHub/Gitea notifications bot (push events to a dev room)
## Fun Stuff
- [ ] Custom room avatars with Lotus Guild branding
- [ ] Animated room banners
- [ ] Welcome bot message for new members joining the space
- [ ] Daily adjective posting (port from Discord bot)
- [ ] Game night scheduling bot/widget
- [ ] Karma/points system via bot reactions
- [ ] Custom Matrix "profile badges" via bot (similar to Discord roles showing on profile)
--- ---
## Bot - Core Setup ## Bot Checklist
- [ ] Project scaffolding (`bot.py`, config, `.env`, requirements)
- [ ] matrix-nio async client with E2EE support
- [ ] Device verification / trust storage
- [ ] Logging (rotating file + stdout, matching Discord bot pattern)
- [ ] Config validation (homeserver URL, access token, room IDs)
- [ ] Graceful shutdown and reconnection handling
## Bot - Command Porting (from Discord bot) ### Core
- [ ] `!help` - List all available commands - [x] matrix-nio async client with E2EE
- [ ] `!ping` - Bot latency check - [x] Device trust (auto-trust all devices)
- [ ] `!8ball <question>` - Magic 8-ball - [x] Graceful shutdown (SIGTERM/SIGINT)
- [ ] `!fortune` - Fortune cookie message - [x] Initial sync token (ignores old messages on startup)
- [ ] `!flip` - Coin flip - [x] Auto-accept room invites
- [ ] `!roll <NdS>` - Dice roller - [x] Deployed as systemd service (`matrixbot.service`) on LXC 151
- [ ] `!random <min> <max>` - Random number generator
- [ ] `!rps <choice>` - Rock Paper Scissors
- [ ] `!poll <question>` - Poll (using reactions)
- [ ] `!trivia` - Trivia game (using reactions for answers)
- [ ] `!champion` - Random LoL champion picker
## Bot - Integrations ### Commands
- [ ] `!minecraft <username>` - RCON whitelist (Mojang API validation + RCON) - [x] `!help` — list commands
- [ ] `!hytale <username>` - Hytale whitelist request (audit log to admin room) - [x] `!ping` — latency check
- [ ] `!ask <question>` - Ollama LLM integration (lotusllm model) - [x] `!8ball <question>` — magic 8-ball
- [x] `!fortune` — fortune cookie
- [x] `!flip` — coin flip
- [x] `!roll <NdS>` — dice roller
- [x] `!random <min> <max>` — random number
- [x] `!rps <choice>` — rock paper scissors
- [x] `!poll <question>` — poll with reactions
- [x] `!trivia` — trivia game (reactions, 30s reveal)
- [x] `!champion [lane]` — random LoL champion
- [x] `!agent [role]` — random Valorant agent
- [x] `!wordle` — full Wordle game (daily, hard mode, stats, share)
- [x] `!minecraft <username>` — RCON whitelist add
- [x] `!ask <question>` — Ollama LLM (lotusllm, 2min cooldown)
- [x] `!health` — bot uptime + service status
## Bot - Admin Commands ### Welcome System
- [ ] `!clear <count>` - Redact messages (requires power level) - [x] Auto-post welcome message in Welcome room on startup
- [ ] `!health` - Bot stats (uptime, command counts, service status) - [x] React-to-join: react with ✅ → bot invites to General, Commands, Memes
- [ ] Power level checks (Matrix equivalent of Discord role checks) - [x] Welcome event ID persisted to `welcome_state.json`
- [x] Watches Space joins and DMs new members
## Bot - Audit Logging ### Wordle
- [ ] Member join/leave events - [x] Daily puzzles with two-pass letter evaluation
- [ ] Message edits and redactions - [x] Hard mode with constraint validation
- [ ] Room state changes - [x] Stats persistence (`wordle_stats.json`)
- [ ] Batched audit log posting to admin room - [x] Cinny-compatible rendering (inline `<span>` tiles)
- [x] DM-based gameplay, `!wordle share` posts result to public room
## Bot - Deployment - [x] Virtual keyboard display
- [ ] Systemd service (`matrixbot.service`)
- [ ] Auto-deploy from Gitea webhook (matching Discord bot pattern)
- [ ] Deployment script (`/usr/local/bin/matrix_bot_deploy.sh`)
- [ ] Determine host LXC (new container or colocate with Synapse on 10.10.10.29)
## Bot - Not Porting (Discord-specific)
- Reaction roles (no Matrix equivalent)
- Status cycling (Matrix presence is simpler)
- Guild-specific event handlers (channel create/delete, boost, etc.)
--- ---
## Tech Stack ## Tech Stack
- **Language**: Python 3
- **Library**: matrix-nio (with E2EE)
- **Homeserver**: matrix.lotusguild.org (Synapse on 10.10.10.29)
- **Database**: PostgreSQL 17 on 10.10.10.44
- **TURN**: coturn on 10.10.10.29 (colocated with Synapse)
- **Dependencies**: matrix-nio[e2ee], aiohttp, python-dotenv, mcrcon
## Production Server | Component | Technology |
- **Host**: TBD |-----------|-----------|
- **Bot Directory**: TBD | Bot language | Python 3 |
- **Service**: `matrixbot.service` | Bot library | matrix-nio (E2EE) |
| Homeserver | Synapse 1.147+ |
| Database | PostgreSQL 17 |
| TURN | coturn |
| Video calls | LiveKit + lk-jwt-service |
| SSO | Authelia (OIDC) + LLDAP |
| Webhook bridge | matrix-hookshot 7.3.2 |
| Reverse proxy | Nginx Proxy Manager |
| Bot dependencies | matrix-nio[e2ee], aiohttp, python-dotenv, mcrcon |
## Bot Files
```
matrixBot/
├── bot.py # Entry point, client setup, event loop
├── callbacks.py # Message + reaction event handlers
├── commands.py # All command implementations
├── config.py # Environment config + validation
├── utils.py # send_text, send_html, send_reaction, get_or_create_dm
├── welcome.py # Welcome message + react-to-join logic
├── wordle.py # Full Wordle game engine
├── wordlist_answers.py # Wordle answer word list
├── wordlist_valid.py # Wordle valid guess word list
├── .env.example # Environment variable template
└── requirements.txt # Python dependencies
```

100
bot.py
View File

@@ -8,8 +8,11 @@ from pathlib import Path
from nio import ( from nio import (
AsyncClient, AsyncClient,
AsyncClientConfig, AsyncClientConfig,
InviteMemberEvent,
LoginResponse, LoginResponse,
RoomMemberEvent,
RoomMessageText, RoomMessageText,
UnknownEvent,
) )
from config import ( from config import (
@@ -17,11 +20,13 @@ from config import (
MATRIX_USER_ID, MATRIX_USER_ID,
MATRIX_ACCESS_TOKEN, MATRIX_ACCESS_TOKEN,
MATRIX_DEVICE_ID, MATRIX_DEVICE_ID,
MATRIX_PASSWORD,
LOG_LEVEL, LOG_LEVEL,
ConfigValidator, ConfigValidator,
) )
from callbacks import Callbacks from callbacks import Callbacks
from utils import setup_logging from utils import setup_logging
from welcome import post_welcome_message
logger = setup_logging(LOG_LEVEL) logger = setup_logging(LOG_LEVEL)
@@ -40,16 +45,28 @@ def save_credentials(resp, homeserver):
logger.info("Credentials saved to %s", CREDENTIALS_FILE) logger.info("Credentials saved to %s", CREDENTIALS_FILE)
def trust_devices(client: AsyncClient): async def trust_devices(client: AsyncClient):
"""Auto-trust all devices for all users we share rooms with.""" """Query keys and trust all devices for all users we share rooms with."""
if not client.olm: if not client.olm:
logger.warning("Olm not loaded, skipping device trust") logger.warning("Olm not loaded, skipping device trust")
return 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 user_id, devices in client.device_store.items():
for device_id, olm_device in devices.items(): for device_id, olm_device in devices.items():
if not client.olm.is_device_verified(olm_device): if not client.olm.is_device_verified(olm_device):
client.verify_device(olm_device) client.verify_device(olm_device)
logger.info("Trusted all known devices") logger.info("Trusted all known devices (%d users)", len(users))
async def main(): async def main():
@@ -70,21 +87,73 @@ async def main():
client = AsyncClient( client = AsyncClient(
MATRIX_HOMESERVER, MATRIX_HOMESERVER,
MATRIX_USER_ID, MATRIX_USER_ID,
device_id=MATRIX_DEVICE_ID, device_id=MATRIX_DEVICE_ID or None,
config=client_config, config=client_config,
store_path=str(STORE_PATH), store_path=str(STORE_PATH),
) )
# Restore access token (no password login needed) # Try saved credentials first, then .env token, then password login
client.access_token = MATRIX_ACCESS_TOKEN logged_in = False
client.user_id = MATRIX_USER_ID has_creds = False
client.device_id = MATRIX_DEVICE_ID
# Load the olm/e2ee store if it exists if CREDENTIALS_FILE.exists():
client.load_store() 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) callbacks = Callbacks(client)
client.add_event_callback(callbacks.message, RoomMessageText) 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 # Graceful shutdown
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -97,11 +166,7 @@ async def main():
for sig in (signal.SIGTERM, signal.SIGINT): for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _signal_handler) loop.add_signal_handler(sig, _signal_handler)
logger.info("Starting initial sync...") # Mark startup complete from the initial sync
# Do a first sync to catch up, then mark startup complete so we only
# process new messages going forward.
sync_resp = await client.sync(timeout=30000, full_state=True)
if hasattr(sync_resp, "next_batch"): if hasattr(sync_resp, "next_batch"):
callbacks.startup_sync_token = sync_resp.next_batch callbacks.startup_sync_token = sync_resp.next_batch
logger.info("Initial sync complete, token: %s", sync_resp.next_batch[:20]) logger.info("Initial sync complete, token: %s", sync_resp.next_batch[:20])
@@ -111,7 +176,10 @@ async def main():
sys.exit(1) sys.exit(1)
# Trust devices after initial sync loads the device store # Trust devices after initial sync loads the device store
trust_devices(client) 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) logger.info("Bot ready as %s — listening for commands", MATRIX_USER_ID)

View File

@@ -1,10 +1,11 @@
import logging import logging
from functools import wraps from functools import wraps
from nio import AsyncClient, RoomMessageText from nio import AsyncClient, RoomMessageText, UnknownEvent
from config import BOT_PREFIX, MATRIX_USER_ID from config import BOT_PREFIX, MATRIX_USER_ID
from commands import COMMANDS, metrics from commands import COMMANDS, metrics
from welcome import handle_welcome_reaction, handle_space_join, SPACE_ROOM_ID
logger = logging.getLogger("matrixbot") logger = logging.getLogger("matrixbot")
@@ -60,3 +61,54 @@ class Callbacks:
metrics.record_command(cmd_name) metrics.record_command(cmd_name)
wrapped = handle_command_errors(handler) wrapped = handle_command_errors(handler)
await wrapped(self.client, room.room_id, event.sender, args) 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)

View File

@@ -617,3 +617,14 @@ async def cmd_health(client: AsyncClient, room_id: str, sender: str, args: str):
f"<strong>Services:</strong> {', '.join(services)}" f"<strong>Services:</strong> {', '.join(services)}"
) )
await send_html(client, room_id, plain, html) await send_html(client, room_id, plain, html)
# ---------------------------------------------------------------------------
# Wordle
# ---------------------------------------------------------------------------
from wordle import handle_wordle
@command("wordle", "Play Wordle! (!wordle help for details)")
async def cmd_wordle(client: AsyncClient, room_id: str, sender: str, args: str):
await handle_wordle(client, room_id, sender, args)

View File

@@ -10,6 +10,7 @@ MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://matrix.lotusguild.or
MATRIX_USER_ID = os.getenv("MATRIX_USER_ID", "@lotusbot:matrix.lotusguild.org") MATRIX_USER_ID = os.getenv("MATRIX_USER_ID", "@lotusbot:matrix.lotusguild.org")
MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "") MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
MATRIX_DEVICE_ID = os.getenv("MATRIX_DEVICE_ID", "") MATRIX_DEVICE_ID = os.getenv("MATRIX_DEVICE_ID", "")
MATRIX_PASSWORD = os.getenv("MATRIX_PASSWORD", "")
# Bot settings # Bot settings
BOT_PREFIX = os.getenv("BOT_PREFIX", "!") BOT_PREFIX = os.getenv("BOT_PREFIX", "!")
@@ -34,7 +35,7 @@ MAX_USERNAME_LENGTH = 16
class ConfigValidator: class ConfigValidator:
REQUIRED = ["MATRIX_HOMESERVER", "MATRIX_USER_ID", "MATRIX_ACCESS_TOKEN", "MATRIX_DEVICE_ID"] REQUIRED = ["MATRIX_HOMESERVER", "MATRIX_USER_ID"]
@classmethod @classmethod
def validate(cls): def validate(cls):

View File

@@ -3,6 +3,7 @@ from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
from nio import AsyncClient, RoomSendResponse from nio import AsyncClient, RoomSendResponse
from nio.exceptions import OlmUnverifiedDeviceError
from config import MAX_INPUT_LENGTH from config import MAX_INPUT_LENGTH
@@ -32,10 +33,35 @@ def setup_logging(level="INFO"):
return logger 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): async def send_text(client: AsyncClient, room_id: str, text: str):
logger = logging.getLogger("matrixbot") logger = logging.getLogger("matrixbot")
resp = await client.room_send( resp = await _room_send_trusted(
room_id, client, room_id,
message_type="m.room.message", message_type="m.room.message",
content={"msgtype": "m.text", "body": text}, content={"msgtype": "m.text", "body": text},
) )
@@ -46,8 +72,8 @@ async def send_text(client: AsyncClient, room_id: str, text: str):
async def send_html(client: AsyncClient, room_id: str, plain: str, html: str): async def send_html(client: AsyncClient, room_id: str, plain: str, html: str):
logger = logging.getLogger("matrixbot") logger = logging.getLogger("matrixbot")
resp = await client.room_send( resp = await _room_send_trusted(
room_id, client, room_id,
message_type="m.room.message", message_type="m.room.message",
content={ content={
"msgtype": "m.text", "msgtype": "m.text",
@@ -62,8 +88,8 @@ async def send_html(client: AsyncClient, room_id: str, plain: str, html: str):
async def send_reaction(client: AsyncClient, room_id: str, event_id: str, emoji: str): async def send_reaction(client: AsyncClient, room_id: str, event_id: str, emoji: str):
return await client.room_send( return await _room_send_trusted(
room_id, client, room_id,
message_type="m.reaction", message_type="m.reaction",
content={ content={
"m.relates_to": { "m.relates_to": {
@@ -75,6 +101,31 @@ async def send_reaction(client: AsyncClient, room_id: str, event_id: str, 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: def sanitize_input(text: str, max_length: int = MAX_INPUT_LENGTH) -> str:
text = text.strip()[:max_length] text = text.strip()[:max_length]
text = "".join(char for char in text if char.isprintable()) text = "".join(char for char in text if char.isprintable())

150
welcome.py Normal file
View File

@@ -0,0 +1,150 @@
"""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")

786
wordle.py Normal file
View File

@@ -0,0 +1,786 @@
"""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
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)
# 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
# ---------------------------------------------------------------------------
def get_daily_word() -> tuple[str, int]:
"""Return (word, puzzle_number) for today's daily puzzle."""
today = date.today()
puzzle_number = (today - _WORDLE_EPOCH).days
word = _ANSWER_LIST[puzzle_number % len(_ANSWER_LIST)]
return word, puzzle_number
def evaluate_guess(guess: str, target: str) -> list[int]:
"""Evaluate a guess against the target. Returns list of 5 scores:
2 = correct position (green), 1 = wrong position (yellow), 0 = absent (gray).
Handles duplicate letters correctly with a two-pass approach.
"""
result = [0] * 5
target_remaining = list(target)
# Pass 1: mark exact matches (green)
for i in range(5):
if guess[i] == target[i]:
result[i] = 2
target_remaining[i] = None
# Pass 2: mark present-but-wrong-position (yellow)
for i in range(5):
if result[i] == 2:
continue
if guess[i] in target_remaining:
result[i] = 1
target_remaining[target_remaining.index(guess[i])] = None
return result
def validate_hard_mode(
guess: str,
previous_guesses: list[str],
previous_results: list[list[int]],
) -> str | None:
"""Return None if valid, or an error message if hard mode violated."""
for prev_guess, prev_result in zip(previous_guesses, previous_results):
for i, (letter, score) in enumerate(zip(prev_guess, prev_result)):
if score == 2 and guess[i] != letter:
return (
f"Hard mode: position {i + 1} must be "
f"'{letter}' (green from previous guess)"
)
if score == 1 and letter not in guess:
return (
f"Hard mode: guess must contain "
f"'{letter}' (yellow from previous guess)"
)
return None
# ---------------------------------------------------------------------------
# HTML rendering
# ---------------------------------------------------------------------------
def _tile_span(letter: str, bg: str) -> str:
"""Render a single letter tile using Matrix-compatible attributes."""
return (
f'<font data-mx-bg-color="{bg}" data-mx-color="#ffffff">'
f"<b>\u00a0{letter}\u00a0</b></font>"
)
def render_grid_html(game: WordleGame) -> str:
"""Render the Wordle grid as inline spans (compatible with Cinny)."""
rows = []
for row_idx in range(6):
tiles = []
if row_idx < len(game.guesses):
guess = game.guesses[row_idx]
result = game.results[row_idx]
for letter, score in zip(guess, result):
bg = _TILE[score]["bg"]
tiles.append(_tile_span(letter, bg))
else:
for _ in range(5):
tiles.append(_tile_span("\u00a0", _EMPTY_BG))
rows.append("".join(tiles))
return "<br>".join(rows)
def render_keyboard_html(game: WordleGame) -> str:
"""Render a virtual keyboard showing letter states."""
letter_states: dict[str, int] = {}
for guess, result in zip(game.guesses, game.results):
for letter, score in zip(guess, result):
letter_states[letter] = max(letter_states.get(letter, -1), score)
kb_rows = []
for row in _KB_ROWS:
keys = []
for letter in row:
state = letter_states.get(letter, -1)
if state == -1:
bg, color = "#818384", "#ffffff"
elif state == 0:
bg, color = "#3a3a3c", "#555555"
elif state == 1:
bg, color = "#b59f3b", "#ffffff"
else:
bg, color = "#538d4e", "#ffffff"
keys.append(
f'<font data-mx-bg-color="{bg}" data-mx-color="{color}">'
f"{letter}</font>"
)
kb_rows.append(" ".join(keys))
return "<br>" + "<br>".join(kb_rows)
def render_grid_plain(game: WordleGame) -> str:
"""Plain text grid with emoji squares and letter markers."""
_marker = {2: "!", 1: "?", 0: "."} # ! = correct, ? = wrong spot, . = absent
lines = []
for guess, result in zip(game.guesses, game.results):
emoji_row = "".join(_EMOJI[s] for s in result)
# Show each letter with a marker: [C!] = correct, [R?] = wrong spot, [A.] = absent
marked = " ".join(f"{letter}{_marker[score]}" for letter, score in zip(guess, result))
lines.append(f"{emoji_row} {marked}")
return "\n".join(lines)
def render_keyboard_plain(game: WordleGame) -> str:
"""Plain text keyboard status."""
letter_states: dict[str, int] = {}
for guess, result in zip(game.guesses, game.results):
for letter, score in zip(guess, result):
letter_states[letter] = max(letter_states.get(letter, -1), score)
lines = []
symbols = {-1: " ", 0: "\u2717", 1: "?", 2: "\u2713"}
for row in _KB_ROWS:
chars = []
for letter in row:
state = letter_states.get(letter, -1)
if state == 0:
chars.append("\u00b7") # dimmed
elif state >= 1:
chars.append(letter)
else:
chars.append(letter.lower())
lines.append(" ".join(chars))
return "\n".join(lines)
def render_stats_html(stats: dict) -> str:
"""Render player statistics as HTML (Matrix-compatible)."""
played = stats["games_played"]
won = stats["games_won"]
win_pct = (won / max(played, 1)) * 100
streak = stats["current_streak"]
max_streak = stats["max_streak"]
dist = stats["guess_distribution"]
max_count = max((int(v) for v in dist.values()), default=1) or 1
html = "<strong>Wordle Statistics</strong><br><br>"
html += (
f"<b>{played}</b> Played | "
f"<b>{win_pct:.0f}%</b> Win | "
f"<b>{streak}</b> Streak | "
f"<b>{max_streak}</b> Best<br><br>"
)
html += "<strong>Guess Distribution</strong><br>"
for i in range(1, 7):
count = int(dist.get(str(i), 0))
is_max = count == max_count and count > 0
bar_len = max(int((count / max_count) * 10), 1) if max_count > 0 else 1
bar = "\u2588" * bar_len # Block character for bar
if is_max and count > 0:
html += f'{i} <font data-mx-bg-color="#538d4e" data-mx-color="#ffffff"> {bar} {count} </font><br>'
else:
html += f'{i} <font data-mx-bg-color="#3a3a3c" data-mx-color="#ffffff"> {bar} {count} </font><br>'
return html
def render_stats_plain(stats: dict) -> str:
"""Plain text stats."""
played = stats["games_played"]
won = stats["games_won"]
win_pct = (won / max(played, 1)) * 100
streak = stats["current_streak"]
max_streak = stats["max_streak"]
dist = stats["guess_distribution"]
max_count = max((int(v) for v in dist.values()), default=1) or 1
lines = [
"Wordle Statistics",
f"Played: {played} | Win: {win_pct:.0f}% | Streak: {streak} | Max: {max_streak}",
"",
"Guess Distribution:",
]
for i in range(1, 7):
count = int(dist.get(str(i), 0))
bar_len = max(round((count / max_count) * 16), 1) if count > 0 else 0
bar = "\u2588" * bar_len
lines.append(f" {i}: {bar} {count}")
return "\n".join(lines)
def generate_share(stats: dict) -> str:
"""Generate the shareable emoji grid from last completed daily."""
results = stats.get("last_daily_result")
if not results:
return ""
won = stats.get("last_daily_won", False)
hard = stats.get("last_daily_hard", False)
daily_num = stats.get("last_daily", 0)
score = str(len(results)) if won else "X"
mode = "*" if hard else ""
header = f"Wordle {daily_num} {score}/6{mode}\n\n"
rows = []
for result in results:
rows.append("".join(_EMOJI[s] for s in result))
return header + "\n".join(rows)
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
async def wordle_help(client: AsyncClient, room_id: str):
"""Show help text with rules and commands."""
p = BOT_PREFIX
plain = (
f"Wordle - Guess the 5-letter word in 6 tries!\n\n"
f"Commands:\n"
f" {p}wordle Start today's daily puzzle (or show current game)\n"
f" {p}wordle <word> Submit a 5-letter guess\n"
f" {p}wordle stats View your statistics\n"
f" {p}wordle hard Toggle hard mode\n"
f" {p}wordle share Share your last daily result\n"
f" {p}wordle give up Forfeit current game\n"
f" {p}wordle help Show this help\n\n"
f"Rules:\n"
f" - Each guess must be a valid 5-letter English word\n"
f" - Green = correct letter, correct position\n"
f" - Yellow = correct letter, wrong position\n"
f" - Gray = letter not in the word\n"
f" - Hard mode: must use all revealed hints in subsequent guesses\n"
f" - Everyone gets the same daily word!"
)
html = (
"<h4>Wordle</h4>"
"<p>Guess the 5-letter word in 6 tries!</p>"
"<strong>Commands:</strong>"
"<ul>"
f"<li><code>{p}wordle</code> — Start today's daily puzzle</li>"
f"<li><code>{p}wordle &lt;word&gt;</code> — Submit a guess</li>"
f"<li><code>{p}wordle stats</code> — View your statistics</li>"
f"<li><code>{p}wordle hard</code> — Toggle hard mode</li>"
f"<li><code>{p}wordle share</code> — Share your last result</li>"
f"<li><code>{p}wordle give up</code> — Forfeit current game</li>"
"</ul>"
"<strong>How to play:</strong>"
"<ul>"
'<li><font data-mx-bg-color="#538d4e" data-mx-color="#ffffff"><b> G </b></font> '
"Green = correct letter, correct position</li>"
'<li><font data-mx-bg-color="#b59f3b" data-mx-color="#ffffff"><b> Y </b></font> '
"Yellow = correct letter, wrong position</li>"
'<li><font data-mx-bg-color="#3a3a3c" data-mx-color="#ffffff"><b> X </b></font> '
"Gray = letter not in the word</li>"
"</ul>"
"<p><em>Hard mode:</em> You must use all revealed hints in subsequent guesses.</p>"
"<p>Everyone gets the same daily word!</p>"
)
await send_html(client, room_id, plain, html)
async def wordle_start_or_status(client: AsyncClient, room_id: str, sender: str, origin_room_id: str = ""):
"""Start a new daily game or show current game status."""
# Check for active game
if sender in _active_games:
game = _active_games[sender]
if not game.finished:
guesses_left = 6 - len(game.guesses)
grid_plain = render_grid_plain(game)
kb_plain = render_keyboard_plain(game)
plain = (
f"Wordle {game.daily_number}"
f"Guess {len(game.guesses) + 1}/6\n\n"
f"{grid_plain}\n\n{kb_plain}"
)
grid_html = render_grid_html(game)
kb_html = render_keyboard_html(game)
mode = " (Hard Mode)" if game.hard_mode else ""
html = (
f''
f"<strong>Wordle {game.daily_number}{mode}</strong> — "
f"Guess {len(game.guesses) + 1}/6<br><br>"
f"{grid_html}{kb_html}"
)
await send_html(client, room_id, plain, html)
return
# Check if already completed today's puzzle
word, puzzle_number = get_daily_word()
stats = _get_player_stats(sender)
if stats["last_daily"] == puzzle_number:
await send_text(
client, room_id,
f"You already completed today's Wordle (#{puzzle_number})! "
f"Use {BOT_PREFIX}wordle stats to see your results "
f"or {BOT_PREFIX}wordle share to share them."
)
return
# Start new game
hard_mode = stats.get("hard_mode", False)
game = WordleGame(
player_id=sender,
room_id=room_id,
target=word,
hard_mode=hard_mode,
daily_number=puzzle_number,
origin_room_id=origin_room_id or room_id,
)
_active_games[sender] = game
mode_str = " (Hard Mode)" if hard_mode else ""
grid_html = render_grid_html(game)
kb_html = render_keyboard_html(game)
plain = (
f"Wordle #{puzzle_number}{mode_str}\n"
f"Guess a 5-letter word! You have 6 attempts.\n"
f"Type {BOT_PREFIX}wordle <word> to guess."
)
html = (
f''
f"<strong>Wordle #{puzzle_number}{mode_str}</strong><br>"
f"Guess a 5-letter word! You have 6 attempts.<br>"
f"Type <code>{BOT_PREFIX}wordle &lt;word&gt;</code> to guess."
f"<br><br>{grid_html}{kb_html}"
)
await send_html(client, room_id, plain, html)
async def wordle_guess(
client: AsyncClient, room_id: str, sender: str, guess: str
):
"""Process a guess."""
if sender not in _active_games:
await send_text(
client, room_id,
f"No active game. Start one with {BOT_PREFIX}wordle"
)
return
game = _active_games[sender]
if game.finished:
await send_text(
client, room_id,
f"Your game is already finished! "
f"Use {BOT_PREFIX}wordle to start a new daily puzzle."
)
return
# Validate word
if guess not in _VALID_SET:
await send_text(client, room_id, f"'{guess.lower()}' is not in the word list. Try again.")
return
# Hard mode validation
if game.hard_mode and game.guesses:
violation = validate_hard_mode(guess, game.guesses, game.results)
if violation:
await send_text(client, room_id, violation)
return
# Evaluate
result = evaluate_guess(guess, game.target)
game.guesses.append(guess)
game.results.append(result)
# Check win
if all(s == 2 for s in result):
game.finished = True
game.won = True
_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}\n\n"
f"Use {BOT_PREFIX}wordle stats for your statistics "
f"or {BOT_PREFIX}wordle share to share!"
)
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}<br>"
f"<em>Use <code>{BOT_PREFIX}wordle stats</code> for statistics "
f"or <code>{BOT_PREFIX}wordle share</code> to share!</em>"
)
await send_html(client, room_id, plain, html)
return
# Check loss (6 guesses used)
if len(game.guesses) >= 6:
game.finished = True
game.won = False
_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)
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
_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)
# ---------------------------------------------------------------------------
# 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 ""
if subcmd == "help":
# Help can go to the same room
dm_room, origin = await _get_dm_room(client, room_id, sender)
await wordle_help(client, dm_room)
elif subcmd == "stats":
dm_room, origin = await _get_dm_room(client, room_id, sender)
await wordle_stats(client, dm_room, sender)
elif subcmd == "hard":
dm_room, origin = await _get_dm_room(client, room_id, sender)
await wordle_toggle_hard(client, dm_room, sender)
elif subcmd == "share":
# Share goes to the PUBLIC room (origin), not DM
await wordle_share(client, room_id, sender)
elif subcmd == "give" and sub_args.lower().startswith("up"):
dm_room, origin = await _get_dm_room(client, room_id, sender)
await wordle_give_up(client, dm_room, sender)
elif subcmd == "":
dm_room, origin = await _get_dm_room(client, room_id, sender)
await wordle_start_or_status(client, dm_room, sender, origin)
elif len(subcmd) == 5 and subcmd.isalpha():
dm_room, origin = await _get_dm_room(client, room_id, sender)
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()

411
wordlist_answers.py Normal file
View File

@@ -0,0 +1,411 @@
# 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 Normal file

File diff suppressed because it is too large Load Diff