diff --git a/README.md b/README.md index d1ef85f..3e2a344 100644 --- a/README.md +++ b/README.md @@ -522,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 ` | Ask the AI anything | +| `!fortune` | Get a fortune cookie | +| `!8ball ` | Magic 8-ball (yes/no/maybe, funny style). `--debug` shows raw AI output | +| `!roast @user` | Roast someone | +| `!story ` | Generate a short story | +| `!debate ` | 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 | +| `!poll \| option1 \| option2...` | Create a reaction poll | +| `!hangman [--hard] [--extended]` | Hangman — `--hard` uses long words, `--extended` adds more body parts | +| `!guess ` | 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 1–100 | +| `!ng ` | Guess in an active number game (temperature hints included) | +| `!wordchain` | Word Chain — each word must start with the last letter of the previous | +| `!wc ` | 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 ` | Submit an acronym expansion | +| `!20q` | 20 Questions — AI thinks of something, you ask yes/no questions | +| `!q ` | Ask a yes/no question in 20Q | +| `!answer ` | 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 ` | Answer in a Trivia Duel | + +### Random + +| Command | Description | +|---------|-------------| +| `!flip` | Flip a coin | +| `!roll [NdN]` | Roll dice (e.g. `!roll 2d6`) | +| `!random ` | 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 | diff --git a/matrixbot/callbacks.py b/matrixbot/callbacks.py index 3a0619e..d896e36 100644 --- a/matrixbot/callbacks.py +++ b/matrixbot/callbacks.py @@ -4,7 +4,16 @@ 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 +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") @@ -82,6 +91,9 @@ class Callbacks: 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.""" @@ -103,6 +115,9 @@ class Callbacks: 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.""" diff --git a/matrixbot/commands.py b/matrixbot/commands.py index ea68c41..67ab6c1 100644 --- a/matrixbot/commands.py +++ b/matrixbot/commands.py @@ -114,7 +114,19 @@ def check_cooldown(sender: str, cmd_name: str, seconds: int = COOLDOWN_SECONDS) async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str): categories = [ ("🤖 AI / Fun", ["ask", "fortune", "8ball", "roast", "story", "debate"]), - ("🎮 Games", ["wordle", "trivia", "rps", "poll", "hangman", "guess", "scramble", "wyr", "riddle"]), + ("🎮 Games", [ + "wordle", "trivia", "rps", "poll", "hangman", "guess", + "scramble", "wyr", "riddle", + "numguess", "ng", + "wordchain", "wc", "endwc", + "acronym", "ac", + "20q", "q", "answer", + "nhie", + "hottake", + "ttt", "move", + "blackjack", "hit", "stand", + "triviaduel", "da", + ]), ("🎲 Random", ["flip", "roll", "random", "champion", "agent"]), ("🖥️ Server", ["minecraft", "ping", "health"]), ] @@ -2398,3 +2410,1165 @@ async def cmd_debate(client: AsyncClient, room_id: str, sender: str, args: str): except Exception as e: logger.error(f"debate generation error: {e}", exc_info=True) await send_text(client, room_id, "Failed to generate the debate. Try again later.") + + +# =========================================================================== +# Number Guess +# =========================================================================== + +_NUMGUESS_GAMES: dict[str, dict] = {} + + +@command("numguess", "Guess the number — bot picks 1–100, use !ng to guess") +async def cmd_numguess(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _NUMGUESS_GAMES: + g = _NUMGUESS_GAMES[room_id] + await send_text(client, room_id, + f"🔢 Number game already active! Guesses: {g['guesses']} — use !ng ") + return + number = random.randint(1, 100) + _NUMGUESS_GAMES[room_id] = {"number": number, "guesses": 0} + await send_html(client, room_id, + "🔢 Number game! I'm thinking of a number between 1 and 100. Use !ng to guess!", + '🔢 Number Game!
' + "I'm thinking of a number between 1 and 100.
" + "Use !ng <number> to guess — temperature hints included! 🌡️", + ) + + +@command("ng", "Guess in a number game (!ng )") +async def cmd_ng(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _NUMGUESS_GAMES: + await send_text(client, room_id, "No number game active. Start one with !numguess") + return + g = _NUMGUESS_GAMES[room_id] + try: + guess = int(args.strip()) + except (ValueError, AttributeError): + await send_text(client, room_id, "That's not a number. Try: !ng 42") + return + if not 1 <= guess <= 100: + await send_text(client, room_id, "Guess must be between 1 and 100.") + return + + g["guesses"] += 1 + number = g["number"] + guesser = sender.split(":")[0].lstrip("@") + diff = abs(guess - number) + count = g["guesses"] + + if guess == number: + del _NUMGUESS_GAMES[room_id] + await send_html(client, room_id, + f"🎉 {guesser} got it in {count} guess{'es' if count != 1 else ''}! The number was {number}!", + f'🎉 {guesser} got it! ' + f'The number was {number} — solved in {count} ' + f'guess{"es" if count != 1 else ""}!', + ) + return + + direction = "📈 Higher!" if guess < number else "📉 Lower!" + if diff <= 3: + temp = "🔥🔥 SCORCHING" + elif diff <= 8: + temp = "🔥 Hot" + elif diff <= 15: + temp = "♨️ Warm" + elif diff <= 25: + temp = "❄️ Cold" + else: + temp = "🧊 Freezing" + + await send_text(client, room_id, f"{direction} {temp} (guess #{count})") + + +# =========================================================================== +# Word Chain +# =========================================================================== + +_WORDCHAIN_GAMES: dict[str, dict] = {} +_WORDCHAIN_STARTERS = [ + "apple", "bridge", "cloud", "dragon", "eagle", "forest", "garden", + "harbor", "island", "jungle", "knight", "lemon", "marble", "noodle", + "orange", "planet", "quartz", "river", "storm", "tiger", "violet", + "walrus", "yellow", "zebra", "anchor", "butter", "cactus", "funnel", + "glider", "hammer", "lantern", "mango", "napkin", "oyster", "parrot", +] + + +@command("wordchain", "Word chain — each word must start with the last letter of the previous!") +async def cmd_wordchain(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _WORDCHAIN_GAMES: + g = _WORDCHAIN_GAMES[room_id] + await send_text(client, room_id, + f"🔗 Word chain active! {g['chain_length']} words | " + f"Last: {g['last_word'].upper()} | Next starts with: {g['last_letter'].upper()}") + return + starter = random.choice(_WORDCHAIN_STARTERS) + _WORDCHAIN_GAMES[room_id] = { + "last_word": starter, + "last_letter": starter[-1], + "used_words": {starter}, + "chain_length": 1, + } + await send_html(client, room_id, + f"🔗 Word chain! Starting word: {starter.upper()} | Next must start with: {starter[-1].upper()} | Use !wc ", + f'🔗 Word Chain!
' + f'Starting word: {starter.upper()}
' + f'Next word must start with: {starter[-1].upper()}
' + f'Use !wc <word> to continue — !endwc to finish.', + ) + + +@command("wc", "Add a word to the chain (!wc )") +async def cmd_wc(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _WORDCHAIN_GAMES: + await send_text(client, room_id, "No word chain active. Start one with !wordchain") + return + g = _WORDCHAIN_GAMES[room_id] + word = args.strip().lower() + if not word: + await send_text(client, room_id, "Please provide a word, e.g. !wc apple") + return + if not word.isalpha(): + await send_text(client, room_id, "Words must contain only letters (no spaces or symbols).") + return + if len(word) < 2: + await send_text(client, room_id, "Words must be at least 2 letters long.") + return + if word[0] != g["last_letter"]: + await send_text(client, room_id, + f"❌ '{word.upper()}' doesn't start with '{g['last_letter'].upper()}'. Try again!") + return + if word in g["used_words"]: + await send_text(client, room_id, + f"❌ '{word.upper()}' was already used! Pick a different word.") + return + + g["used_words"].add(word) + g["last_word"] = word + g["last_letter"] = word[-1] + g["chain_length"] += 1 + chain = g["chain_length"] + player = sender.split(":")[0].lstrip("@") + + await send_html(client, room_id, + f"✅ {player}: {word.upper()} | Chain: {chain} words | Next: {word[-1].upper()}", + f'✅ {player}: ' + f'{word.upper()} — ' + f'chain: {chain} words | next starts with: {word[-1].upper()}', + ) + + +@command("endwc", "End the current word chain and see the final score") +async def cmd_endwc(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _WORDCHAIN_GAMES: + await send_text(client, room_id, "No word chain active.") + return + g = _WORDCHAIN_GAMES.pop(room_id) + await send_html(client, room_id, + f"🔗 Word chain ended! Final chain: {g['chain_length']} words. Last word: {g['last_word'].upper()}", + f'🔗 Word Chain Complete!
' + f'Final chain: {g["chain_length"]} words
' + f'Last word: {g["last_word"].upper()}', + ) + + +# =========================================================================== +# Acronym +# =========================================================================== + +_ACRONYM_GAMES: dict[str, dict] = {} +_ACRONYM_POLL_IDS: dict[str, str] = {} # poll_event_id -> room_id +_ACRONYM_WORDS = [ + "BLAST", "CRIMP", "FLUNK", "GROAN", "QUIRK", "SMASH", "STOMP", "THUMP", + "BLURT", "CLUNK", "DROOP", "FLICK", "GRUNT", "PLONK", "SNORT", "CRANK", + "GLOOM", "PLUCK", "SWAMP", "TWEAK", "BRISK", "CHOMP", "GRUMP", "SKIMP", + "CLAMP", "FROTH", "SHRUG", "SLUMP", "SNIFF", "SPUNK", "STRAP", "THROB", + "TRAMP", "WHACK", "CLANG", "FLARE", "GLEAM", "PROWL", "SCOFF", "SHOVE", +] + + +def record_acronym_vote(event_id: str, sender: str, key: str) -> None: + """Record a numbered-emoji vote on an acronym poll.""" + if event_id not in _ACRONYM_POLL_IDS: + return + room_id = _ACRONYM_POLL_IDS[event_id] + game = _ACRONYM_GAMES.get(room_id) + if not game or game.get("phase") != "voting": + return + _NUMBER_EMOJIS = {"1️⃣": 0, "2️⃣": 1, "3️⃣": 2, "4️⃣": 3, "5️⃣": 4, + "6️⃣": 5, "7️⃣": 6, "8️⃣": 7, "9️⃣": 8} + idx = _NUMBER_EMOJIS.get(key) + if idx is None: + return + entries = game.get("entries", []) + if idx < len(entries): + game.setdefault("votes", {})[sender] = idx # one vote per person + + +@command("acronym", "AI picks an acronym — submit the funniest expansion with !ac, then vote!") +async def cmd_acronym(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _ACRONYM_GAMES: + g = _ACRONYM_GAMES[room_id] + phase = g.get("phase", "") + if phase == "collecting": + await send_text(client, room_id, + f"🔤 Acronym active: {g['acronym']} | {len(g['entries'])} entries so far | " + f"Submit with !ac ") + else: + await send_text(client, room_id, "🔤 Acronym voting in progress! React with a number.") + return + + acronym = random.choice(_ACRONYM_WORDS) + _ACRONYM_GAMES[room_id] = { + "acronym": acronym, + "entries": [], # list of (sender, expansion) + "phase": "collecting", + "votes": {}, + } + + letters = " — ".join(list(acronym)) + await send_html(client, room_id, + f"🔤 ACRONYM: {acronym} ({letters})\nSubmit your funniest expansion with !ac \nYou have 60 seconds!", + f'🔤 Acronym: {acronym}
' + f'{letters}

' + f'Submit your funniest expansion with !ac <your expansion>
' + f'60 seconds!', + ) + + async def _reveal(): + await asyncio.sleep(60) + game = _ACRONYM_GAMES.get(room_id) + if not game or game.get("phase") != "collecting": + return + entries = game["entries"] + if not entries: + _ACRONYM_GAMES.pop(room_id, None) + await send_text(client, room_id, "🔤 No entries for the acronym — game over!") + return + + game["phase"] = "voting" + _NUMBER_EMOJI_LIST = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"] + random.shuffle(entries) + game["entries"] = entries + + lines_plain = [f"🔤 Submissions for {acronym}! React with the number of your favourite:\n"] + lines_html = [f'🔤 Submissions for {acronym}!' + f'
React with the number of your favourite:
    '] + for i, (_, expansion) in enumerate(entries[:9]): + emoji = _NUMBER_EMOJI_LIST[i] + lines_plain.append(f"{emoji} {expansion}") + lines_html.append(f"
  • {emoji} {expansion}
  • ") + lines_html.append("
30 seconds to vote!") + + resp = await send_html(client, room_id, + "\n".join(lines_plain), "".join(lines_html)) + if hasattr(resp, "event_id"): + _ACRONYM_POLL_IDS[resp.event_id] = room_id + + await asyncio.sleep(30) + + game = _ACRONYM_GAMES.pop(room_id, None) + if not game: + return + votes = game.get("votes", {}) + # Tally votes by entry index + tally: dict[int, int] = {} + for voter_idx in votes.values(): + tally[voter_idx] = tally.get(voter_idx, 0) + 1 + + if not tally: + await send_text(client, room_id, "🔤 No votes cast — it's a draw! Everyone's equally funny (or unfunny).") + else: + winner_idx = max(tally, key=lambda k: tally[k]) + winner_sender, winner_expansion = entries[winner_idx] + winner_name = winner_sender.split(":")[0].lstrip("@") + vote_count = tally[winner_idx] + await send_html(client, room_id, + f"🏆 Acronym winner: {winner_name} with '{winner_expansion}' ({vote_count} vote{'s' if vote_count != 1 else ''})!", + f'🏆 Acronym Winner: {winner_name}!
' + f'{winner_expansion}
' + f'({vote_count} vote{"s" if vote_count != 1 else ""})', + ) + # Cleanup poll IDs + for eid, rid in list(_ACRONYM_POLL_IDS.items()): + if rid == room_id: + del _ACRONYM_POLL_IDS[eid] + + asyncio.create_task(_reveal()) + + +@command("ac", "Submit an acronym expansion (!ac )") +async def cmd_ac(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _ACRONYM_GAMES: + await send_text(client, room_id, "No acronym game active. Start one with !acronym") + return + game = _ACRONYM_GAMES[room_id] + if game.get("phase") != "collecting": + await send_text(client, room_id, "Submissions are closed — voting is underway!") + return + expansion = sanitize_input(args.strip()) + if not expansion: + await send_text(client, room_id, "Please provide an expansion, e.g. !ac Silly People Owning Really Kooky stuff") + return + # One entry per player + if any(s == sender for s, _ in game["entries"]): + # Update existing entry + game["entries"] = [(s, e) if s != sender else (sender, expansion) for s, e in game["entries"]] + await send_text(client, room_id, f"✏️ Entry updated: {expansion}") + else: + if len(game["entries"]) >= 9: + await send_text(client, room_id, "Maximum 9 entries reached!") + return + game["entries"].append((sender, expansion)) + await send_text(client, room_id, f"✅ Entry received! ({len(game['entries'])} total)") + + +# =========================================================================== +# 20 Questions +# =========================================================================== + +_TWENTYQ_GAMES: dict[str, dict] = {} + + +async def _generate_20q_thing() -> dict | None: + system_msg = ( + "You are generating a subject for a game of 20 questions. " + "Pick a specific, well-known, concrete thing. Avoid overly obscure topics. " + "Good categories: animal, famous person, place, everyday object, food, movie/show, fictional character. " + "Respond with ONLY a JSON object — no markdown, no explanation. " + '{"thing": "elephant", "category": "animal", "hint": "it\'s a living creature"}' + ) + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/chat", + json={"model": ASK_MODEL, "stream": False, + "messages": [{"role": "system", "content": system_msg}, + {"role": "user", "content": "Generate a thing for 20 questions."}]}, + ) as response: + data = await response.json() + text = data.get("message", {}).get("content", "").strip() + if "```" in text: + text = re.sub(r"```[a-z]*\n?", "", text).strip() + m = re.search(r"\{[^{}]+\}", text, re.DOTALL) + candidate = m.group(0) if m else text + try: + parsed = json.loads(candidate) + except json.JSONDecodeError: + parsed = {} + thing = parsed.get("thing", "").strip() + category = parsed.get("category", "thing").strip() + hint = parsed.get("hint", f"it's a {category}").strip() + if thing and len(thing) > 1: + return {"thing": thing, "category": category, "hint": hint} + except Exception as e: + logger.error("20q generation error: %s", e, exc_info=True) + return None + + +async def _answer_20q(thing: str, category: str, question: str) -> str: + system_msg = ( + f'You are playing 20 questions. You are thinking of: "{thing}" ({category}). ' + "Answer the player's question with ONLY: Yes, No, Sometimes, or Partly. " + "Do NOT reveal what you are thinking of. Do NOT give hints beyond the single word answer. " + "One word answer only." + ) + try: + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/chat", + json={"model": ASK_MODEL, "stream": False, + "messages": [{"role": "system", "content": system_msg}, + {"role": "user", "content": question}]}, + ) as response: + data = await response.json() + raw = data.get("message", {}).get("content", "").strip() + # Only keep the first word to prevent leakage + first_word = raw.split()[0].rstrip(".,!?") if raw.split() else "..." + return first_word + except Exception as e: + logger.error("20q answer error: %s", e, exc_info=True) + return "..." + + +@command("20q", "AI thinks of something — ask up to 20 yes/no questions with !q, guess with !answer") +async def cmd_20q(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _TWENTYQ_GAMES: + g = _TWENTYQ_GAMES[room_id] + remaining = g["questions_left"] + await send_text(client, room_id, + f"🤔 20Q active! {remaining} question{'s' if remaining != 1 else ''} left. " + f"Ask with !q or guess with !answer") + return + + await send_text(client, room_id, "🤔 I'm thinking of something...") + thing_data = await _generate_20q_thing() + if not thing_data: + await send_text(client, room_id, "Failed to think of something. Try again!") + return + + _TWENTYQ_GAMES[room_id] = { + "thing": thing_data["thing"], + "category": thing_data["category"], + "hint": thing_data["hint"], + "questions_left": 20, + "asked": [], + } + await send_html(client, room_id, + f"🤔 I've got something in mind! Hint: {thing_data['hint']}\nAsk yes/no questions with !q (20 total) or guess with !answer ", + f'🤔 20 Questions!
' + f'I\'m thinking of something — hint: {thing_data["hint"]}
' + f'Ask with !q <question> (20 total) or guess with !answer <guess>', + ) + + +@command("q", "Ask a yes/no question in 20 Questions (!q )") +async def cmd_q(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _TWENTYQ_GAMES: + await send_text(client, room_id, "No 20Q game active. Start one with !20q") + return + g = _TWENTYQ_GAMES[room_id] + question = sanitize_input(args.strip()) + if not question: + await send_text(client, room_id, "Ask a question, e.g. !q Is it alive?") + return + if g["questions_left"] <= 0: + await send_text(client, room_id, "No questions left! Use !answer to make your final guess.") + return + + g["questions_left"] -= 1 + g["asked"].append(question) + remaining = g["questions_left"] + + answer = await _answer_20q(g["thing"], g["category"], question) + asker = sender.split(":")[0].lstrip("@") + + suffix = f" ({remaining} question{'s' if remaining != 1 else ''} left)" + if remaining == 0: + suffix = " — no questions left! Use !answer to guess!" + + await send_html(client, room_id, + f'Q{len(g["asked"])}: "{question}" → {answer}{suffix}', + f'Q{len(g["asked"])}: {question}
' + f'→ {answer}' + f' {suffix}', + ) + + if remaining == 0 and not g.get("final_warned"): + g["final_warned"] = True + + +@command("answer", "Guess the answer in 20 Questions (!answer )") +async def cmd_answer(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _TWENTYQ_GAMES: + await send_text(client, room_id, "No 20Q game active. Start one with !20q") + return + g = _TWENTYQ_GAMES[room_id] + guess = sanitize_input(args.strip()).lower() + if not guess: + await send_text(client, room_id, "Guess something! e.g. !answer elephant") + return + + thing = g["thing"].lower() + guesser = sender.split(":")[0].lstrip("@") + + def _matches(a: str, b: str) -> bool: + a, b = a.strip(), b.strip() + return a == b or a in b or b in a + + if _matches(guess, thing): + del _TWENTYQ_GAMES[room_id] + qs = 20 - g["questions_left"] + await send_html(client, room_id, + f"🎉 {guesser} got it! The answer was: {g['thing'].upper()} ({qs} questions used)", + f'🎉 {guesser} got it!
' + f'The answer was: {g["thing"].upper()}
' + f'({qs} question{"s" if qs != 1 else ""} used)', + ) + else: + g["questions_left"] = max(g["questions_left"] - 1, 0) + remaining = g["questions_left"] + if remaining == 0: + del _TWENTYQ_GAMES[room_id] + await send_html(client, room_id, + f"❌ Nope! And you're out of questions. The answer was: {g['thing'].upper()}", + f'❌ Nope! Game over.
' + f'The answer was: {g["thing"].upper()}', + ) + else: + await send_text(client, room_id, + f"❌ Not quite! {remaining} question{'s' if remaining != 1 else ''} left — keep asking with !q") + + +# =========================================================================== +# Never Have I Ever +# =========================================================================== + +_NHIE_POLLS: dict[str, dict] = {} # event_id -> {room_id, have: set, never: set} + + +def record_nhie_reaction(event_id: str, sender: str, key: str) -> None: + poll = _NHIE_POLLS.get(event_id) + if not poll: + return + if key == "🙋": + poll["have"].discard(sender) + poll["have"].add(sender) + poll["never"].discard(sender) + elif key == "🙅": + poll["never"].discard(sender) + poll["never"].add(sender) + poll["have"].discard(sender) + + +async def _generate_nhie_prompt() -> str | None: + system_msg = ( + "Generate a fun, surprising, or relatable 'Never Have I Ever' statement. " + "Do NOT include 'Never have I ever' in your response — just the action part. " + "Keep it funny, PG-13 at most, and something that creates an interesting mix of have/have-not. " + "One sentence only, no quotes." + ) + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/chat", + json={"model": CREATIVE_MODEL, "stream": False, + "messages": [{"role": "system", "content": system_msg}, + {"role": "user", "content": "Generate a Never Have I Ever statement."}]}, + ) as response: + data = await response.json() + text = data.get("message", {}).get("content", "").strip().strip('"') + if text and len(text) > 5: + return text + except Exception as e: + logger.error("nhie generation error: %s", e, exc_info=True) + return None + + +@command("nhie", "Never Have I Ever — AI generates a prompt, react 🙋 (have) or 🙅 (never)!") +async def cmd_nhie(client: AsyncClient, room_id: str, sender: str, args: str): + prompt_text = await _generate_nhie_prompt() + if not prompt_text: + await send_text(client, room_id, "Failed to generate a prompt. Try again!") + return + + full_prompt = f"Never have I ever... {prompt_text}" + plain = f"🙋🙅 {full_prompt}\nReact 🙋 if you HAVE or 🙅 if you NEVER have! (30s)" + html = ( + f'🙋🙅 Never Have I Ever
' + f'{full_prompt}

' + f'React 🙋 if you HAVE done it
' + f'React 🙅 if you NEVER have
' + f'30 seconds! via {_model_label(CREATIVE_MODEL)}' + ) + resp = await send_html(client, room_id, plain, html) + if not hasattr(resp, "event_id"): + return + + event_id = resp.event_id + _NHIE_POLLS[event_id] = {"room_id": room_id, "have": set(), "never": set()} + + async def _reveal(): + await asyncio.sleep(30) + poll = _NHIE_POLLS.pop(event_id, None) + if not poll: + return + have_count = len(poll["have"]) + never_count = len(poll["never"]) + total = have_count + never_count + if total == 0: + await send_text(client, room_id, "🙋🙅 Nobody reacted — a room full of ghosts!") + return + have_pct = int(have_count / total * 100) + never_pct = 100 - have_pct + await send_html(client, room_id, + f"Results: 🙋 {have_count} have ({have_pct}%) | 🙅 {never_count} never ({never_pct}%)", + f'🙋🙅 Results!
' + f'🙋 HAVE: {have_count} ({have_pct}%)
' + f'🙅 NEVER: {never_count} ({never_pct}%)', + ) + + asyncio.create_task(_reveal()) + + +# =========================================================================== +# Hot Take +# =========================================================================== + +_HOTTAKE_POLLS: dict[str, dict] = {} # event_id -> {room_id, agree: set, disagree: set} + + +def record_hottake_reaction(event_id: str, sender: str, key: str) -> None: + poll = _HOTTAKE_POLLS.get(event_id) + if not poll: + return + if key == "🔥": + poll["agree"].add(sender) + poll["disagree"].discard(sender) + elif key in ("💧", "❄️"): + poll["disagree"].add(sender) + poll["agree"].discard(sender) + + +async def _generate_hot_take() -> str | None: + system_msg = ( + "Generate a spicy, controversial hot take opinion. It should be something where " + "reasonable people strongly disagree — not hateful, but genuinely polarising. " + "Keep it fun and debate-worthy. One sentence only, stated as a confident opinion. No quotes." + ) + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/chat", + json={"model": CREATIVE_MODEL, "stream": False, + "messages": [{"role": "system", "content": system_msg}, + {"role": "user", "content": "Give me a hot take."}]}, + ) as response: + data = await response.json() + text = data.get("message", {}).get("content", "").strip().strip('"') + if text and len(text) > 10: + return text + except Exception as e: + logger.error("hottake generation error: %s", e, exc_info=True) + return None + + +@command("hottake", "AI generates a spicy hot take — react 🔥 (agree) or 💧 (disagree)!") +async def cmd_hottake(client: AsyncClient, room_id: str, sender: str, args: str): + take = await _generate_hot_take() + if not take: + await send_text(client, room_id, "Failed to generate a hot take. Try again!") + return + + plain = f"🔥 Hot Take: {take}\nReact 🔥 to agree or 💧 to disagree! (30s)" + html = ( + f'🔥 Hot Take
' + f'"{take}"

' + f'React 🔥 to agree
' + f'React 💧 to disagree
' + f'30 seconds! via {_model_label(CREATIVE_MODEL)}' + ) + resp = await send_html(client, room_id, plain, html) + if not hasattr(resp, "event_id"): + return + + event_id = resp.event_id + _HOTTAKE_POLLS[event_id] = {"room_id": room_id, "agree": set(), "disagree": set()} + + async def _reveal(): + await asyncio.sleep(30) + poll = _HOTTAKE_POLLS.pop(event_id, None) + if not poll: + return + agree = len(poll["agree"]) + disagree = len(poll["disagree"]) + total = agree + disagree + if total == 0: + await send_text(client, room_id, "🔥 Nobody reacted — truly the most controversial take.") + return + agree_pct = int(agree / total * 100) + disagree_pct = 100 - agree_pct + verdict = "🔥 Based!" if agree > disagree else "💧 Ratio'd!" if disagree > agree else "⚖️ Perfectly divided!" + await send_html(client, room_id, + f"{verdict} | 🔥 Agree: {agree} ({agree_pct}%) | 💧 Disagree: {disagree} ({disagree_pct}%)", + f'{verdict}
' + f'🔥 Agree: {agree} ({agree_pct}%)
' + f'💧 Disagree: {disagree} ({disagree_pct}%)', + ) + + asyncio.create_task(_reveal()) + + +# =========================================================================== +# Tic-Tac-Toe +# =========================================================================== + +_TTT_GAMES: dict[str, dict] = {} +_TTT_WINS = [(0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6)] + + +def _ttt_board_art(board: list) -> str: + def c(i): + return board[i] if board[i] else str(i + 1) + return ( + f" {c(0)} │ {c(1)} │ {c(2)} \n" + f"───┼───┼───\n" + f" {c(3)} │ {c(4)} │ {c(5)} \n" + f"───┼───┼───\n" + f" {c(6)} │ {c(7)} │ {c(8)} " + ) + + +def _ttt_check_winner(board: list) -> str | None: + for a, b, c in _TTT_WINS: + if board[a] and board[a] == board[b] == board[c]: + return board[a] + return None + + +def _ttt_board_html(game: dict, status: str = "") -> tuple[str, str]: + board = game["board"] + p1, p2 = game["players"] + p1n = p1.split(":")[0].lstrip("@") + p2n = p2.split(":")[0].lstrip("@") + art = _ttt_board_art(board) + current = game["current"] + cur_name = p1n if current == p1 else p2n + cur_mark = "X" if current == p1 else "O" + plain = ( + f"⭕ Tic-Tac-Toe: {p1n}(X) vs {p2n}(O)\n{art}" + + (f"\n{status}" if status else f"\n{cur_name}'s turn ({cur_mark})") + ) + html = ( + f'⭕ Tic-Tac-Toe — ' + f'{p1n} X vs ' + f'{p2n} O
' + f'
{art}
' + + (f'{status}' if status else f'{cur_name}\'s turn ({cur_mark}) — !move <1-9>') + ) + return plain, html + + +@command("ttt", "Tic-Tac-Toe — challenge someone with !ttt @user") +async def cmd_ttt(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _TTT_GAMES: + plain, html = _ttt_board_html(_TTT_GAMES[room_id]) + await send_html(client, room_id, plain, html) + return + + opponent = args.strip() + if not opponent or not opponent.startswith("@"): + await send_text(client, room_id, f"Usage: {BOT_PREFIX}ttt @username") + return + if opponent == sender: + await send_text(client, room_id, "You can't challenge yourself!") + return + if opponent == MATRIX_USER_ID: + await send_text(client, room_id, "I'm just the host — challenge another player!") + return + + challenger_name = sender.split(":")[0].lstrip("@") + opponent_name = opponent.split(":")[0].lstrip("@") + + game = { + "board": [None] * 9, + "players": [sender, opponent], + "current": sender, + "board_event_id": None, + } + _TTT_GAMES[room_id] = game + + plain, html = _ttt_board_html(game, + f"{challenger_name}(X) vs {opponent_name}(O) — {challenger_name} goes first! !move <1-9>") + resp = await send_html(client, room_id, plain, html) + if hasattr(resp, "event_id"): + game["board_event_id"] = resp.event_id + + +@command("move", "Make a move in Tic-Tac-Toe (!move <1-9>)") +async def cmd_move(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _TTT_GAMES: + await send_text(client, room_id, "No Tic-Tac-Toe game active. Start one with !ttt @user") + return + game = _TTT_GAMES[room_id] + p1, p2 = game["players"] + if sender not in (p1, p2): + await send_text(client, room_id, "You're not in this game!") + return + if sender != game["current"]: + cur_name = (p1 if game["current"] == p1 else p2).split(":")[0].lstrip("@") + await send_text(client, room_id, f"It's {cur_name}'s turn, not yours!") + return + + try: + pos = int(args.strip()) - 1 + except (ValueError, AttributeError): + await send_text(client, room_id, "Pick a position 1-9, e.g. !move 5") + return + if not 0 <= pos <= 8: + await send_text(client, room_id, "Position must be between 1 and 9.") + return + if game["board"][pos] is not None: + await send_text(client, room_id, "That spot is taken! Pick another.") + return + + mark = "X" if sender == p1 else "O" + game["board"][pos] = mark + game["current"] = p2 if sender == p1 else p1 + + board_id = game.get("board_event_id") + + async def _update(status: str = ""): + p, h = _ttt_board_html(game, status) + if board_id: + await edit_html(client, room_id, board_id, p, h) + else: + await send_html(client, room_id, p, h) + + winner = _ttt_check_winner(game["board"]) + if winner: + winner_id = p1 if winner == "X" else p2 + winner_name = winner_id.split(":")[0].lstrip("@") + del _TTT_GAMES[room_id] + await _update(f"🏆 {winner_name} wins with {winner}!") + return + + if all(cell is not None for cell in game["board"]): + del _TTT_GAMES[room_id] + await _update("🤝 It's a draw!") + return + + await _update() + + +# =========================================================================== +# Blackjack +# =========================================================================== + +_BLACKJACK_GAMES: dict[str, dict] = {} + + +def _bj_new_deck() -> list: + suits = ["♠", "♥", "♦", "♣"] + values = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] + deck = [(v, s) for s in suits for v in values] + random.shuffle(deck) + return deck + + +def _bj_card_value(card: tuple) -> int: + v = card[0] + if v in ("J", "Q", "K"): + return 10 + if v == "A": + return 11 + return int(v) + + +def _bj_total(hand: list) -> int: + total = sum(_bj_card_value(c) for c in hand) + aces = sum(1 for c in hand if c[0] == "A") + while total > 21 and aces: + total -= 10 + aces -= 1 + return total + + +def _bj_format_hand(hand: list, hide_first: bool = False) -> str: + if hide_first: + return f"[?] [{hand[1][0]}{hand[1][1]}]" + return " ".join(f"[{v}{s}]" for v, s in hand) + + +def _bj_board(game: dict, reveal_dealer: bool = False, status: str = "") -> tuple[str, str]: + player_hand = game["player_hand"] + dealer_hand = game["dealer_hand"] + player_total = _bj_total(player_hand) + dealer_visible = _bj_format_hand(dealer_hand) if reveal_dealer else _bj_format_hand(dealer_hand, hide_first=True) + dealer_total = _bj_total(dealer_hand) if reveal_dealer else _bj_card_value(dealer_hand[1]) + player_name = game["player_id"].split(":")[0].lstrip("@") + + plain = ( + f"🃏 Blackjack — {player_name}\n" + f"Dealer: {dealer_visible} = {'?' if not reveal_dealer else dealer_total}\n" + f"You: {_bj_format_hand(player_hand)} = {player_total}" + + (f"\n{status}" if status else "\n!hit to draw | !stand to stay") + ) + html = ( + f'🃏 Blackjack — {player_name}
' + f'Dealer: {dealer_visible} = ' + f'{"?" if not reveal_dealer else dealer_total}
' + f'You: {_bj_format_hand(player_hand)} = ' + f'{player_total}' + + (f'
{status}' if status else '
!hit to draw | !stand to stay') + ) + return plain, html + + +@command("blackjack", "Play Blackjack! Beat the dealer — !hit to draw, !stand to stay") +async def cmd_blackjack(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _BLACKJACK_GAMES: + g = _BLACKJACK_GAMES[room_id] + plain, html = _bj_board(g) + await send_html(client, room_id, plain, html) + return + + deck = _bj_new_deck() + player_hand = [deck.pop(), deck.pop()] + dealer_hand = [deck.pop(), deck.pop()] + game = { + "deck": deck, + "player_hand": player_hand, + "dealer_hand": dealer_hand, + "player_id": sender, + "board_event_id": None, + } + _BLACKJACK_GAMES[room_id] = game + + # Check for instant blackjack + if _bj_total(player_hand) == 21: + del _BLACKJACK_GAMES[room_id] + plain, html = _bj_board(game, reveal_dealer=True, + status="🎉 BLACKJACK! You win!") + await send_html(client, room_id, plain, html) + return + + plain, html = _bj_board(game) + resp = await send_html(client, room_id, plain, html) + if hasattr(resp, "event_id"): + game["board_event_id"] = resp.event_id + + +async def _bj_update(client: AsyncClient, room_id: str, game: dict, + reveal: bool = False, status: str = ""): + p, h = _bj_board(game, reveal_dealer=reveal, status=status) + board_id = game.get("board_event_id") + if board_id: + await edit_html(client, room_id, board_id, p, h) + else: + await send_html(client, room_id, p, h) + + +@command("hit", "Draw another card in Blackjack") +async def cmd_hit(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _BLACKJACK_GAMES: + await send_text(client, room_id, "No Blackjack game active. Start one with !blackjack") + return + game = _BLACKJACK_GAMES[room_id] + if sender != game["player_id"]: + await send_text(client, room_id, "It's not your game!") + return + + card = game["deck"].pop() if game["deck"] else _bj_new_deck().pop() + game["player_hand"].append(card) + total = _bj_total(game["player_hand"]) + + if total > 21: + del _BLACKJACK_GAMES[room_id] + await _bj_update(client, room_id, game, reveal=True, + status=f"💀 Bust! You went over 21 with {total}. Dealer wins.") + elif total == 21: + # Auto-stand on 21 + await _bj_update(client, room_id, game, status="Hit 21! Standing automatically...") + await _auto_stand(client, room_id, game) + else: + await _bj_update(client, room_id, game) + + +async def _auto_stand(client: AsyncClient, room_id: str, game: dict): + """Dealer plays out and resolve the game.""" + _BLACKJACK_GAMES.pop(room_id, None) + dealer_hand = game["dealer_hand"] + deck = game["deck"] + while _bj_total(dealer_hand) < 17: + dealer_hand.append(deck.pop() if deck else _bj_new_deck().pop()) + + player_total = _bj_total(game["player_hand"]) + dealer_total = _bj_total(dealer_hand) + + if dealer_total > 21: + status = f"🎉 Dealer busts ({dealer_total})! You win with {player_total}!" + elif player_total > dealer_total: + status = f"🎉 You win! {player_total} beats dealer's {dealer_total}." + elif dealer_total > player_total: + status = f"💀 Dealer wins. {dealer_total} beats your {player_total}." + else: + status = f"🤝 Push! Both have {player_total}." + + await _bj_update(client, room_id, game, reveal=True, status=status) + + +@command("stand", "Stand in Blackjack — dealer plays out") +async def cmd_stand(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _BLACKJACK_GAMES: + await send_text(client, room_id, "No Blackjack game active. Start one with !blackjack") + return + game = _BLACKJACK_GAMES[room_id] + if sender != game["player_id"]: + await send_text(client, room_id, "It's not your game!") + return + await _auto_stand(client, room_id, game) + + +# =========================================================================== +# Trivia Duel +# =========================================================================== + +_TRIVIADUEL_GAMES: dict[str, dict] = {} + + +def _tduel_fuzzy_match(guess: str, answer: str) -> bool: + def _norm(s: str) -> str: + s = s.strip().lower() + for art in ("a ", "an ", "the "): + if s.startswith(art): + s = s[len(art):] + return re.sub(r"[^a-z0-9 ]", "", s).strip() + g, a = _norm(guess), _norm(answer) + return g == a or (len(g) >= 3 and (g in a or a in g)) + + +@command("triviaduel", "Trivia Duel — challenge someone to a first-to-3 trivia battle! (!triviaduel @user)") +async def cmd_triviaduel(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _TRIVIADUEL_GAMES: + g = _TRIVIADUEL_GAMES[room_id] + p1, p2 = g["players"] + p1n = p1.split(":")[0].lstrip("@") + p2n = p2.split(":")[0].lstrip("@") + scores = g["scores"] + await send_text(client, room_id, + f"⚔️ Duel active: {p1n} {scores[p1]}-{scores[p2]} {p2n} | First to 3 wins | !da ") + return + + opponent = args.strip() + if not opponent or not opponent.startswith("@"): + await send_text(client, room_id, f"Usage: {BOT_PREFIX}triviaduel @user") + return + if opponent == sender: + await send_text(client, room_id, "You can't duel yourself!") + return + if opponent == MATRIX_USER_ID: + await send_text(client, room_id, "I'm just the host — duel another player!") + return + + challenger_name = sender.split(":")[0].lstrip("@") + opponent_name = opponent.split(":")[0].lstrip("@") + + game: dict = { + "players": [sender, opponent], + "scores": {sender: 0, opponent: 0}, + "current_question": None, + "round": 0, + } + _TRIVIADUEL_GAMES[room_id] = game + + await send_html(client, room_id, + f"⚔️ Trivia Duel: {challenger_name} vs {opponent_name}! First to 3 points wins. Loading question...", + f'⚔️ Trivia Duel!
' + f'{challenger_name} vs {opponent_name} — first to 3 points wins!
' + f'Loading first question...', + ) + await _tduel_next_question(client, room_id) + + +async def _tduel_next_question(client: AsyncClient, room_id: str): + game = _TRIVIADUEL_GAMES.get(room_id) + if not game: + return + game["round"] += 1 + game["current_question"] = None + game["answered_by"] = None + + q_data = await _generate_trivia_question("general") + if not q_data: + await send_text(client, room_id, "Failed to load a question. Duel cancelled.") + _TRIVIADUEL_GAMES.pop(room_id, None) + return + + game["current_question"] = q_data + options_text = "\n".join(f" {chr(65+i)}) {opt}" for i, opt in enumerate(q_data["options"])) + p1, p2 = game["players"] + p1n, p2n = p1.split(":")[0].lstrip("@"), p2.split(":")[0].lstrip("@") + scores = game["scores"] + + plain = ( + f"⚔️ Round {game['round']}: {p1n}({scores[p1]}) vs {p2n}({scores[p2]})\n" + f"Q: {q_data['q']}\n{options_text}\nFirst correct answer with !da
wins the point! (45s)" + ) + html = ( + f'⚔️ Round {game["round"]} — ' + f'{p1n}: {scores[p1]} | {p2n}: {scores[p2]}
' + f'Q: {q_data["q"]}
' + + "".join(f"{chr(65+i)}) {opt}
" for i, opt in enumerate(q_data["options"])) + + 'First to !da <A/B/C/D or answer> wins the point! 45 seconds.' + ) + await send_html(client, room_id, plain, html) + + # Timeout: reveal answer if nobody gets it + async def _timeout(): + await asyncio.sleep(45) + g = _TRIVIADUEL_GAMES.get(room_id) + if not g or g.get("current_question") is not q_data: + return + correct_idx = q_data["answer"] + correct_ans = q_data["options"][correct_idx] + g["current_question"] = None + await send_text(client, room_id, + f"⏱️ Time's up! The answer was: {chr(65+correct_idx)}) {correct_ans}") + await asyncio.sleep(2) + await _tduel_next_question(client, room_id) + + asyncio.create_task(_timeout()) + + +@command("da", "Answer in a Trivia Duel (!da
)") +async def cmd_da(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _TRIVIADUEL_GAMES: + await send_text(client, room_id, "No Trivia Duel active. Start one with !triviaduel @user") + return + game = _TRIVIADUEL_GAMES[room_id] + if sender not in game["players"]: + await send_text(client, room_id, "You're not in this duel!") + return + if not game.get("current_question"): + await send_text(client, room_id, "Wait for the next question!") + return + if game.get("answered_by"): + await send_text(client, room_id, "Someone already answered — wait for the next question!") + return + + q = game["current_question"] + guess = args.strip() + if not guess: + return + + correct_idx = q["answer"] + correct_ans = q["options"][correct_idx] + correct_letter = chr(65 + correct_idx) + + # Accept letter (A/B/C/D) or full answer text + is_correct = False + if len(guess) == 1 and guess.upper() in "ABCD": + is_correct = guess.upper() == correct_letter + else: + is_correct = _tduel_fuzzy_match(guess, correct_ans) + + if not is_correct: + player_name = sender.split(":")[0].lstrip("@") + await send_text(client, room_id, f"❌ {player_name}: Wrong!") + return + + # Correct! + game["answered_by"] = sender + game["current_question"] = None + game["scores"][sender] += 1 + player_name = sender.split(":")[0].lstrip("@") + scores = game["scores"] + p1, p2 = game["players"] + p1n, p2n = p1.split(":")[0].lstrip("@"), p2.split(":")[0].lstrip("@") + + await send_html(client, room_id, + f"✅ {player_name} got it! Answer: {correct_letter}) {correct_ans}\nScore: {p1n} {scores[p1]}-{scores[p2]} {p2n}", + f'✅ {player_name} got it!
' + f'Answer: {correct_letter}) {correct_ans}
' + f'Score: {p1n} {scores[p1]} – {p2n} {scores[p2]}', + ) + + # Check for winner (first to 3) + if scores[sender] >= 3: + del _TRIVIADUEL_GAMES[room_id] + await send_html(client, room_id, + f"🏆 {player_name} wins the Trivia Duel {scores[sender]}-{scores[p2 if sender == p1 else p1]}!", + f'🏆 {player_name} wins the Trivia Duel!
' + f'Final score: {p1n} {scores[p1]} – {p2n} {scores[p2]}', + ) + return + + await asyncio.sleep(3) + await _tduel_next_question(client, room_id)