Compare commits

...

85 Commits

Author SHA1 Message Date
jared 278f850f0c fix: remove spurious f-string prefix (ruff F541)
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 38s
Lint / Secret scan (gitleaks) (push) Successful in 4s
2026-04-29 16:15:39 -04:00
jared 88627470c1 feat: management polish, !cancel, !wordlestats, welcome fixes
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Failing after 5s
Lint / Python deps (pip-audit) (push) Successful in 41s
Lint / Secret scan (gitleaks) (push) Successful in 5s
- Add !cancel command (anyone cancels own blackjack; PL50+ clears all room games)
- Add !wordlestats top-level command (wraps wordle stats function)
- Add !cleanwelcome admin command to purge stale welcome DM records
- !help now hides management section from sub-PL50 users, hides !health from non-admins
- !announce uses nio room cache for join_rule instead of an API call per room
- Fix _INVITEALL_BLOCKED comment (Commands is knock-gated, not restricted)
- welcome.py: skip duplicate DM if a pending welcome already exists for the user
- welcome.py: add clean_stale_dm_messages() helper
- welcome.py: replace no-op post_welcome_message with log_ready()
- bot.py: update import/call to match welcome.py rename

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:50:14 -04:00
jared 4ef73afed2 fix: exclude #commands from !inviteall
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 50s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Commands has join_rule=restricted so the join-rule filter didn't catch
it. Added _INVITEALL_BLOCKED set with the Commands room ID — any room
in that set is skipped regardless of join rule. Invite-only rooms
(Management, Cool Kids, Spam and Stuff) are still excluded by the
existing join_rule check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:48:12 -04:00
jared 72577dedf7 feat: setpl and inviteall apply space-wide with smart filtering
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 39s
Lint / Secret scan (gitleaks) (push) Successful in 4s
!setpl now iterates every Space room via the hierarchy API and updates
the power_levels state event in each. Setting a user to the room's
users_default cleans up the explicit entry rather than leaving a
stale PL0. Rooms where the bot lacks permission are counted and
reported but don't block the rest.

!inviteall skips rooms with join_rule=invite (Management, Cool Kids,
Spam and Stuff) — only public/restricted rooms get the invite. Also
skips rooms where the target is already a member.

_get_space_room_ids() fetches the Space child list via the v1 hierarchy
API with pagination support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:42:12 -04:00
jared 789db82d9f feat: management commands for PL50+ users
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 42s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Five new commands, all gated behind is_elevated() (power level >= 50):

!mkroom <name>   — clone #general's power levels, join rules, encryption,
                   history visibility, and avatar into a fresh v12 room,
                   auto-adds it to the Lotus Guild Space, and invites
                   the caller.

!roominfo        — show room display name, ID, member count, join rule,
                   encryption status, and all users with PL > 0.

!topic [text]    — set or clear the current room's topic.

!invite @user    — invite any Matrix user to the current room.

!setpl @user <n> — update a user's power level (0-100); cannot exceed
                   the caller's own level.

Also adds urllib.parse.quote and MATRIX_HOMESERVER to imports, and
adds a "Management (PL50+)" section to !help.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:34:08 -04:00
jared 66136ff2f7 feat: bypass !ask cooldown for users with power level >= 50
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 41s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Add is_elevated() helper that reads the room power level from the nio
client store. Users at PL50+ (Nerdy Council and above) skip the cooldown
check entirely. The timestamp is still recorded so cooldown applies
if their power level is later reduced below 50.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:39:44 -04:00
jared 37f5d2d70d feat: !ask --model flag to pick the LLM per-request
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 42s
Lint / Secret scan (gitleaks) (push) Successful in 8s
Usage: !ask --model <alias> <question>  (or -m for short)
Aliases: phi4, llama3, llama3-1b, gemma, gemma-1b, deepseek,
         codellama, qwen, dolphin, creative, abliterated,
         uncensored, llama2. The 'Thinking...' message shows
         which model is responding. Invalid aliases list all options.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:35:22 -04:00
jared 3ed15de5ce fix: triviaduel — one guess per player, auto-advance when both wrong
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 41s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Each player now gets exactly one attempt per question. If their guess
is wrong it's recorded and they're locked out for that round. If both
players have guessed wrong the correct answer is revealed and the game
moves to the next question immediately (no need to wait for the 45s
timeout).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:15:56 -04:00
jared 121e160535 fix: 20q answers truncate at sentence boundary, not mid-word
Lint / Shell (shellcheck) (push) Successful in 15s
Lint / JS (eslint) (push) Successful in 9s
Lint / Python (ruff) (push) Successful in 10s
Lint / Python deps (pip-audit) (push) Successful in 58s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Instead of a raw 6-word slice (which left dangling fragments like
'Partly - Not exclusively American; has'), extract the first complete
sentence (up to 10 words). Falls back to the 6-word cap only if no
sentence boundary is found in the response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:10:49 -04:00
jared 75f9c7bdb9 fix: tighten 20q answer cap to prevent answer leakage
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 42s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Reduce word cap from 12 to 6 and add explicit instructions not to use
proper nouns, brand names, or place names in answers. Fixes the case
where the model blurted 'The Grand Canyon State (Arizona)' in response
to a geography question.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:08:03 -04:00
jared ee38b1e76a fix: remove unused variable asker in cmd_q (ruff F841)
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 4s
Lint / Secret scan (gitleaks) (push) Has been cancelled
Lint / Python deps (pip-audit) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:07:24 -04:00
jared a7a3891d1c fix: 20q answer dedup cache — prevent repeated answers
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (ruff) (push) Failing after 10s
Lint / Python deps (pip-audit) (push) Successful in 57s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Add a rolling cache of the last 30 answers (persisted to
twentyq_cache.json) and pass the recent list to the model as an
explicit avoid clause. Also prompt the model to vary categories
each round. If the model still returns a cached answer it is
rejected and one retry is attempted automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:57:47 -04:00
jared ad09286e27 fix: 20q now answers any question, not just yes/no
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Failing after 5s
Lint / Python deps (pip-audit) (push) Successful in 41s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Allow open-ended questions like "What color is it?" or "How big is it?"
The model now gives brief descriptive answers (capped at 12 words) for
open questions while still answering Yes/No/Sometimes/Partly for binary
ones. Updated command descriptions and in-game prompts accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:47:11 -04:00
jared d095c34276 fix: ttt/triviaduel crash, blackjack per-player, improve hottake/nhie prompts
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Failing after 5s
Lint / Python deps (pip-audit) (push) Successful in 43s
Lint / Secret scan (gitleaks) (push) Successful in 5s
- Import MATRIX_USER_ID in commands.py (was missing — caused !ttt and
  !triviaduel to crash with NameError on every invocation)
- Blackjack is now per-player per-room: multiple players can each run
  their own game simultaneously; !hit and !stand operate on the caller's
  own game only
- !hottake: pick a random topic from 20 categories and pass it to the
  model so takes aren't all nostalgia-flavoured
- !nhie: tighter prompt with topic rotation and a word-count cap so
  generated scenarios are simpler and more relatable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:56:30 -04:00
jared 54c73535b8 feat: add 9 new games and update help/README
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Failing after 5s
Lint / Python deps (pip-audit) (push) Successful in 41s
Lint / Secret scan (gitleaks) (push) Successful in 5s
New commands: numguess/ng, wordchain/wc/endwc, acronym/ac,
20q/q/answer, nhie, hottake, ttt/move, blackjack/hit/stand,
triviaduel/da. All per-room with AI-generated content where
applicable. callbacks.py wired up for new reaction handlers
(acronym votes, nhie, hottake). Help and README updated with
full command reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 16:29:23 -04:00
jared e29263c3e2 hangman: redesign extended stages — drop confusing ear/arm overlap
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 41s
Lint / Secret scan (gitleaks) (push) Successful in 5s
The old \O/ ears row looked like 6 limbs when combined with /|\ arms.
New extended progression:
  0-6: same as standard (head → body → arms → legs)
  7:   left foot (/ in the previously empty row below legs)
  8:   both feet (/   \)
  9:   @ head (anguish — full figure visible)
  10:  X head (dead)

Each stage is visually distinct with no overlapping limb confusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 16:00:35 -04:00
jared 8effb24761 ci: fix pip-audit — restore --local, explicitly ignore pip's own CVE
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 48s
Lint / Secret scan (gitleaks) (push) Successful in 5s
-r requirements.txt causes pip-audit to spawn an internal venv which
calls ensurepip, failing with exit 127 on the standalone Python build.
--local avoids the venv. CVE-2026-3219 is in pip itself (not our deps)
so we ignore it explicitly with --ignore-vuln.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:46:16 -04:00
jared 407e66e499 fix: add guess to help, document 8ball --debug, fix pip-audit CVE false positive
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Failing after 23s
Lint / Secret scan (gitleaks) (push) Successful in 6s
- !help Games section now includes !guess
- !8ball description mentions --debug flag
- pip-audit now scans only requirements.txt instead of --local (which
  was flagging CVE-2026-3219 in pip itself, not our dependencies)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:43:30 -04:00
jared 49cc0b3d75 hangman: add --hard and --extended flags
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Failing after 42s
Lint / Secret scan (gitleaks) (push) Successful in 6s
--hard (-h): words 9-15 letters instead of 5-8
--extended (-e): 10 wrong guesses with full body (feet + ears stages)
Flags are combinable: !hangman --hard --extended

Board header shows mode emoji (🔥 hard, 💀 extended, 💀🔥 both).
Wrong counter shows X/10 in extended mode. All guess logic reads
max_wrong from game dict instead of hardcoded 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:40:51 -04:00
jared 22313b4a41 hangman: persist last 30 words to disk to prevent duplicates
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Failing after 43s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Mirrors riddle/trivia cache pattern: loads hangman_cache.json on startup,
appends each new word, caps at 30, saves after each game. Recent words
are passed to the model prompt to avoid repeats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:33:54 -04:00
jared 6eb435010d hangman: add retry + log validation failures for word generation
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Failing after 42s
Lint / Secret scan (gitleaks) (push) Successful in 7s
Silent failures (word too long/short, has hyphens, empty hint) are now
logged as warnings showing the actual word/hint returned. Retry up to
2 times before giving up, matching riddle's behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:19:58 -04:00
jared 0a486c2176 8ball: constrain general responses to actual yes/no/maybe style
Lint / Shell (shellcheck) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Failing after 42s
Lint / Secret scan (gitleaks) (push) Successful in 5s
The creative model was producing fortune-cookie mysticism ("navigate
the curve of fate") instead of 8-ball answers. New system prompt
explicitly requires YES/NO/UNCERTAIN category answers, 2-6 words,
funny and direct — no cryptic prophecies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:45:00 -04:00
jared f4e6e6f9fe debate: switch to CREATIVE_MODEL with system prompt for uncensored arguments
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 45s
Lint / Secret scan (gitleaks) (push) Successful in 5s
phi4-mini refused to argue FOR controversial topics, instead deflecting
with neutral takes. The abliterated model with a committed debater system
prompt will actually take both sides without hedging or disclaimers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:59:02 -04:00
jared 8fc734643b fix: robust JSON extraction with try/except + retry in all AI commands
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 53s
Lint / Secret scan (gitleaks) (push) Successful in 9s
- Added _extract_riddle_answer() with dual fallback: JSON parse first,
  then regex extraction of quoted riddle/answer values directly from text
- _generate_riddle() now retries up to 2 times on parse/network failure
- Hangman, scramble, WYR, and trivia now catch JSONDecodeError and log
  the raw model output instead of letting the exception propagate silently

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:03:18 -04:00
jared 78c01cf5f2 riddle/trivia: persist dedup caches to disk so restarts don't reset them
Lint / Shell (shellcheck) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 54s
Lint / Secret scan (gitleaks) (push) Successful in 7s
riddle_cache.json: stores last 30 riddle texts + answers
trivia_cache.json: stores last 20 questions per category

Both files are capped at their respective maxes so they never grow
unboundedly. Loaded on startup, saved after each new question.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:01:22 -04:00
jared a254bb9381 rename BALL_MODEL -> CREATIVE_MODEL
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 42s
Lint / Secret scan (gitleaks) (push) Successful in 6s
It's used for 8ball, roasts, riddles, WYR, and debate — not just the
magic 8-ball anymore. CREATIVE_MODEL better reflects its role as the
uncensored/abliterated model for creative generation tasks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:58:20 -04:00
jared 66f761b466 wyr/riddle: add model attribution + fix truncated WYR options
Lint / Shell (shellcheck) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Secret scan (gitleaks) (push) Has been cancelled
Lint / Python deps (pip-audit) (push) Has been cancelled
wyr:
- Reject options that end on a dangling word (but/and/or/with/never etc.)
  so truncated sentences like 'but never' return None and retry
- Add 'via Llama 3.2 3B (abliterated)' credit to the poll message

riddle:
- Add 'via Llama 3.2 3B (abliterated)' credit to the riddle message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:57:11 -04:00
jared bc50e8205a trivia: strengthen prompt to prevent hallucinated/incoherent questions
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (ruff) (push) Successful in 10s
Lint / Python deps (pip-audit) (push) Successful in 1m11s
Lint / Secret scan (gitleaks) (push) Successful in 8s
The previous system prompt was basically empty. Now it explicitly:
- Requires the answer to be unambiguously correct
- Bans vague, ambiguous, or invented facts
- Requires plausible-but-wrong distractors
- Includes a concrete example of a good question
- Tells the model to pick a simpler topic if unsure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:51:55 -04:00
jared 8bbcc0530f riddle: switch to abliterated model to stop shadow/fire loops
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 43s
Lint / Secret scan (gitleaks) (push) Successful in 5s
phi4-mini is too conservative and defaults to the same 2-3 answers.
Use BALL_MODEL (abliterated Llama 3.2) like WYR does.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:48:32 -04:00
jared 63f1bfda49 riddle/wyr: fix repeat shadow answers and truncate long WYR options
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 11s
Lint / Python deps (pip-audit) (push) Successful in 1m26s
Lint / Secret scan (gitleaks) (push) Successful in 13s
riddle:
- Cache answers separately so the same answer (e.g. 'shadow') can't
  appear twice in a session even if the riddle text differs
- Explicitly ban 'shadow' in the prompt and append avoid-answers clause
- Ban question endings ('what am I?', 'what could it be?') more strictly

wyr:
- Hard-cap options at 10 words server-side so the model can't ignore
  the word limit and generate paragraph-length options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:34:03 -04:00
jared bc84507e64 wyr: few-shot examples + rebuild question from options + switch to abliterated model
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / Python (ruff) (push) Has been cancelled
Lint / JS (eslint) (push) Has been cancelled
Lint / Python deps (pip-audit) (push) Has been cancelled
Lint / Secret scan (gitleaks) (push) Has been cancelled
- Add 3 assistant-turn examples to lock in the JSON format and tone
- Construct the 'question' field from option_a/option_b so it's always
  well-formed regardless of what the model puts in the 'question' key
- Switch from phi4-mini to the abliterated Llama 3.2 model for edgier,
  uncensored dilemmas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:23:01 -04:00
jared b6e99d165b riddle/wyr: fix quality issues from tonight's chat
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 48s
Lint / Secret scan (gitleaks) (push) Successful in 12s
riddle:
- Tighten generation prompt with explicit rules: specific noun answer,
  no answer word in the riddle, no 'what could it possibly mean', clues
  must logically point to ONE answer, prefer concrete things
- Fix answer matching: strip articles (a/an/the), allow partial match
  so 'person' hits 'a person' and 'shadow' hits 'my shadow' etc.

wyr:
- Prompt now asks for genuinely difficult dilemmas with real downsides
  on both sides; explicitly bans boring options like dolphins/karaoke

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:17:44 -04:00
jared 2457451d4c riddle: add dedup cache to prevent repeated riddles
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 43s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Keep a rolling list of the last 30 riddles used and inject them into
the prompt as an avoid clause, same pattern as trivia's per-category cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 20:28:43 -04:00
jared 80d77a8a0f fix: wyr votes never counted — reactions arrive as ReactionEvent not UnknownEvent
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 11s
Lint / Python deps (pip-audit) (push) Successful in 49s
Lint / Secret scan (gitleaks) (push) Successful in 5s
nio has a dedicated ReactionEvent type with .reacts_to and .key attributes.
The callback was registered for UnknownEvent so reaction events were silently
dropped. Register for ReactionEvent and use its native attributes; keep the
UnknownEvent fallback for edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:00:03 -04:00
jared 126979f5cb hangman: edit board in place + fix ASCII art rendering; wyr: debug reaction logging
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 44s
Lint / Secret scan (gitleaks) (push) Successful in 6s
- Add edit_html() to utils using m.replace so messages can be updated
- Hangman board now edits in place on every guess — shows progressing
  ASCII figure as wrong guesses accumulate instead of spamming new messages
- Extract _hangman_board_html() helper for consistent board rendering
- wyr: add INFO-level logging to reaction callback to diagnose vote tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:30:46 -04:00
jared 3405ab8b32 8ball: add third-party branch for Jared asking about someone other than himself/Wynter
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 45s
Lint / Secret scan (gitleaks) (push) Successful in 5s
When Jared asks about a @mentioned third party, give a neutral honest
prediction instead of hijacking the answer to be about Jared.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:22:49 -04:00
jared 6e0a738552 8ball leon: speak TO Leon in 2nd person, not AS Leon in 1st person
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 48s
Lint / Secret scan (gitleaks) (push) Successful in 6s
The oracle should address Leon ('you survived Raccoon City...') not
impersonate him ('I'm not buying it').

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:16:22 -04:00
jared 40e921a9da 8ball leon: switch to api/chat so system persona actually sticks
Lint / Shell (shellcheck) (push) Successful in 22s
Lint / JS (eslint) (push) Successful in 15s
Lint / Python (ruff) (push) Successful in 21s
Lint / Python deps (pip-audit) (push) Successful in 2m2s
Lint / Secret scan (gitleaks) (push) Successful in 12s
api/generate has no system role — the model was ignoring the character
context and giving generic one-word answers. Chat API with a proper
system message forces the Leon voice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:00:59 -04:00
jared 868bca6494 8ball/roast: add Leon S. Kennedy context for stranger_danger
Lint / Shell (shellcheck) (push) Successful in 35s
Lint / JS (eslint) (push) Successful in 15s
Lint / Python (ruff) (push) Successful in 13s
Lint / Python deps (pip-audit) (push) Successful in 1m46s
Lint / Secret scan (gitleaks) (push) Successful in 14s
- 8ball: stranger_danger gets RE-universe flavored responses drawing on
  Leon's lore (Raccoon City, Ada Wong, bioweapon ops, dry wit)
- roast: stranger_danger/leon added to _ROAST_LORE lookup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:56:06 -04:00
jared 115749e232 hangman: fix display never showing guessed letters + improve word-guess feedback
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 44s
Lint / Secret scan (gitleaks) (push) Successful in 4s
- _hangman_display compared uppercase word chars against lowercase
  guessed_letters set, so letters were never revealed after correct guesses
- Word guess wrong path now shows the board and remaining guesses
- Winner display now includes the guesser's name on correct word guess

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 01:04:27 -04:00
jared c9d9febbe0 wyr: track real reaction votes and announce winner with counts
Lint / Shell (shellcheck) (push) Successful in 27s
Lint / JS (eslint) (push) Successful in 14s
Lint / Python (ruff) (push) Successful in 11s
Lint / Python deps (pip-audit) (push) Successful in 45s
Lint / Secret scan (gitleaks) (push) Successful in 5s
- Add _WYR_POLLS dict keyed by poll event_id to accumulate votes
- record_wyr_vote() called from callbacks.reaction() on every reaction
- reveal() reads actual vote counts and announces winner with percentage
- Handles tie and zero-vote cases
- Remove the useless 'check the reactions above' message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 01:02:25 -04:00
jared 6c00e8b4fd fix: raise Ollama timeout from 20s to 60s for all game generators
Lint / Shell (shellcheck) (push) Successful in 14s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 45s
Lint / Secret scan (gitleaks) (push) Successful in 5s
phi4-mini can queue behind other requests and take >20s under load,
causing TimeoutError and silent failures in wyr/riddle/hangman/scramble.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:57:54 -04:00
jared 82a3f24519 fix: switch all JSON-returning game generators to api/chat + robust parsing
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m0s
Lint / Secret scan (gitleaks) (push) Successful in 7s
hangman, scramble, riddle, and wyr all used api/generate which has no
system role. The model would wrap JSON in prose or markdown fences,
causing json.loads() to throw and the command to silently die after
the 'Generating...' message.

Fix for all four: switch to api/chat with a system message enforcing
raw JSON output, strip markdown fences, and use regex to extract the
JSON object even if surrounded by extra text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:55:45 -04:00
jared a47648435e roast: expand Nathan lore with education background
Lint / Shell (shellcheck) (push) Successful in 13s
Lint / JS (eslint) (push) Successful in 9s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 47s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:53:33 -04:00
jared 4213449a88 wyr: fix JSON parsing failure causing silent no-op after 'Generating...'
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 7s
Lint / Secret scan (gitleaks) (push) Has been cancelled
Lint / Python deps (pip-audit) (push) Has been cancelled
Switch to api/chat with a system prompt for better JSON compliance,
and use regex extraction to find the JSON object even if the model
wraps it in extra text or markdown fences.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:52:39 -04:00
jared 83a4a2ffae roast: use chat API with system prompt + few-shot example for actual biting roasts
Lint / Shell (shellcheck) (push) Successful in 13s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 40s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Switch from api/generate to api/chat so we can set a system role that
instructs the model to be genuinely savage. Add a few-shot example so
it knows what a roast looks like vs a backhanded compliment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:50:10 -04:00
jared acf68038d8 roast: fix refusal prompt, add Cole/Nathan lore, expand known users
Lint / Shell (shellcheck) (push) Successful in 15s
Lint / JS (eslint) (push) Successful in 11s
Lint / Python (ruff) (push) Successful in 15s
Lint / Secret scan (gitleaks) (push) Has been cancelled
Lint / Python deps (pip-audit) (push) Has been cancelled
- Reframe prompt as a consented comedy roast between friends so the
  model doesn't refuse on safety grounds
- Add lore for lonely (Cole, 23, dishwasher, gamer) and
  natcofragomatic (Nathan, DCO Tech 3 at AWS, ginger, tape-drive nerd)
- Use a lookup table (_ROAST_LORE) so adding new users is one line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:48:48 -04:00
jared 0dada4c2b7 lint: fix E741 ambiguous variable names and F841 unused variable
Lint / Shell (shellcheck) (push) Successful in 15s
Lint / JS (eslint) (push) Successful in 13s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 43s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:41:55 -04:00
jared e4dbcfde7a ping: always show round-trip time instead of only when >500ms
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Failing after 6s
Lint / Python deps (pip-audit) (push) Successful in 1m39s
Lint / Secret scan (gitleaks) (push) Successful in 9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:39:10 -04:00
jared 973e422678 feat: add 7 new commands — hangman, scramble, wyr, riddle, roast, story, debate
Lint / Shell (shellcheck) (push) Successful in 13s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Failing after 5s
Lint / Python deps (pip-audit) (push) Successful in 1m16s
Lint / Secret scan (gitleaks) (push) Successful in 5s
- !hangman: AI picks a 5-8 letter word with hint; players !guess letters/words, 6 wrong = dead
- !scramble: AI picks a word, scrambles it; first correct answer in chat wins (45s timeout)
- !wyr: AI generates Would You Rather with 🅰️/🅱️ reaction voting, 30s reveal
- !riddle: AI generates riddle monitored for 60s, substring match in chat wins
- !roast: AI roasts a target using BALL_MODEL with special Jared/Wynter lore
- !story: collaborative story with !story add <line> and !story end (AI conclusion, max 10 lines)
- !debate: AI writes FOR/AGAINST arguments for any topic using ASK_MODEL
- callbacks.py: route all non-command messages through scramble/riddle answer checkers
- help: updated categories to include all new commands
2026-04-22 00:35:19 -04:00
jared 9015338a1c help: move 8ball to AI category
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 43s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:15:46 -04:00
jared fb39b17473 trivia: per-category fallbacks, always show model attribution
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m14s
Lint / Secret scan (gitleaks) (push) Successful in 10s
- Replace flat fallback list with per-category fallback dict so
  !trivia music never shows a gaming question when AI is down
- Always show "via <model>" tag on AI questions; show warning tag
  on static fallbacks so users know AI was unavailable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:05:29 -04:00
jared 876c7d26d4 trivia: add 8 new categories + per-category dedup cache
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 56s
Lint / Secret scan (gitleaks) (push) Successful in 5s
New categories: anime, sports, food, history, geography, nature,
mythology, tv (14 total).

Add _trivia_recent dict that tracks the last 20 questions per
category and injects them into the LLM prompt as a avoid list,
preventing duplicate questions within a session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:53:57 -04:00
jared caf9ad806a 8ball: romantic-question-aware fallbacks for Wynter branches
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m6s
Lint / Secret scan (gitleaks) (push) Successful in 5s
When Wynter asks a romantic question about Jared ("is he in love
with me", "does he miss me", etc.) the LLM fallback now explicitly
denies the premise instead of giving a generic Jared-wins response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:35:47 -04:00
jared 896b76d6ab 8ball: enforce no-romance lore + AI responses for all users
Lint / Shell (shellcheck) (push) Successful in 22s
Lint / JS (eslint) (push) Successful in 25s
Lint / Python (ruff) (push) Successful in 12s
Lint / Python deps (pip-audit) (push) Successful in 1m36s
Lint / Secret scan (gitleaks) (push) Successful in 6s
- Add explicit Jared/Wynter no-romance lore to all four branch
  bio_contexts and prompts — prevents model from implying romantic
  feelings between them
- Add _implies_jared_wynter_romance() validator; responses that
  suggest romantic connection fall back to the static fallback
- Replace random-list responses for non-Jared/Wynter senders with
  AI-generated magic 8-ball predictions via BALL_MODEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:25:47 -04:00
jared dcb38618a7 ci: upgrade pip+setuptools before audit to clear bundled CVEs
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 48s
Lint / Secret scan (gitleaks) (push) Successful in 8s
The python-build-standalone tarball ships pip 24.1.2 and setuptools
70.3.0 which have known CVEs. Upgrade them first so --local audit
only sees current, patched versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:06:01 -04:00
jared f6ce517a69 ci: use pip-audit --local to avoid internal venv ensurepip failure
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 7s
Lint / Python deps (pip-audit) (push) Failing after 47s
Lint / Secret scan (gitleaks) (push) Successful in 5s
The standalone Python 3.10 binary's venv ensurepip step exits 127.
Workaround: install requirements + pip-audit into the same env,
then audit with --local (no internal venv creation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:03:19 -04:00
jared 353695f8c3 ci: use python-build-standalone 3.10 binary for pip-audit
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Failing after 34s
Lint / Secret scan (gitleaks) (push) Successful in 8s
Debian Bullseye only ships Python 3.9 and python3.10 is not in its
repos. python-dotenv 1.2.2 (vuln fix) requires Python >=3.10.
Use indygreg/python-build-standalone to get a self-contained Python
3.10.15 binary that works on any glibc Linux runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:00:36 -04:00
jared a85ea312c9 ci: bootstrap pip via ensurepip for python3.10 (no venv package on Debian)
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Failing after 4m41s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:50:40 -04:00
jared d4f3563982 ci: use python3.10 for pip-audit (dotenv 1.2.2 requires >=3.10)
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Failing after 7s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:48:53 -04:00
jared 0a1c90ef86 deps: pin python-dotenv>=1.2.2 to fix GHSA-mf9w-mj56-hr94
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Failing after 1m40s
Lint / Secret scan (gitleaks) (push) Successful in 31s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:45:25 -04:00
jared 0bc9373bd9 8ball: add --debug flag to show post-processed prompt
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Failing after 1m11s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:41:59 -04:00
jared 4048659e28 commands: remove deleted models from display map
Lint / Shell (shellcheck) (push) Successful in 16s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m9s
Lint / Secret scan (gitleaks) (push) Successful in 5s
lotusllm, lotusllmben, and llama3.3 70B have been removed from
Ollama on LXC 130 to free ~44 GB disk space.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 00:41:02 -04:00
jared f77fdbc7bb commands: improve model display names with variant labels
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m10s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Add uncensored/abliterated tags and accurate parameter counts
to all model display names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 00:38:37 -04:00
jared bfedd34f1f models: 8ball → llama3.2-abliterate 3B, ask/fortune → phi4-mini
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 1m11s
Lint / Secret scan (gitleaks) (push) Successful in 12s
- BALL_MODEL: huihui_ai/llama3.2-abliterate:3b (abliterated 3B,
  follows complex persona instructions without censorship)
- ASK_MODEL + OLLAMA_MODEL: phi4-mini:latest (Phi-4 Mini 3.8B,
  best instruction-following model available within GPU VRAM)
- Update _MODEL_DISPLAY for new model names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 00:33:20 -04:00
jared 21a64174e6 8ball: fix substring pronoun bug, switch to 3B model
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m27s
Lint / Secret scan (gitleaks) (push) Successful in 7s
- Fix about_jared/about_wynter using substring match — "they" matched
  "he", "theme" matched "he", etc., routing Wynter's questions to the
  wrong branch. Now uses \b word boundaries via re.search.
- Switch BALL_MODEL default from sadiq-bd 1B uncensored to
  llama3.2:latest (3B) — the 1B model hallucinates, ignores persona
  instructions, and mentions Jared randomly. GPU is now working on
  Arc A380 at ~25 tok/s so the larger model is practical.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 00:21:42 -04:00
jared f7ca1b00db ask: switch to llama3.2:latest, increase timeout to 120s
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m10s
Lint / Secret scan (gitleaks) (push) Successful in 5s
gemma3:latest produces garbage output on the Vulkan backend (Intel Arc A380).
llama3.2:latest runs correctly at 100% GPU. Timeout bumped to 120s to handle
cold model loads (~22s) without timing out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:49:08 -04:00
jared 1ba1151673 help: move 8ball from AI to Games category
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m18s
Lint / Secret scan (gitleaks) (push) Successful in 5s
8ball is only AI-powered for specific users (Wynter/Jared); for everyone
else it's a random static response. Games is the correct category.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:32:43 -04:00
jared 05c83e8ad1 8ball: suppress model attribution on fallback, vary fallback responses
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 1m10s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Model attribution is now only shown when the LLM actually generated the
response. If the model refused or gave an invalid answer and we fell back
to the static response, no 'via ...' line is shown.

Fallback responses for all three Wynter branches are now randomised pools
so the bot doesn't always give the same flat yes/no phrase regardless of
what Wynter actually typed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:29:20 -04:00
jared 43903af22e Improve help command, model attribution, and model config
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 1m25s
Lint / Secret scan (gitleaks) (push) Successful in 5s
help: grouped into AI / Games / Random / Server categories with Option B
purple header; descriptions auto-pulled from the command registry.

Model attribution: added _MODEL_DISPLAY map so 'via lotusllm' becomes
'via Llama 3.2 1B', 'via gemma3:latest' becomes 'via Gemma 3 4B', etc.

Config: OLLAMA_MODEL switched from lotusllm to llama3.2:latest; added
BALL_MODEL (sadiq-bd/llama3.2-1b-uncensored) as a dedicated config var
for the 8ball so it stays on the uncensored model without affecting fortune.

Descriptions: fortune -> AI-generated fortune cookie; ask -> Ask LotusBot;
health -> Bot health & stats (admin only).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:27:14 -04:00
jared bb5307c06b 8ball: address Wynter in second person when she's the asker
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 9s
Lint / Python deps (pip-audit) (push) Successful in 1m8s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Responding 'Wynter is too busy...' in third person to someone who just
asked 'will I...' feels disconnected. Changed the prompt to speak
directly to Wynter using you/your, with her name used only for emphasis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:15:45 -04:00
jared 6f5964ffe5 8ball: force name usage over she/her pronouns for Wynter
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Secret scan (gitleaks) (push) Has been cancelled
Lint / Python deps (pip-audit) (push) Has been cancelled
The LLM was responding with 'She's far too busy...' instead of using
'Wynter' by name. Added explicit instruction to both Wynter branches
to always refer to her by name and never use she/her pronouns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:14:33 -04:00
jared 639689bc0d Style: Option B HTML styling across all AI commands
Lint / Shell (shellcheck) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 14s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 2m0s
Lint / Secret scan (gitleaks) (push) Successful in 7s
8ball: color-coded answer text (green=positive, red=negative, amber=neutral)
for both the random and Jared/Wynter AI branches; question shown as small
italic below the answer; AI responses include model attribution.

fortune: teal header, answer in blockquote italics, model attribution shown
only when response came from the LLM (not the static fallback list).

ask: purple header, question in italic, response in blockquote, model
attribution at bottom.

trivia: blue header with category, green reveal answer, model attribution
shown only for LLM-generated questions (not static fallbacks).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:52:16 -04:00
jared 58d8987e32 README: remove stale phase status line
Lint / Shell (shellcheck) (push) Successful in 13s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 1m10s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:16:44 -04:00
jared ef9ff1106c README: remove Priority Order section, drop vCPUs from infra table
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Has been cancelled
Lint / Python deps (pip-audit) (push) Has been cancelled
Lint / Secret scan (gitleaks) (push) Has been cancelled
Priority Order is stale project tracking that doesn't belong in a README.
vCPUs removed from the infrastructure table — containers are HA and can
migrate between physical hosts so pinning a CPU model is misleading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:16:15 -04:00
jared e14b9a274f ask: instruct LLM not to ask follow-up questions
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m6s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Each !ask call is stateless — no context is retained between commands,
so ending a response with a question is misleading. Added explicit
instruction to the system prompt to prevent this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:10:26 -04:00
jared 637b2a4b20 Upgrade fortune, ask, and trivia commands to use Ollama LLM
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 1m36s
Lint / Secret scan (gitleaks) (push) Successful in 5s
fortune: generates a fresh witty one-liner via Ollama on every call,
falls back to static list if LLM is unavailable.

ask: switched to /api/chat endpoint with a system prompt for better
conversational quality; now uses ASK_MODEL (default: gemma3:latest)
separately from the 8ball OLLAMA_MODEL so each can be tuned independently.

trivia: LLM generates a fresh question each time (no more repeating the
same 25 questions); supports !trivia <category> with six categories
(gaming, tech, general, movies, music, science); falls back to static
questions if JSON generation fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:07:01 -04:00
jared 86cb78d74d Fix ruff lint errors across matrixbot (F401, F841, E402)
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (ruff) (push) Successful in 10s
Lint / Python deps (pip-audit) (push) Successful in 1m10s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Remove unused imports: logging from bot.py and config.py, RoomMessageText/
UnknownEvent from callbacks.py, functools.partial and MAX_INPUT_LENGTH from
commands.py. Rename unused local variables to _ (resp in cmd_ping, symbols in
render_keyboard_plain, guesses_left in two wordle functions). Move wordle import
to top of commands.py to fix E402.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:54:55 -04:00
jared d2983eca23 Fix ruff binary extraction; fix gitleaks to scan app dirs only
Lint / Shell (shellcheck) (push) Successful in 13s
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (ruff) (push) Failing after 8s
Lint / Python deps (pip-audit) (push) Successful in 1m18s
Lint / Secret scan (gitleaks) (push) Successful in 5s
- ruff: add --strip-components=1 to tar extract; the tarball puts the
  binary inside ruff-x86_64-unknown-linux-gnu/ not at the root
- gitleaks: path-based allowlists are broken in v8.21.2 --no-git mode
  (tested down to bare substrings — still fires). Switched to scanning
  only application code directories (matrixbot/, hookshot/, .gitea/,
  systemd/, cinny/, landing/) which excludes deploy/ where the
  intentional Gitea webhook HMAC secrets live. Also removed the
  .gitleaks-baseline.json from the repo (it was flagging itself).
  The .gitleaks.toml is kept for any future per-rule overrides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:48:06 -04:00
jared 78d1645f08 Fix all CI jobs: ruff binary, pip-audit venv, gitleaks baseline
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Failing after 4s
Lint / Python deps (pip-audit) (push) Successful in 1m5s
Lint / Secret scan (gitleaks) (push) Failing after 5s
- ruff: download standalone binary instead of using python3 -m ruff
  (runner image lacks the PATH entry for pip-installed bin scripts)
- pip-audit: add python3-venv to apt install (pip-audit creates a venv
  internally to resolve deps; ensurepip was missing)
- gitleaks: switch from stopwords allowlist to --baseline-path approach.
  Stopwords don't suppress findings from git history scans. The baseline
  records the 4 known-intentional webhook HMAC secrets; CI now only
  fails on findings NOT in the baseline (i.e. newly introduced secrets)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:36:59 -04:00
jared 371ed8116f Fix Python runner; add gitleaks secret scanning
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Failing after 42s
Lint / Python deps (pip-audit) (push) Failing after 47s
Lint / Secret scan (gitleaks) (push) Failing after 9s
- All Python jobs now install python3-pip via apt first (runner image
  has no pip by default)
- Added secret-scan job: gitleaks v8.21.2 scans full git history on
  every push/PR with --redact to avoid leaking found secrets in logs
- Added .gitleaks.toml allowlisting deploy/hooks-lxc*.json files
  (webhook HMAC secrets are intentional config, not leaks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:29:14 -04:00
jared d49b33fc42 Fix pip → python3 -m pip in ruff job; add pip-audit dep scan
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 9s
Lint / Python (ruff) (push) Failing after 6s
Lint / Python deps (pip-audit) (push) Failing after 7s
- python3 -m pip works in the act runner where bare 'pip' isn't in PATH
- Added python-audit job: pip-audit checks matrixbot/requirements.txt
  against the OSV database for known CVEs on every push/PR

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:26:03 -04:00
jared 0e76c8b51c Fix Jared-asks-about-Wynter branch; add Python lint to CI
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Failing after 7s
- When Jared asks a question containing Wynter's name, it now uses a
  dedicated mock-Wynter prompt instead of the generic positive-Jared
  one. The _is_positive_about_jared guard is also skipped for this
  branch so negative words aimed at Wynter don't trigger the fallback.
  Fallback changed from "Jared is absolutely right!" (nonsensical for
  Wynter questions) to "Sounds about right — Wynter had it coming."
- Added ruff Python lint job to .gitea/workflows/lint.yml covering
  matrixbot/ on every push and PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:23:59 -04:00
jared b9a251bd7a Integrate matrixbot into existing LXC 151 deploy hook
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 6s
Removed standalone matrixbot/deploy.sh — deploy is handled by the existing
webhook system. Added matrixbot/ block to deploy/lxc151-hookshot.sh: on push,
if any matrixbot/ file changed, source files are synced to /opt/matrixbot and
matrixbot.service is restarted automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:18:10 -04:00
jared 52c4781e64 Add matrixbot source to repo
All bot source files from LXC 151 (/opt/matrixbot) are now tracked here.
Secrets (.env, credentials.json), venv dirs, and runtime state files
(nio_store, welcome_state.json, wordle_stats.json) are excluded via .gitignore.
Includes deploy.sh to sync files to /opt/matrixbot and restart the service.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:16:38 -04:00
17 changed files with 9406 additions and 32 deletions
+63
View File
@@ -30,3 +30,66 @@ jobs:
- name: Run ESLint
run: npx eslint --ext .js hookshot/
python-lint:
name: Python (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install ruff
run: |
curl -sSL https://github.com/astral-sh/ruff/releases/download/0.8.6/ruff-x86_64-unknown-linux-gnu.tar.gz \
| tar -xz --strip-components=1
mv ruff /usr/local/bin/ruff
- name: Check syntax errors
run: ruff check matrixbot/ --select E9,F63,F7,F82 --output-format=github
- name: Run full lint
run: ruff check matrixbot/ --output-format=github
python-audit:
name: Python deps (pip-audit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Python 3.10 and pip-audit
run: |
# Debian Bullseye only ships Python 3.9; use a prebuilt standalone binary
curl -sSL "https://github.com/indygreg/python-build-standalone/releases/download/20241002/cpython-3.10.15+20241002-x86_64-unknown-linux-gnu-install_only.tar.gz" \
| tar -xz -C /opt
# Install bot deps + pip-audit into the same env; --local below avoids
# pip-audit creating an internal venv (ensurepip fails on standalone builds)
/opt/python/bin/pip install --upgrade pip setuptools
/opt/python/bin/pip install pip-audit -r matrixbot/requirements.txt
- name: Audit matrixbot dependencies
# --local scans the env without spawning a venv (required for standalone Python)
# CVE-2026-3219 is in pip itself, not our code — ignore it explicitly
run: /opt/python/bin/pip-audit --local --ignore-vuln CVE-2026-3219
secret-scan:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install gitleaks
run: |
curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
| tar -xz gitleaks
mv gitleaks /usr/local/bin/gitleaks
- name: Scan for secrets
run: |
# Scan application code directories — deploy/ is excluded because
# it contains intentional Gitea webhook HMAC secrets in hooks-lxc*.json
for dir in matrixbot/ hookshot/ .gitea/ systemd/ cinny/ landing/; do
[ -d "$dir" ] || continue
gitleaks detect --source "$dir" --no-git --config .gitleaks.toml \
--redact --exit-code 1
done
+2
View File
@@ -0,0 +1,2 @@
[extend]
useDefault = true
+81 -31
View File
@@ -6,25 +6,6 @@ Matrix server infrastructure for the Lotus Guild homeserver (`matrix.lotusguild.
**Repo**: https://code.lotusguild.org/LotusGuild/matrix
## Status: Phase 7 — Moderation & Client Customisation
---
## 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. ~~Voice stability & quality tuning~~
9. ~~Custom Cinny client (chat.lotusguild.org)~~
10. Custom emoji packs (partially finished)
11. Cinny custom branding (Lotus Guild theme)
12. ~~Draupnir moderation bot~~
13. Push notifications (Sygnal)
---
## Repo Structure
@@ -66,18 +47,18 @@ matrix/
## Infrastructure
| Service | IP | LXC | RAM | vCPUs | Disk | Versions |
|---------|----|-----|-----|-------|------|----------|
| Synapse | 10.10.10.29 | 151 | 8GB | 4 (Ryzen 9 7900) | 50GB | Synapse 1.149.0, LiveKit 1.9.11, hookshot 7.3.2, coturn latest |
| PostgreSQL 17 | 10.10.10.44 | 109 | 6GB | 3 (Ryzen 9 7900) | 30GB | PostgreSQL 17.9 |
| Cinny Web | 10.10.10.6 | 106 | 2GB | 1 | 8GB | Debian 12, nginx, Node 24, Cinny `dev` branch (nightly build) |
| Draupnir | 10.10.10.24 | 110 | 1GB | 2 (Ryzen 9 7900) | 10GB | Draupnir v2.9.0, Node.js v22 |
| Prometheus | 10.10.10.48 | 118 | — | — | — | Prometheus — scrapes all Matrix services |
| Grafana | 10.10.10.49 | 107 | — | — | — | Grafana 12.4.0 — dashboard.lotusguild.org |
| NPM | 10.10.10.27 | 139 | — | — | — | Nginx Proxy Manager + matrix landing page |
| Authelia | 10.10.10.36 | 167 | — | — | — | SSO/OIDC provider |
| LLDAP | 10.10.10.39 | 147 | — | — | — | LDAP user directory |
| Uptime Kuma | 10.10.10.25 | 101 | — | — | — | Uptime monitoring (micro1 node) |
| Service | IP | LXC | RAM | Disk | Versions |
|---------|----|-----|-----|------|----------|
| Synapse | 10.10.10.29 | 151 | 8GB | 50GB | Synapse 1.149.0, LiveKit 1.9.11, hookshot 7.3.2, coturn latest |
| PostgreSQL 17 | 10.10.10.44 | 109 | 6GB | 30GB | PostgreSQL 17.9 |
| Cinny Web | 10.10.10.6 | 106 | 2GB | 8GB | Debian 12, nginx, Node 24, Cinny `dev` branch (nightly build) |
| Draupnir | 10.10.10.24 | 110 | 1GB | 10GB | Draupnir v2.9.0, Node.js v22 |
| Prometheus | 10.10.10.48 | 118 | — | — | Prometheus — scrapes all Matrix services |
| Grafana | 10.10.10.49 | 107 | — | — | Grafana 12.4.0 — dashboard.lotusguild.org |
| NPM | 10.10.10.27 | 139 | — | — | Nginx Proxy Manager + matrix landing page |
| Authelia | 10.10.10.36 | 167 | — | — | SSO/OIDC provider |
| LLDAP | 10.10.10.39 | 147 | — | — | LDAP user directory |
| Uptime Kuma | 10.10.10.25 | 101 | — | — | Uptime monitoring (micro1 node) |
**Key paths on Synapse LXC (151):**
- Synapse config: `/etc/matrix-synapse/homeserver.yaml`
@@ -541,6 +522,75 @@ Periodic `TLS/TCP socket error: Connection reset by peer` in coturn logs. Normal
---
## LotusBot
LotusBot (`@lotusbot:matrix.lotusguild.org`) is a Matrix bot running on LXC 151 at `/opt/matrixbot/`.
All commands use the `!` prefix. Run `!help` in any room for the full list.
### AI / Fun
| Command | Description |
|---------|-------------|
| `!ask <question>` | Ask the AI anything |
| `!fortune` | Get a fortune cookie |
| `!8ball <question>` | Magic 8-ball (yes/no/maybe, funny style). `--debug` shows raw AI output |
| `!roast @user` | Roast someone |
| `!story <prompt>` | Generate a short story |
| `!debate <topic>` | AI argues both sides of a topic |
### Games
| Command | Description |
|---------|-------------|
| `!wordle` | Daily Wordle-style word game |
| `!trivia [category]` | Trivia question (gaming/tech/movies/music/science/anime/etc.) |
| `!rps <rock\|paper\|scissors>` | Rock Paper Scissors |
| `!poll <question> \| option1 \| option2...` | Create a reaction poll |
| `!hangman [--hard] [--extended]` | Hangman — `--hard` uses long words, `--extended` adds more body parts |
| `!guess <letter or word>` | Guess a letter or the full word in hangman |
| `!scramble` | Unscramble the word before time runs out |
| `!wyr` | Would You Rather — two AI-generated options, vote with reactions |
| `!riddle` | AI generates a riddle — try to solve it! |
| `!numguess` | Number Guess — bot picks 1100 |
| `!ng <number>` | Guess in an active number game (temperature hints included) |
| `!wordchain` | Word Chain — each word must start with the last letter of the previous |
| `!wc <word>` | Add a word to the chain |
| `!endwc` | End the word chain and see the final score |
| `!acronym` | AI picks an acronym — submit the funniest expansion with `!ac` then vote |
| `!ac <expansion>` | Submit an acronym expansion |
| `!20q` | 20 Questions — AI thinks of something, you ask yes/no questions |
| `!q <question>` | Ask a yes/no question in 20Q |
| `!answer <guess>` | Guess the answer in 20Q |
| `!nhie` | Never Have I Ever — react 🙋 (have) or 🙅 (never) |
| `!hottake` | AI generates a hot take — react 🔥 (agree) or 💧 (disagree) |
| `!ttt @user` | Tic-Tac-Toe — challenge someone |
| `!move <1-9>` | Make a move in Tic-Tac-Toe |
| `!blackjack` | Play Blackjack against the dealer |
| `!hit` | Draw another card in Blackjack |
| `!stand` | Stand — dealer plays out |
| `!triviaduel @user` | Trivia Duel — first-to-3 battle |
| `!da <A/B/C/D or answer>` | Answer in a Trivia Duel |
### Random
| Command | Description |
|---------|-------------|
| `!flip` | Flip a coin |
| `!roll [NdN]` | Roll dice (e.g. `!roll 2d6`) |
| `!random <min> <max>` | Random number in range |
| `!champion` | Pick a random champion |
| `!agent [role]` | Pick a random Valorant agent |
### Server
| Command | Description |
|---------|-------------|
| `!minecraft` | Check Minecraft server status |
| `!ping` | Check bot latency |
| `!health` | Bot health + uptime stats |
---
## Tech Stack
| Component | Technology | Version |
+20 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# Auto-deploy script for LXC 151 (matrix homeserver)
# Handles: hookshot transformation functions, livekit service file (graceful)
# Handles: hookshot transformation functions, livekit service file (graceful), matrixbot
# Triggered by: Gitea webhook on push to main
set -euo pipefail
@@ -45,6 +45,25 @@ else
touch /run/livekit-restart-pending
echo "Restart pending — will apply when no active calls."
fi
# Matrixbot source files
if echo "$CHANGED" | grep -q '^matrixbot/'; then
echo "Deploying matrixbot changes..."
BOT_DIR="/opt/matrixbot"
BOT_FILES="bot.py callbacks.py commands.py config.py utils.py welcome.py wordle.py wordlist_answers.py wordlist_valid.py requirements.txt"
for f in $BOT_FILES; do
if [ -f "$REPO_DIR/matrixbot/$f" ]; then
cp "$REPO_DIR/matrixbot/$f" "$BOT_DIR/$f"
fi
done
systemctl restart matrixbot
sleep 2
if systemctl is-active --quiet matrixbot; then
echo "matrixbot restarted successfully."
else
echo "ERROR: matrixbot failed to restart."
fi
fi
fi
echo "=== $(date) === LXC151 deploy complete ==="
+15
View File
@@ -0,0 +1,15 @@
MATRIX_HOMESERVER=https://matrix.lotusguild.org
MATRIX_USER_ID=@lotusbot:matrix.lotusguild.org
MATRIX_ACCESS_TOKEN=
MATRIX_DEVICE_ID=
BOT_PREFIX=!
ADMIN_USERS=@jared:matrix.lotusguild.org
LOG_LEVEL=INFO
# Integrations
OLLAMA_URL=http://10.10.10.157:11434
OLLAMA_MODEL=lotusllm
MINECRAFT_RCON_HOST=10.10.10.67
MINECRAFT_RCON_PORT=25575
MINECRAFT_RCON_PASSWORD=
COOLDOWN_SECONDS=120
+22
View File
@@ -0,0 +1,22 @@
# Python venv
bin/
lib/
lib64
include/
pyvenv.cfg
__pycache__/
*.pyc
*.pyo
# Secrets — never commit these
.env
credentials.json
# Runtime state
logs/
nio_store/
welcome_state.json
wordle_stats.json
# Stale copy
app/
+229
View File
@@ -0,0 +1,229 @@
# Lotus Matrix Bot & Server Roadmap
Matrix bot and server improvements for the Lotus Guild homeserver (`matrix.lotusguild.org`).
**Repo**: https://code.lotusguild.org/LotusGuild/matrixBot
## Status: Phase 3 — Bot Features & SSO
---
## Priority Order (suggested)
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. Custom Element Web (chat.lotusguild.org with branding)
8. Discord bridge (lets people transition gradually)
9. Custom emoji packs (makes it feel like home)
10. Moderation bot
11. Everything else
---
## Infrastructure
| Service | Host | IP | LXC | Notes |
|---------|------|-----|-----|-------|
| Synapse | micro1 | 10.10.10.29 | 151 | Homeserver + coturn + LiveKit |
| PostgreSQL 17 | micro1 | 10.10.10.44 | 109 | Synapse database backend |
| NPM | large1 | 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):**
- Synapse config: `/etc/matrix-synapse/homeserver.yaml`
- Synapse venv: `/opt/venvs/matrix-synapse/`
- coturn config: `/etc/turnserver.conf`
- LiveKit config: `/etc/livekit/config.yaml`
- LiveKit service: `livekit-server.service` (systemd)
- Synapse admin UI: `/var/www/synapse-admin/` (nginx on :8080)
- Landing page: `/var/www/matrix-landing/index.html` (on NPM LXC 139)
**Port forwarding (router -> 10.10.10.29):**
- TCP+UDP 3478 (TURN signaling)
- TCP 7881 (LiveKit TCP)
- UDP 49152-65535 (media relay)
- UDP 50100-50200 (LiveKit WebRTC media)
---
## Rooms (all v12)
| Room | Room ID | Join Rule | Bot In |
|------|---------|-----------|--------|
| The Lotus Guild (space) | `!gSynpxmopNrtoxeSvj` | public | — |
| General | `!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0` | public | no |
| Welcome | `!Y-wvNosuytqBOWampH9k-ta7bYXW7okqwBQ7PuRVBWE` | public | yes |
| Commands | `!ou56mVZQ8ZB7AhDYPmBV5_BR28WMZ4x5zwZkPCqjq1s` | restricted | yes |
| Management | `!mEvR5fe3jMmzwd-FwNygD72OY_yu8H3UP_N-57oK7MI` | invite | no |
| Memes | `!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U` | public | no |
| Cool Kids | `!R7DT3QZHG9P8QQvX6zsZYxjkKgmUucxDz_n31qNrC94` | invite | no |
**Power level roles (Cinny tags):**
- 100: Owner (jared)
- 50: The Nerdy Council (enhuynh, lonely)
- 48: Panel of Geeks
- 35: Cool Kids
- 0: Member
**Welcome room** has `events_default: 50` (users can't message) but `m.reaction: 0` (users can react to the welcome message to get invited to channels).
---
## Server - Quality of Life
- [x] Migrate from SQLite to PostgreSQL
- [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)
- [x] LiveKit server with systemd service for Element Call video rooms
- [x] Default room version set to v12, all rooms upgraded
- [x] Room publishing rules (jared + lotusbot can publish to directory)
- [ ] Enable push notifications gateway for mobile clients
## Server - Auth & SSO
- [x] Token-based registration (registration tokens shared in Discord)
- [x] SSO/OIDC via Authelia (`oidc_providers` in homeserver.yaml)
- [x] `allow_existing_users: true` for linking existing accounts to SSO
- [x] Password auth remains enabled alongside SSO
## Server - Hardening
- [x] Rate limiting configuration in Synapse
- [x] E2EE enabled on all rooms
- [ ] Federation allow/deny lists (decide if you want open federation or Lotus-only)
- [ ] Regular Synapse version updates
- [ ] Monitoring with Prometheus + Grafana
- [ ] Synapse worker mode if performance becomes an issue
## Server - Admin & Moderation
- [x] Synapse admin API dashboard (synapse-admin v0.11.1 at http://10.10.10.29:8080)
- [x] Power levels configured per room (Cinny tags for role hierarchy)
- [x] Invite-only registration flow (token-based)
- [ ] Set up Mjolnir or Draupnir (moderation bot for ban lists, spam protection)
- [ ] Set up room ACLs for federation control (block known-bad servers)
- [ ] 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
- [x] Set up "The Lotus Guild" space as top-level container
- [x] General, Welcome, Commands, Management, Memes, Cool Kids rooms
- [x] Welcome room with react-to-join onboarding
- [x] Bot commands room (Commands — keeps bot spam contained)
- [x] Voice/video call room (Element Call via LiveKit)
- [x] Custom room avatars with Lotus Guild branding
- [ ] Sub-spaces for categories (Gaming, Media, etc.)
- [ ] Read-only announcements room
- [ ] Game-specific rooms (Minecraft, Valorant, League, Hytale, etc.)
## 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
## Element/Client Customization
- [x] Landing page at matrix.lotusguild.org with client recommendations (Cinny, Commet, Element)
- [ ] Custom Element Web instance (self-hosted on chat.lotusguild.org)
- [ ] Custom theme with #980000 branding
- [ ] Configure .well-known to point clients to custom Element Web instance
## Widgets & Integrations
- [ ] RSS bot for game news feeds
- [ ] GitHub/Gitea notifications bot (push events to a dev room)
---
## Bot - Core Setup
- [x] Project scaffolding (`bot.py`, config, `.env`, requirements)
- [x] matrix-nio async client with E2EE support
- [x] Device verification / trust storage (auto-trust all devices)
- [x] Logging (rotating file + stdout)
- [x] Config validation (homeserver URL, access token, device ID)
- [x] Graceful shutdown handling (SIGTERM/SIGINT)
- [x] Initial sync with startup token (ignores old messages)
- [x] Auto-accept room invites
## Bot - Commands (all implemented)
- [x] `!help` — List all available commands
- [x] `!ping` — Bot latency check
- [x] `!8ball <question>` — Magic 8-ball
- [x] `!fortune` — Fortune cookie message
- [x] `!flip` — Coin flip
- [x] `!roll <NdS>` — Dice roller
- [x] `!random <min> <max>` — Random number generator
- [x] `!rps <choice>` — Rock Paper Scissors
- [x] `!poll <question>` — Poll (reactions)
- [x] `!trivia` — Trivia game (reactions, 30s reveal)
- [x] `!champion [lane]` — Random LoL champion picker
- [x] `!agent [role]` — Random Valorant agent picker
- [x] `!wordle` — Full Wordle game (daily puzzles, hard mode, stats, share)
## Bot - Integrations
- [x] `!minecraft <username>` — RCON whitelist
- [x] `!ask <question>` — Ollama LLM integration (lotusllm, 2min cooldown)
## Bot - Admin Commands
- [x] `!health` — Bot stats (uptime, command counts, service status)
## Bot - Welcome System
- [x] Auto-post welcome message in Welcome room on startup
- [x] React-to-join: users react with checkmark, bot invites to General, Commands, Memes
- [x] Welcome message event ID persisted to `welcome_state.json`
- [x] Reaction handler via `UnknownEvent` callback for `m.reaction` events
## Bot - Wordle
- [x] Daily puzzles with proper two-pass letter evaluation
- [x] Hard mode with constraint validation
- [x] Statistics tracking with persistence (`wordle_stats.json`)
- [x] Cinny-compatible rendering (inline `<span>` tiles instead of `<table>`)
- [x] DM-based gameplay (games happen in DMs, `!wordle share` posts to public room)
- [x] Virtual keyboard display with letter state tracking
## Bot - Deployment
- [ ] Systemd service (`matrixbot.service`)
- [ ] Auto-deploy from Gitea webhook
- [ ] Deployment script
- **Bot lives in**: Welcome (react-to-join) and Commands (all commands)
## Bot - Not Porting (Discord-specific)
- Reaction roles (replaced by react-to-join in Welcome room)
- Status cycling (Matrix presence is simpler)
- Guild-specific event handlers (channel create/delete, boost, etc.)
---
## 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)
- **LiveKit**: livekit-server on 10.10.10.29 (systemd, public IP 162.192.14.139)
- **SSO**: Authelia on 10.10.10.36 (OIDC provider, backed by LLDAP)
- **Dependencies**: matrix-nio[e2ee], aiohttp, python-dotenv, mcrcon
## Bot Files
```
matrixBot/
├── bot.py # Entry point, client setup, event callbacks
├── callbacks.py # Message + reaction event handlers
├── commands.py # Command registry + 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 template
└── requirements.txt # Python dependencies
```
+204
View File
@@ -0,0 +1,204 @@
import asyncio
import json
import signal
import sys
from pathlib import Path
from nio import (
AsyncClient,
AsyncClientConfig,
InviteMemberEvent,
LoginResponse,
ReactionEvent,
RoomMemberEvent,
RoomMessageText,
UnknownEvent,
)
from config import (
MATRIX_HOMESERVER,
MATRIX_USER_ID,
MATRIX_ACCESS_TOKEN,
MATRIX_DEVICE_ID,
MATRIX_PASSWORD,
LOG_LEVEL,
ConfigValidator,
)
from callbacks import Callbacks
from utils import setup_logging
from welcome import log_ready as _welcome_log_ready
logger = setup_logging(LOG_LEVEL)
CREDENTIALS_FILE = Path("credentials.json")
STORE_PATH = Path("nio_store")
def save_credentials(resp, homeserver):
data = {
"homeserver": homeserver,
"user_id": resp.user_id,
"device_id": resp.device_id,
"access_token": resp.access_token,
}
CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
logger.info("Credentials saved to %s", CREDENTIALS_FILE)
async def trust_devices(client: AsyncClient):
"""Query keys and trust all devices for all users we share rooms with."""
if not client.olm:
logger.warning("Olm not loaded, skipping device trust")
return
# Collect all users across all joined rooms
users = set()
for room in client.rooms.values():
for user_id in room.users:
users.add(user_id)
# Fetch device keys so the store is complete
if users:
await client.keys_query()
# Trust every device
for user_id, devices in client.device_store.items():
for device_id, olm_device in devices.items():
if not client.olm.is_device_verified(olm_device):
client.verify_device(olm_device)
logger.info("Trusted all known devices (%d users)", len(users))
async def main():
errors = ConfigValidator.validate()
if errors:
for e in errors:
logger.error(e)
sys.exit(1)
STORE_PATH.mkdir(exist_ok=True)
client_config = AsyncClientConfig(
store_sync_tokens=True,
encryption_enabled=True,
store_name="matrixbot",
)
client = AsyncClient(
MATRIX_HOMESERVER,
MATRIX_USER_ID,
device_id=MATRIX_DEVICE_ID or None,
config=client_config,
store_path=str(STORE_PATH),
)
# Try saved credentials first, then .env token, then password login
logged_in = False
has_creds = False
if CREDENTIALS_FILE.exists():
creds = json.loads(CREDENTIALS_FILE.read_text())
client.access_token = creds["access_token"]
client.user_id = creds["user_id"]
client.device_id = creds["device_id"]
has_creds = True
logger.info("Loaded credentials from %s", CREDENTIALS_FILE)
elif MATRIX_ACCESS_TOKEN and MATRIX_DEVICE_ID:
client.access_token = MATRIX_ACCESS_TOKEN
client.user_id = MATRIX_USER_ID
client.device_id = MATRIX_DEVICE_ID
has_creds = True
logger.info("Using access token from .env")
# Load the olm/e2ee store only if we have a device_id
if has_creds:
client.load_store()
# Test the token with a sync; if it fails, fall back to password login
if has_creds and client.access_token:
logger.info("Testing existing access token...")
sync_resp = await client.sync(timeout=30000, full_state=True)
if hasattr(sync_resp, "next_batch"):
logged_in = True
logger.info("Existing token is valid")
else:
logger.warning("Existing token is invalid, will try password login")
client.access_token = ""
if not logged_in:
if not MATRIX_PASSWORD:
logger.error("No valid token and no MATRIX_PASSWORD set — cannot authenticate")
await client.close()
sys.exit(1)
logger.info("Logging in with password...")
login_resp = await client.login(MATRIX_PASSWORD, device_name="LotusBot")
if isinstance(login_resp, LoginResponse):
logger.info("Password login successful, device_id=%s", login_resp.device_id)
save_credentials(login_resp, MATRIX_HOMESERVER)
client.load_store()
sync_resp = await client.sync(timeout=30000, full_state=True)
else:
logger.error("Password login failed: %s", login_resp)
await client.close()
sys.exit(1)
callbacks = Callbacks(client)
client.add_event_callback(callbacks.message, RoomMessageText)
client.add_event_callback(callbacks.reaction, ReactionEvent)
client.add_event_callback(callbacks.unknown_event, UnknownEvent)
client.add_event_callback(callbacks.member, RoomMemberEvent)
# Auto-accept room invites
async def _auto_accept_invite(room, event):
if event.membership == "invite" and event.state_key == MATRIX_USER_ID:
logger.info("Auto-accepting invite to %s", room.room_id)
await client.join(room.room_id)
client.add_event_callback(_auto_accept_invite, InviteMemberEvent)
# Graceful shutdown
loop = asyncio.get_running_loop()
shutdown_event = asyncio.Event()
def _signal_handler():
logger.info("Shutdown signal received")
shutdown_event.set()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _signal_handler)
# Mark startup complete from the initial sync
if hasattr(sync_resp, "next_batch"):
callbacks.startup_sync_token = sync_resp.next_batch
logger.info("Initial sync complete, token: %s", sync_resp.next_batch[:20])
else:
logger.error("Initial sync failed: %s", sync_resp)
await client.close()
sys.exit(1)
# Trust devices after initial sync loads the device store
await trust_devices(client)
_welcome_log_ready()
logger.info("Bot ready as %s — listening for commands", MATRIX_USER_ID)
# Run sync_forever in a task so we can cancel on shutdown
async def _sync_loop():
await client.sync_forever(timeout=30000, full_state=False)
sync_task = asyncio.create_task(_sync_loop())
await shutdown_event.wait()
sync_task.cancel()
try:
await sync_task
except asyncio.CancelledError:
pass
await client.close()
logger.info("Bot shut down cleanly")
if __name__ == "__main__":
asyncio.run(main())
+145
View File
@@ -0,0 +1,145 @@
import logging
from functools import wraps
from nio import AsyncClient
from config import BOT_PREFIX, MATRIX_USER_ID
from commands import (
COMMANDS,
metrics,
check_scramble_answer,
check_riddle_answer,
record_wyr_vote,
record_acronym_vote,
record_nhie_reaction,
record_hottake_reaction,
)
from welcome import handle_welcome_reaction, handle_space_join, SPACE_ROOM_ID
logger = logging.getLogger("matrixbot")
def handle_command_errors(func):
@wraps(func)
async def wrapper(client, room_id, sender, args):
try:
return await func(client, room_id, sender, args)
except Exception as e:
logger.error(f"Error in command {func.__name__}: {e}", exc_info=True)
metrics.record_error(func.__name__)
try:
from utils import send_text
await send_text(client, room_id, "An unexpected error occurred. Please try again later.")
except Exception as e2:
logger.error(f"Failed to send error message: {e2}", exc_info=True)
return wrapper
class Callbacks:
def __init__(self, client: AsyncClient):
self.client = client
# Track the sync token so we ignore old messages on startup
self.startup_sync_token = None
async def message(self, room, event):
# Ignore messages from before the bot started
if self.startup_sync_token is None:
return
# Ignore our own messages
if event.sender == MATRIX_USER_ID:
return
body = event.body.strip() if event.body else ""
# Check active non-command games that monitor all room messages
if body and not body.startswith(BOT_PREFIX):
await check_scramble_answer(self.client, room.room_id, event.sender, body)
await check_riddle_answer(self.client, room.room_id, event.sender, body)
return
if not body.startswith(BOT_PREFIX):
return
# Parse command and args
without_prefix = body[len(BOT_PREFIX):]
parts = without_prefix.split(None, 1)
cmd_name = parts[0].lower() if parts else ""
args = parts[1] if len(parts) > 1 else ""
logger.info(f"Command '{cmd_name}' from {event.sender} in {room.room_id}")
handler_entry = COMMANDS.get(cmd_name)
if handler_entry is None:
return
handler, _ = handler_entry
metrics.record_command(cmd_name)
wrapped = handle_command_errors(handler)
await wrapped(self.client, room.room_id, event.sender, args)
async def reaction(self, room, event):
"""Handle ReactionEvent (nio's native reaction type)."""
if self.startup_sync_token is None:
return
if event.sender == MATRIX_USER_ID:
return
reacted_event_id = event.reacts_to
key = event.key
logger.info("reaction: key=%r target=%s sender=%s", key, reacted_event_id[:16], event.sender)
await handle_welcome_reaction(self.client, room.room_id, event.sender, reacted_event_id, key)
record_wyr_vote(reacted_event_id, event.sender, key)
record_acronym_vote(reacted_event_id, event.sender, key)
record_nhie_reaction(reacted_event_id, event.sender, key)
record_hottake_reaction(reacted_event_id, event.sender, key)
async def unknown_event(self, room, event):
"""Fallback handler for UnknownEvent — catches any m.reaction not parsed by nio."""
if self.startup_sync_token is None:
return
if event.sender == MATRIX_USER_ID:
return
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", "")
logger.info("unknown_event reaction: key=%r target=%s sender=%s", key, reacted_event_id[:16], event.sender)
await handle_welcome_reaction(self.client, room.room_id, event.sender, reacted_event_id, key)
record_wyr_vote(reacted_event_id, event.sender, key)
record_acronym_vote(reacted_event_id, event.sender, key)
record_nhie_reaction(reacted_event_id, event.sender, key)
record_hottake_reaction(reacted_event_id, event.sender, 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)
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
import os
from dotenv import load_dotenv
load_dotenv()
# Required
MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://matrix.lotusguild.org")
MATRIX_USER_ID = os.getenv("MATRIX_USER_ID", "@lotusbot:matrix.lotusguild.org")
MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
MATRIX_DEVICE_ID = os.getenv("MATRIX_DEVICE_ID", "")
MATRIX_PASSWORD = os.getenv("MATRIX_PASSWORD", "")
# Bot settings
BOT_PREFIX = os.getenv("BOT_PREFIX", "!")
ADMIN_USERS = [u.strip() for u in os.getenv("ADMIN_USERS", "").split(",") if u.strip()]
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
# Integrations
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.10.10.157:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "phi4-mini:latest")
CREATIVE_MODEL = os.getenv("CREATIVE_MODEL", "huihui_ai/llama3.2-abliterate:3b")
ASK_MODEL = os.getenv("ASK_MODEL", "phi4-mini:latest")
MINECRAFT_RCON_HOST = os.getenv("MINECRAFT_RCON_HOST", "10.10.10.67")
MINECRAFT_RCON_PORT = int(os.getenv("MINECRAFT_RCON_PORT", "25575"))
MINECRAFT_RCON_PASSWORD = os.getenv("MINECRAFT_RCON_PASSWORD", "")
# Constants
MAX_INPUT_LENGTH = 500
MAX_DICE_SIDES = 100
MAX_DICE_COUNT = 20
COOLDOWN_SECONDS = int(os.getenv("COOLDOWN_SECONDS", "120"))
RCON_TIMEOUT = 5.0
MIN_USERNAME_LENGTH = 3
MAX_USERNAME_LENGTH = 16
class ConfigValidator:
REQUIRED = ["MATRIX_HOMESERVER", "MATRIX_USER_ID"]
@classmethod
def validate(cls):
errors = []
for var in cls.REQUIRED:
if not os.getenv(var):
errors.append(f"Missing required: {var}")
return errors
+5
View File
@@ -0,0 +1,5 @@
matrix-nio[e2e]
python-dotenv>=1.2.2
aiohttp
markdown
mcrcon
+157
View File
@@ -0,0 +1,157 @@
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from nio import AsyncClient, RoomSendResponse
from nio.exceptions import OlmUnverifiedDeviceError
from config import MAX_INPUT_LENGTH
def setup_logging(level="INFO"):
Path("logs").mkdir(exist_ok=True)
logger = logging.getLogger("matrixbot")
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
file_handler = RotatingFileHandler(
"logs/matrixbot.log",
maxBytes=10 * 1024 * 1024,
backupCount=5,
)
file_handler.setFormatter(
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
)
logger.addHandler(file_handler)
logger.addHandler(stream_handler)
return logger
def _trust_all(client: AsyncClient):
"""Trust all devices in the device store."""
if not client.olm:
return
for user_id, devices in client.device_store.items():
for device_id, olm_device in devices.items():
if not client.olm.is_device_verified(olm_device):
client.verify_device(olm_device)
async def _room_send_trusted(client: AsyncClient, room_id: str, message_type: str, content: dict):
"""Send a message, auto-trusting devices on OlmUnverifiedDeviceError."""
try:
return await client.room_send(
room_id, message_type=message_type, content=content,
ignore_unverified_devices=True,
)
except OlmUnverifiedDeviceError:
_trust_all(client)
return await client.room_send(
room_id, message_type=message_type, content=content,
ignore_unverified_devices=True,
)
async def send_text(client: AsyncClient, room_id: str, text: str):
logger = logging.getLogger("matrixbot")
resp = await _room_send_trusted(
client, room_id,
message_type="m.room.message",
content={"msgtype": "m.text", "body": text},
)
if not isinstance(resp, RoomSendResponse):
logger.error("send_text failed: %s", resp)
return resp
async def send_html(client: AsyncClient, room_id: str, plain: str, html: str):
logger = logging.getLogger("matrixbot")
resp = await _room_send_trusted(
client, room_id,
message_type="m.room.message",
content={
"msgtype": "m.text",
"body": plain,
"format": "org.matrix.custom.html",
"formatted_body": html,
},
)
if not isinstance(resp, RoomSendResponse):
logger.error("send_html failed: %s", resp)
return resp
async def edit_html(client: AsyncClient, room_id: str, event_id: str, plain: str, html: str):
"""Edit an existing message in place using m.replace."""
logger = logging.getLogger("matrixbot")
content = {
"msgtype": "m.text",
"body": f"* {plain}",
"format": "org.matrix.custom.html",
"formatted_body": html,
"m.new_content": {
"msgtype": "m.text",
"body": plain,
"format": "org.matrix.custom.html",
"formatted_body": html,
},
"m.relates_to": {
"rel_type": "m.replace",
"event_id": event_id,
},
}
resp = await _room_send_trusted(client, room_id, message_type="m.room.message", content=content)
if not isinstance(resp, RoomSendResponse):
logger.error("edit_html failed: %s", resp)
return resp
async def send_reaction(client: AsyncClient, room_id: str, event_id: str, emoji: str):
return await _room_send_trusted(
client, room_id,
message_type="m.reaction",
content={
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": event_id,
"key": emoji,
}
},
)
async def get_or_create_dm(client: AsyncClient, user_id: str) -> str | None:
"""Find an existing DM room with user_id, or create one. Returns room_id."""
logger = logging.getLogger("matrixbot")
# Check existing rooms for a DM with this user
for room_id, room in client.rooms.items():
if room.member_count == 2 and user_id in (m.user_id for m in room.users.values()):
return room_id
# Create a new DM room
from nio import RoomCreateResponse
resp = await client.room_create(
is_direct=True,
invite=[user_id],
)
if isinstance(resp, RoomCreateResponse):
logger.info("Created DM room %s with %s", resp.room_id, user_id)
# Sync so the new room appears in client.rooms before we try to send
await client.sync(timeout=5000)
return resp.room_id
logger.error("Failed to create DM room with %s: %s", user_id, resp)
return None
def sanitize_input(text: str, max_length: int = MAX_INPUT_LENGTH) -> str:
text = text.strip()[:max_length]
text = "".join(char for char in text if char.isprintable())
return text
+170
View File
@@ -0,0 +1,170 @@
"""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.
# Intentionally excludes: Management, Cool Kids, Spam and Stuff (invite-only),
# and Commands (knock-gated — access granted deliberately by admins).
INVITE_ROOMS = [
"!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0", # General
"!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U", # Memes
"!ktQu0gavhjpCMkgxk8SYdb6mnJRY-u7mY7_KfksV0SU", # Music
"!ARbRFSPNp2U0MslWTBGoTT3gbmJJ25dPRL6enQntvPo", # Voice
"!3gMjTHqV-r823ZrvXnck7waB0Pd8tiCu-zbF7mSS83E", # Voice 2
]
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)
def clean_stale_dm_messages() -> int:
"""Remove all pending welcome DM records. Returns count removed."""
state = _load_state()
pending = state.get("dm_welcome_messages", {})
count = len(pending)
if count:
state["dm_welcome_messages"] = {}
_save_state(state)
return count
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
# Skip if we already sent them a DM they haven't reacted to yet
pending = state.get("dm_welcome_messages", {})
if any(v["user"] == sender for v in pending.values()):
logger.debug("Already have a pending welcome DM for %s, skipping", sender)
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 public channels.\n\n"
"You'll be added to General, Memes, Music, and the Voice channels."
)
html = (
"<h3>Welcome to The Lotus Guild!</h3>"
f"<p>React to this message with {WELCOME_EMOJI} to get invited to all public channels.</p>"
"<p>You'll be added to <b>General</b>, <b>Memes</b>, <b>Music</b>, and the <b>Voice</b> channels.</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!")
def log_ready():
logger.info("Welcome module ready — watching Space for new members")
+821
View File
@@ -0,0 +1,821 @@
"""Wordle game for Matrix bot.
Full implementation with daily puzzles, statistics tracking,
hard mode, shareable results, and rich HTML rendering.
"""
import json
import logging
import time
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
import aiohttp
from nio import AsyncClient
from utils import send_text, send_html, get_or_create_dm
from config import BOT_PREFIX
from wordlist_answers import ANSWERS
from wordlist_valid import VALID_GUESSES
logger = logging.getLogger("matrixbot")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_WORDLE_EPOCH = date(2021, 6, 19)
# Cache: date string -> (word, puzzle_number)
_nyt_cache: dict[str, tuple[str, int]] = {}
# Build lookup sets at import time
_ANSWER_LIST = [w.upper() for w in ANSWERS]
_VALID_SET = frozenset(w.upper() for w in VALID_GUESSES) | frozenset(_ANSWER_LIST)
# Tile colors (Wordle official palette)
_TILE = {
2: {"bg": "#538d4e", "label": "correct"}, # Green
1: {"bg": "#b59f3b", "label": "present"}, # Yellow
0: {"bg": "#3a3a3c", "label": "absent"}, # Gray
}
_EMPTY_BG = "#121213"
_EMPTY_BORDER = "#3a3a3c"
# Emoji squares for plain-text fallback & share
_EMOJI = {2: "\U0001f7e9", 1: "\U0001f7e8", 0: "\u2b1b"}
# Keyboard layout
_KB_ROWS = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"]
# Stats file
STATS_FILE = Path("wordle_stats.json")
# Congratulations messages by guess number
_CONGRATS = {
1: "Genius!",
2: "Magnificent!",
3: "Impressive!",
4: "Splendid!",
5: "Great!",
6: "Phew!",
}
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class WordleGame:
player_id: str
room_id: str
target: str
guesses: list = field(default_factory=list)
results: list = field(default_factory=list)
hard_mode: bool = False
daily_number: int = 0
started_at: float = field(default_factory=time.time)
finished: bool = False
won: bool = False
origin_room_id: str = "" # Public room where game was started (for share)
# Module-level state
_active_games: dict[str, WordleGame] = {}
_all_stats: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# Stats persistence
# ---------------------------------------------------------------------------
def _load_stats():
global _all_stats
if STATS_FILE.exists():
try:
_all_stats = json.loads(STATS_FILE.read_text())
except (json.JSONDecodeError, OSError) as e:
logger.error("Failed to load wordle stats: %s", e)
_all_stats = {}
else:
_all_stats = {}
def _save_stats():
try:
tmp = STATS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_all_stats, indent=2))
tmp.rename(STATS_FILE)
except OSError as e:
logger.error("Failed to save wordle stats: %s", e)
def _get_player_stats(player_id: str) -> dict:
if player_id not in _all_stats:
_all_stats[player_id] = {
"games_played": 0,
"games_won": 0,
"current_streak": 0,
"max_streak": 0,
"guess_distribution": {str(i): 0 for i in range(1, 7)},
"last_daily": -1,
"hard_mode": False,
"last_daily_result": None,
"last_daily_guesses": None,
}
return _all_stats[player_id]
def _record_game_result(player_id: str, game: WordleGame):
stats = _get_player_stats(player_id)
stats["games_played"] += 1
if game.won:
stats["games_won"] += 1
stats["current_streak"] += 1
stats["max_streak"] = max(stats["max_streak"], stats["current_streak"])
num_guesses = str(len(game.guesses))
stats["guess_distribution"][num_guesses] = (
stats["guess_distribution"].get(num_guesses, 0) + 1
)
else:
stats["current_streak"] = 0
stats["last_daily"] = game.daily_number
stats["last_daily_result"] = game.results
stats["last_daily_guesses"] = game.guesses
stats["last_daily_won"] = game.won
stats["last_daily_hard"] = game.hard_mode
stats["last_origin_room"] = game.origin_room_id
_save_stats()
# ---------------------------------------------------------------------------
# Core algorithms
# ---------------------------------------------------------------------------
async def get_daily_word() -> tuple[str, int]:
"""Return (word, puzzle_number) for today's daily puzzle.
Fetches from the NYT Wordle API. Falls back to the local word list
if the request fails.
"""
today = date.today()
date_str = today.strftime("%Y-%m-%d")
if date_str in _nyt_cache:
return _nyt_cache[date_str]
try:
url = f"https://www.nytimes.com/svc/wordle/v2/{date_str}.json"
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
word = data["solution"].upper()
puzzle_number = int(data["id"])
_nyt_cache[date_str] = (word, puzzle_number)
logger.info("NYT Wordle #%d: %s", puzzle_number, word)
return word, puzzle_number
else:
logger.warning("NYT Wordle API returned %d, falling back to local list", resp.status)
except Exception as e:
logger.warning("Failed to fetch NYT Wordle word: %s — falling back to local list", e)
# Fallback: use local answer list
puzzle_number = (today - _WORDLE_EPOCH).days
word = _ANSWER_LIST[puzzle_number % len(_ANSWER_LIST)]
return word, puzzle_number
def evaluate_guess(guess: str, target: str) -> list[int]:
"""Evaluate a guess against the target. Returns list of 5 scores:
2 = correct position (green), 1 = wrong position (yellow), 0 = absent (gray).
Handles duplicate letters correctly with a two-pass approach.
"""
result = [0] * 5
target_remaining = list(target)
# Pass 1: mark exact matches (green)
for i in range(5):
if guess[i] == target[i]:
result[i] = 2
target_remaining[i] = None
# Pass 2: mark present-but-wrong-position (yellow)
for i in range(5):
if result[i] == 2:
continue
if guess[i] in target_remaining:
result[i] = 1
target_remaining[target_remaining.index(guess[i])] = None
return result
def validate_hard_mode(
guess: str,
previous_guesses: list[str],
previous_results: list[list[int]],
) -> str | None:
"""Return None if valid, or an error message if hard mode violated."""
for prev_guess, prev_result in zip(previous_guesses, previous_results):
for i, (letter, score) in enumerate(zip(prev_guess, prev_result)):
if score == 2 and guess[i] != letter:
return (
f"Hard mode: position {i + 1} must be "
f"'{letter}' (green from previous guess)"
)
if score == 1 and letter not in guess:
return (
f"Hard mode: guess must contain "
f"'{letter}' (yellow from previous guess)"
)
return None
# ---------------------------------------------------------------------------
# HTML rendering
# ---------------------------------------------------------------------------
def _tile_span(letter: str, bg: str) -> str:
"""Render a single letter tile using Matrix-compatible attributes."""
return (
f'<font data-mx-bg-color="{bg}" data-mx-color="#ffffff">'
f"<b>\u00a0{letter}\u00a0</b></font>"
)
def render_grid_html(game: WordleGame) -> str:
"""Render the Wordle grid as inline spans (compatible with Cinny)."""
rows = []
for row_idx in range(6):
tiles = []
if row_idx < len(game.guesses):
guess = game.guesses[row_idx]
result = game.results[row_idx]
for letter, score in zip(guess, result):
bg = _TILE[score]["bg"]
tiles.append(_tile_span(letter, bg))
else:
for _ in range(5):
tiles.append(_tile_span("\u00a0", _EMPTY_BG))
rows.append("".join(tiles))
return "<br>".join(rows)
def render_keyboard_html(game: WordleGame) -> str:
"""Render a virtual keyboard showing letter states."""
letter_states: dict[str, int] = {}
for guess, result in zip(game.guesses, game.results):
for letter, score in zip(guess, result):
letter_states[letter] = max(letter_states.get(letter, -1), score)
kb_rows = []
for row in _KB_ROWS:
keys = []
for letter in row:
state = letter_states.get(letter, -1)
if state == -1:
bg, color = "#818384", "#ffffff"
elif state == 0:
bg, color = "#3a3a3c", "#555555"
elif state == 1:
bg, color = "#b59f3b", "#ffffff"
else:
bg, color = "#538d4e", "#ffffff"
keys.append(
f'<font data-mx-bg-color="{bg}" data-mx-color="{color}">'
f"{letter}</font>"
)
kb_rows.append(" ".join(keys))
return "<br>" + "<br>".join(kb_rows)
def render_grid_plain(game: WordleGame) -> str:
"""Plain text grid with emoji squares and letter markers."""
_marker = {2: "!", 1: "?", 0: "."} # ! = correct, ? = wrong spot, . = absent
lines = []
for guess, result in zip(game.guesses, game.results):
emoji_row = "".join(_EMOJI[s] for s in result)
# Show each letter with a marker: [C!] = correct, [R?] = wrong spot, [A.] = absent
marked = " ".join(f"{letter}{_marker[score]}" for letter, score in zip(guess, result))
lines.append(f"{emoji_row} {marked}")
return "\n".join(lines)
def render_keyboard_plain(game: WordleGame) -> str:
"""Plain text keyboard status."""
letter_states: dict[str, int] = {}
for guess, result in zip(game.guesses, game.results):
for letter, score in zip(guess, result):
letter_states[letter] = max(letter_states.get(letter, -1), score)
lines = []
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:
_ = 6 - len(game.guesses)
grid_plain = render_grid_plain(game)
kb_plain = render_keyboard_plain(game)
plain = (
f"Wordle {game.daily_number}"
f"Guess {len(game.guesses) + 1}/6\n\n"
f"{grid_plain}\n\n{kb_plain}"
)
grid_html = render_grid_html(game)
kb_html = render_keyboard_html(game)
mode = " (Hard Mode)" if game.hard_mode else ""
html = (
f''
f"<strong>Wordle {game.daily_number}{mode}</strong> — "
f"Guess {len(game.guesses) + 1}/6<br><br>"
f"{grid_html}{kb_html}"
)
await send_html(client, room_id, plain, html)
return
# Check if already completed today's puzzle
word, puzzle_number = await get_daily_word()
stats = _get_player_stats(sender)
if stats["last_daily"] == puzzle_number:
await send_text(
client, room_id,
f"You already completed today's Wordle (#{puzzle_number})! "
f"Use {BOT_PREFIX}wordle stats to see your results "
f"or {BOT_PREFIX}wordle share to share them."
)
return
# Start new game
hard_mode = stats.get("hard_mode", False)
game = WordleGame(
player_id=sender,
room_id=room_id,
target=word,
hard_mode=hard_mode,
daily_number=puzzle_number,
origin_room_id=origin_room_id or room_id,
)
_active_games[sender] = game
mode_str = " (Hard Mode)" if hard_mode else ""
grid_html = render_grid_html(game)
kb_html = render_keyboard_html(game)
plain = (
f"Wordle #{puzzle_number}{mode_str}\n"
f"Guess a 5-letter word! You have 6 attempts.\n"
f"Type {BOT_PREFIX}wordle <word> to guess."
)
html = (
f''
f"<strong>Wordle #{puzzle_number}{mode_str}</strong><br>"
f"Guess a 5-letter word! You have 6 attempts.<br>"
f"Type <code>{BOT_PREFIX}wordle &lt;word&gt;</code> to guess."
f"<br><br>{grid_html}{kb_html}"
)
await send_html(client, room_id, plain, html)
async def wordle_guess(
client: AsyncClient, room_id: str, sender: str, guess: str
):
"""Process a guess."""
if sender not in _active_games:
await send_text(
client, room_id,
f"No active game. Start one with {BOT_PREFIX}wordle"
)
return
game = _active_games[sender]
if game.finished:
await send_text(
client, room_id,
f"Your game is already finished! "
f"Use {BOT_PREFIX}wordle to start a new daily puzzle."
)
return
# Validate word
if guess not in _VALID_SET:
await send_text(client, room_id, f"'{guess.lower()}' is not in the word list. Try again.")
return
# Hard mode validation
if game.hard_mode and game.guesses:
violation = validate_hard_mode(guess, game.guesses, game.results)
if violation:
await send_text(client, room_id, violation)
return
# Evaluate
result = evaluate_guess(guess, game.target)
game.guesses.append(guess)
game.results.append(result)
# Check win
if all(s == 2 for s in result):
game.finished = True
game.won = True
origin = game.origin_room_id
_record_game_result(sender, game)
del _active_games[sender]
num = len(game.guesses)
congrats = _CONGRATS.get(num, "Nice!")
grid_plain = render_grid_plain(game)
plain = (
f"{congrats} Wordle {game.daily_number} {num}/6"
f"{'*' if game.hard_mode else ''}\n\n"
f"{grid_plain}"
)
grid_html = render_grid_html(game)
mode = "*" if game.hard_mode else ""
html = (
f''
f"<strong>{congrats}</strong> "
f"Wordle {game.daily_number} {num}/6{mode}<br><br>"
f"{grid_html}"
)
await send_html(client, room_id, plain, html)
if origin and origin != room_id:
await wordle_share(client, origin, sender)
return
# Check loss (6 guesses used)
if len(game.guesses) >= 6:
game.finished = True
game.won = False
origin = game.origin_room_id
_record_game_result(sender, game)
del _active_games[sender]
grid_plain = render_grid_plain(game)
plain = (
f"Wordle {game.daily_number} X/6"
f"{'*' if game.hard_mode else ''}\n\n"
f"{grid_plain}\n\n"
f"The word was: {game.target}\n"
f"Better luck tomorrow!"
)
grid_html = render_grid_html(game)
mode = "*" if game.hard_mode else ""
html = (
f''
f"<strong>Wordle {game.daily_number} X/6{mode}</strong><br><br>"
f"{grid_html}<br>"
f'The word was: <font data-mx-color="#538d4e"><strong>'
f"{game.target}</strong></font><br>"
f"<em>Better luck tomorrow!</em>"
)
await send_html(client, room_id, plain, html)
if origin and origin != room_id:
await wordle_share(client, origin, sender)
return
# Still playing — show grid + keyboard
_ = 6 - len(game.guesses)
grid_plain = render_grid_plain(game)
kb_plain = render_keyboard_plain(game)
plain = (
f"Wordle {game.daily_number}"
f"Guess {len(game.guesses) + 1}/6\n\n"
f"{grid_plain}\n\n{kb_plain}"
)
grid_html = render_grid_html(game)
kb_html = render_keyboard_html(game)
mode = " (Hard Mode)" if game.hard_mode else ""
html = (
f''
f"<strong>Wordle {game.daily_number}{mode}</strong> — "
f"Guess {len(game.guesses) + 1}/6<br><br>"
f"{grid_html}{kb_html}"
)
await send_html(client, room_id, plain, html)
async def wordle_stats(client: AsyncClient, room_id: str, sender: str):
"""Show player statistics."""
stats = _get_player_stats(sender)
if stats["games_played"] == 0:
await send_text(
client, room_id,
f"No Wordle stats yet! Start a game with {BOT_PREFIX}wordle"
)
return
plain = render_stats_plain(stats)
html = render_stats_html(stats)
await send_html(client, room_id, plain, html)
async def wordle_toggle_hard(client: AsyncClient, room_id: str, sender: str):
"""Toggle hard mode for the player."""
stats = _get_player_stats(sender)
new_mode = not stats.get("hard_mode", False)
stats["hard_mode"] = new_mode
_save_stats()
# Also update active game if one exists
if sender in _active_games:
game = _active_games[sender]
if not game.guesses:
# Only allow toggling before first guess
game.hard_mode = new_mode
elif new_mode:
await send_text(
client, room_id,
"Hard mode enabled for future games. "
"Cannot enable mid-game after guessing."
)
return
else:
game.hard_mode = False
status = "enabled" if new_mode else "disabled"
plain = (
f"Hard mode {status}. "
+ ("You must use all revealed hints in subsequent guesses."
if new_mode else "Standard rules apply.")
)
await send_text(client, room_id, plain)
async def wordle_share(client: AsyncClient, room_id: str, sender: str):
"""Share the last completed daily result."""
stats = _get_player_stats(sender)
share_text = generate_share(stats)
if not share_text:
await send_text(
client, room_id,
f"No completed daily puzzle to share. Play one with {BOT_PREFIX}wordle"
)
return
await send_text(client, room_id, share_text)
async def wordle_give_up(client: AsyncClient, room_id: str, sender: str):
"""Forfeit the current game."""
if sender not in _active_games:
await send_text(
client, room_id,
f"No active game to give up. Start one with {BOT_PREFIX}wordle"
)
return
game = _active_games[sender]
if game.finished:
del _active_games[sender]
return
game.finished = True
game.won = False
origin = game.origin_room_id
_record_game_result(sender, game)
del _active_games[sender]
grid_plain = render_grid_plain(game)
plain = (
f"Game over! The word was: {game.target}\n\n"
f"{grid_plain}\n\n"
f"Better luck tomorrow!"
)
grid_html = render_grid_html(game)
html = (
f''
f"<strong>Game Over</strong><br><br>"
f"{grid_html}<br>"
f'The word was: <font data-mx-color="#538d4e"><strong>'
f"{game.target}</strong></font><br>"
f"<em>Better luck tomorrow!</em>"
)
await send_html(client, room_id, plain, html)
if origin and origin != room_id:
await wordle_share(client, origin, sender)
# ---------------------------------------------------------------------------
# Main router
# ---------------------------------------------------------------------------
async def _get_dm_room(client: AsyncClient, room_id: str, sender: str) -> tuple[str, str]:
"""Get or create DM room for the sender. Returns (dm_room_id, origin_room_id).
If already in a DM, returns (room_id, stored_origin or room_id).
If in a public room, creates/finds DM and returns (dm_room_id, room_id).
"""
# Check if this is already a DM (2 members)
room = client.rooms.get(room_id)
if room and room.member_count == 2:
# Already in DM — use origin from active game if available
game = _active_games.get(sender)
origin = game.origin_room_id if game and game.origin_room_id else room_id
return room_id, origin
# Public room — find/create DM
dm_room = await get_or_create_dm(client, sender)
if dm_room:
return dm_room, room_id
# Fallback to public room if DM creation fails
logger.warning("Could not create DM with %s, falling back to public room", sender)
return room_id, room_id
async def handle_wordle(
client: AsyncClient, room_id: str, sender: str, args: str
):
"""Main entry point — dispatches to subcommands."""
parts = args.strip().split(None, 1)
subcmd = parts[0].lower() if parts else ""
sub_args = parts[1] if len(parts) > 1 else ""
# Share always goes to the public room
if subcmd == "share":
await wordle_share(client, room_id, sender)
return
# All other commands route through DM
dm_room, origin = await _get_dm_room(client, room_id, sender)
# Silently redirect to DM — origin room will get an auto-share when the game ends
if subcmd == "help":
await wordle_help(client, dm_room)
elif subcmd == "stats":
await wordle_stats(client, dm_room, sender)
elif subcmd == "hard":
await wordle_toggle_hard(client, dm_room, sender)
elif subcmd == "give" and sub_args.lower().startswith("up"):
await wordle_give_up(client, dm_room, sender)
elif subcmd == "":
await wordle_start_or_status(client, dm_room, sender, origin)
elif len(subcmd) == 5 and subcmd.isalpha():
await wordle_guess(client, dm_room, sender, subcmd.upper())
else:
await send_text(
client, room_id,
f"Invalid wordle command or guess. "
f"Guesses must be exactly 5 letters. "
f"Try {BOT_PREFIX}wordle help"
)
# ---------------------------------------------------------------------------
# Load stats on module import
# ---------------------------------------------------------------------------
_load_stats()
+411
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",
]
File diff suppressed because it is too large Load Diff