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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ logs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
credentials.json
|
||||
wordle_stats.json
|
||||
|
||||
344
README.md
344
README.md
@@ -1,183 +1,241 @@
|
||||
# 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
|
||||
|
||||
## Status: Phase 1 Complete (Database + Voice/Video)
|
||||
## Status: Phase 4 — Webhooks & Integrations
|
||||
|
||||
---
|
||||
|
||||
## Priority Order (suggested)
|
||||
1. ~~PostgreSQL migration (SQLite will bottleneck everything else)~~
|
||||
2. ~~TURN server (makes voice/video actually reliable)~~
|
||||
3. Custom Element Web (chat.lotusguild.org with branding)
|
||||
4. Discord bridge (lets people transition gradually)
|
||||
5. Custom emoji packs (makes it feel like home)
|
||||
6. Room structure + space setup
|
||||
7. Moderation bot
|
||||
8. Matrix bot
|
||||
9. Everything else
|
||||
## Priority Order
|
||||
1. ~~PostgreSQL migration~~
|
||||
2. ~~TURN server~~
|
||||
3. ~~Room structure + space setup~~
|
||||
4. ~~Matrix bot (core + commands)~~
|
||||
5. ~~LiveKit / Element Call~~
|
||||
6. ~~SSO / OIDC (Authelia)~~
|
||||
7. ~~Webhook integrations (hookshot)~~
|
||||
8. Discord bridge (lets people transition gradually)
|
||||
9. Custom Element Web (chat.lotusguild.org with branding)
|
||||
10. Custom emoji packs
|
||||
11. Moderation bot
|
||||
12. Everything else
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Service | Host | IP | LXC | Notes |
|
||||
|---------|------|-----|-----|-------|
|
||||
| Synapse | storage-01 | 10.10.10.29 | 151 | Homeserver + coturn |
|
||||
| PostgreSQL 17 | storage-01 | 10.10.10.44 | 109 | Synapse database backend |
|
||||
| NPM | large1 | 10.10.10.27 | 139 | Reverse proxy for matrix.lotusguild.org |
|
||||
|---------|------|----|-----|-------|
|
||||
| Synapse | large1 | 10.10.10.29 | 151 | Homeserver + coturn + LiveKit + hookshot |
|
||||
| PostgreSQL 17 | large1 | 10.10.10.2 | 109 | Synapse database backend |
|
||||
| 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 venv: `/opt/venvs/matrix-synapse/`
|
||||
- coturn config: `/etc/turnserver.conf`
|
||||
- Synapse admin UI: `/var/www/synapse-admin/` (nginx on :8080)
|
||||
- SQLite backup: `/var/lib/matrix-synapse/homeserver.db`
|
||||
- LiveKit config: `/etc/livekit/config.yaml`
|
||||
- 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):**
|
||||
- TCP+UDP 3478 (TURN signaling)
|
||||
- UDP 49152-65535 (media relay)
|
||||
**Port forwarding (router → 10.10.10.29):**
|
||||
- TCP+UDP 3478 (TURN/STUN signaling)
|
||||
- TCP+UDP 5349 (TURNS/TLS)
|
||||
- TCP 7881 (LiveKit ICE TCP fallback)
|
||||
- TCP+UDP 49152-65535 (TURN relay + LiveKit WebRTC media)
|
||||
|
||||
---
|
||||
|
||||
## Server - Quality of Life
|
||||
- [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
|
||||
## Rooms (all v12)
|
||||
|
||||
## Server - Hardening
|
||||
- [x] Rate limiting configuration in Synapse
|
||||
- [ ] Federation allow/deny lists (decide if you want open federation or Lotus-only)
|
||||
- [ ] E2EE by default for private rooms
|
||||
- [ ] Regular Synapse version updates
|
||||
- [ ] Monitoring with Prometheus + Grafana (you already have both at 10.10.10.x)
|
||||
- [ ] Synapse worker mode if performance becomes an issue
|
||||
| Room | Room ID | Join Rule |
|
||||
|------|---------|-----------|
|
||||
| The Lotus Guild (Space) | `!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc` | public |
|
||||
| General | `!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0` | public |
|
||||
| Commands | `!ou56mVZQ8ZB7AhDYPmBV5_BR28WMZ4x5zwZkPCqjq1s` | restricted (Space members) |
|
||||
| Memes | `!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U` | public |
|
||||
| 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
|
||||
- [ ] Set up Mjolnir or Draupnir (moderation bot for ban lists, spam protection)
|
||||
- [ ] Configure proper power levels per room (mimic Discord role hierarchy)
|
||||
- [ ] Invite-only registration flow (already have token-based registration)
|
||||
- [ ] Set up room ACLs for federation control (block known-bad servers)
|
||||
- [x] Synapse admin API dashboard (synapse-admin v0.11.1 at http://10.10.10.29:8080)
|
||||
- [ ] Automated backups of Synapse database and media
|
||||
**Power level roles (Cinny tags):**
|
||||
- 100: Owner (jared)
|
||||
- 50: The Nerdy Council (enhuynh, lonely)
|
||||
- 48: Panel of Geeks
|
||||
- 35: Cool Kids
|
||||
- 0: Member
|
||||
|
||||
## 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
|
||||
- [ ] Create room directory matching Discord channel layout
|
||||
- [ ] Set up a Space for "Lotus Guild" as the top-level container
|
||||
- [ ] Sub-spaces for categories (General, Gaming, Media, Admin, etc.)
|
||||
- [ ] Welcome room with pinned onboarding instructions
|
||||
## Webhook Integrations (matrix-hookshot 7.3.2)
|
||||
|
||||
Generic webhooks bridged into **Spam and Stuff** via [matrix-hookshot](https://github.com/matrix-org/matrix-hookshot).
|
||||
Each service gets its own virtual user (`@hookshot_<service>`) with a unique avatar.
|
||||
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
|
||||
- [ ] 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
|
||||
- [ ] Export all custom emojis from Discord server
|
||||
- [ ] Create Matrix emoji packs (per-room or space-wide)
|
||||
- [ ] Set up sticker picker widget in Element
|
||||
- [ ] Import/create Lotus Guild sticker pack
|
||||
- [ ] Look into maunium/stickerpicker for a self-hosted sticker server
|
||||
### Hardening
|
||||
- [x] Rate limiting
|
||||
- [x] E2EE on all rooms (except Spam and Stuff — intentional for hookshot)
|
||||
- [ ] Federation allow/deny lists
|
||||
- [ ] Regular Synapse updates
|
||||
- [ ] Automated database + media backups
|
||||
|
||||
## Element/Client Customization
|
||||
- [ ] Custom Element Web instance (self-hosted on chat.lotusguild.org)
|
||||
- [ ] Custom theme with #980000 branding (Element supports custom CSS themes)
|
||||
- [ ] Custom welcome/home page in Element Web
|
||||
- [ ] 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)
|
||||
### Admin
|
||||
- [x] Synapse admin API dashboard (synapse-admin at http://10.10.10.29:8080)
|
||||
- [x] Power levels per room
|
||||
- [ ] Mjolnir/Draupnir moderation bot
|
||||
|
||||
---
|
||||
|
||||
## Bot - Core Setup
|
||||
- [ ] 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 Checklist
|
||||
|
||||
## Bot - Command Porting (from Discord bot)
|
||||
- [ ] `!help` - List all available commands
|
||||
- [ ] `!ping` - Bot latency check
|
||||
- [ ] `!8ball <question>` - Magic 8-ball
|
||||
- [ ] `!fortune` - Fortune cookie message
|
||||
- [ ] `!flip` - Coin flip
|
||||
- [ ] `!roll <NdS>` - Dice roller
|
||||
- [ ] `!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
|
||||
### Core
|
||||
- [x] matrix-nio async client with E2EE
|
||||
- [x] Device trust (auto-trust all devices)
|
||||
- [x] Graceful shutdown (SIGTERM/SIGINT)
|
||||
- [x] Initial sync token (ignores old messages on startup)
|
||||
- [x] Auto-accept room invites
|
||||
- [x] Deployed as systemd service (`matrixbot.service`) on LXC 151
|
||||
|
||||
## Bot - Integrations
|
||||
- [ ] `!minecraft <username>` - RCON whitelist (Mojang API validation + RCON)
|
||||
- [ ] `!hytale <username>` - Hytale whitelist request (audit log to admin room)
|
||||
- [ ] `!ask <question>` - Ollama LLM integration (lotusllm model)
|
||||
### Commands
|
||||
- [x] `!help` — list commands
|
||||
- [x] `!ping` — latency check
|
||||
- [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
|
||||
- [ ] `!clear <count>` - Redact messages (requires power level)
|
||||
- [ ] `!health` - Bot stats (uptime, command counts, service status)
|
||||
- [ ] Power level checks (Matrix equivalent of Discord role checks)
|
||||
### Welcome System
|
||||
- [x] Auto-post welcome message in Welcome room on startup
|
||||
- [x] React-to-join: react with ✅ → bot invites to General, Commands, Memes
|
||||
- [x] Welcome event ID persisted to `welcome_state.json`
|
||||
- [x] Watches Space joins and DMs new members
|
||||
|
||||
## Bot - Audit Logging
|
||||
- [ ] Member join/leave events
|
||||
- [ ] Message edits and redactions
|
||||
- [ ] Room state changes
|
||||
- [ ] Batched audit log posting to admin room
|
||||
|
||||
## Bot - Deployment
|
||||
- [ ] 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.)
|
||||
### Wordle
|
||||
- [x] Daily puzzles with two-pass letter evaluation
|
||||
- [x] Hard mode with constraint validation
|
||||
- [x] Stats persistence (`wordle_stats.json`)
|
||||
- [x] Cinny-compatible rendering (inline `<span>` tiles)
|
||||
- [x] DM-based gameplay, `!wordle share` posts result to public room
|
||||
- [x] Virtual keyboard display
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- **Host**: TBD
|
||||
- **Bot Directory**: TBD
|
||||
- **Service**: `matrixbot.service`
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Bot language | Python 3 |
|
||||
| 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
100
bot.py
@@ -8,8 +8,11 @@ from pathlib import Path
|
||||
from nio import (
|
||||
AsyncClient,
|
||||
AsyncClientConfig,
|
||||
InviteMemberEvent,
|
||||
LoginResponse,
|
||||
RoomMemberEvent,
|
||||
RoomMessageText,
|
||||
UnknownEvent,
|
||||
)
|
||||
|
||||
from config import (
|
||||
@@ -17,11 +20,13 @@ from config import (
|
||||
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)
|
||||
|
||||
@@ -40,16 +45,28 @@ def save_credentials(resp, homeserver):
|
||||
logger.info("Credentials saved to %s", CREDENTIALS_FILE)
|
||||
|
||||
|
||||
def trust_devices(client: AsyncClient):
|
||||
"""Auto-trust all devices for all users we share rooms with."""
|
||||
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")
|
||||
logger.info("Trusted all known devices (%d users)", len(users))
|
||||
|
||||
|
||||
async def main():
|
||||
@@ -70,21 +87,73 @@ async def main():
|
||||
client = AsyncClient(
|
||||
MATRIX_HOMESERVER,
|
||||
MATRIX_USER_ID,
|
||||
device_id=MATRIX_DEVICE_ID,
|
||||
device_id=MATRIX_DEVICE_ID or None,
|
||||
config=client_config,
|
||||
store_path=str(STORE_PATH),
|
||||
)
|
||||
|
||||
# Restore access token (no password login needed)
|
||||
client.access_token = MATRIX_ACCESS_TOKEN
|
||||
client.user_id = MATRIX_USER_ID
|
||||
client.device_id = MATRIX_DEVICE_ID
|
||||
# Try saved credentials first, then .env token, then password login
|
||||
logged_in = False
|
||||
has_creds = False
|
||||
|
||||
# Load the olm/e2ee store if it exists
|
||||
client.load_store()
|
||||
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()
|
||||
@@ -97,11 +166,7 @@ async def main():
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
|
||||
logger.info("Starting initial sync...")
|
||||
|
||||
# Do a first sync to catch up, then mark startup complete so we only
|
||||
# process new messages going forward.
|
||||
sync_resp = await client.sync(timeout=30000, full_state=True)
|
||||
# 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])
|
||||
@@ -111,7 +176,10 @@ async def main():
|
||||
sys.exit(1)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
54
callbacks.py
54
callbacks.py
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
from nio import AsyncClient, RoomMessageText
|
||||
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")
|
||||
|
||||
@@ -60,3 +61,54 @@ class Callbacks:
|
||||
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)
|
||||
|
||||
11
commands.py
11
commands.py
@@ -617,3 +617,14 @@ async def cmd_health(client: AsyncClient, room_id: str, sender: str, args: str):
|
||||
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)
|
||||
|
||||
@@ -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_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", "!")
|
||||
@@ -34,7 +35,7 @@ MAX_USERNAME_LENGTH = 16
|
||||
|
||||
|
||||
class ConfigValidator:
|
||||
REQUIRED = ["MATRIX_HOMESERVER", "MATRIX_USER_ID", "MATRIX_ACCESS_TOKEN", "MATRIX_DEVICE_ID"]
|
||||
REQUIRED = ["MATRIX_HOMESERVER", "MATRIX_USER_ID"]
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
|
||||
63
utils.py
63
utils.py
@@ -3,6 +3,7 @@ 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
|
||||
|
||||
@@ -32,10 +33,35 @@ def setup_logging(level="INFO"):
|
||||
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 client.room_send(
|
||||
room_id,
|
||||
resp = await _room_send_trusted(
|
||||
client, room_id,
|
||||
message_type="m.room.message",
|
||||
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):
|
||||
logger = logging.getLogger("matrixbot")
|
||||
resp = await client.room_send(
|
||||
room_id,
|
||||
resp = await _room_send_trusted(
|
||||
client, room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"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):
|
||||
return await client.room_send(
|
||||
room_id,
|
||||
return await _room_send_trusted(
|
||||
client, room_id,
|
||||
message_type="m.reaction",
|
||||
content={
|
||||
"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:
|
||||
text = text.strip()[:max_length]
|
||||
text = "".join(char for char in text if char.isprintable())
|
||||
|
||||
150
welcome.py
Normal file
150
welcome.py
Normal 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
786
wordle.py
Normal 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 <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 = 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
|
||||
_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
411
wordlist_answers.py
Normal 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
2568
wordlist_valid.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user