diff --git a/matrixbot/callbacks.py b/matrixbot/callbacks.py index 5e3487b..1ac0ab1 100644 --- a/matrixbot/callbacks.py +++ b/matrixbot/callbacks.py @@ -4,7 +4,7 @@ from functools import wraps from nio import AsyncClient from config import BOT_PREFIX, MATRIX_USER_ID -from commands import COMMANDS, metrics +from commands import COMMANDS, metrics, check_scramble_answer, check_riddle_answer from welcome import handle_welcome_reaction, handle_space_join, SPACE_ROOM_ID logger = logging.getLogger("matrixbot") @@ -42,6 +42,13 @@ class Callbacks: 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 diff --git a/matrixbot/commands.py b/matrixbot/commands.py index 5912467..21f4fa1 100644 --- a/matrixbot/commands.py +++ b/matrixbot/commands.py @@ -112,8 +112,8 @@ def check_cooldown(sender: str, cmd_name: str, seconds: int = COOLDOWN_SECONDS) @command("help", "Show all available commands") async def cmd_help(client: AsyncClient, room_id: str, sender: str, args: str): categories = [ - ("🤖 AI", ["ask", "fortune", "8ball"]), - ("🎮 Games", ["wordle", "trivia", "rps", "poll"]), + ("🤖 AI / Fun", ["ask", "fortune", "8ball", "roast", "story", "debate"]), + ("🎮 Games", ["wordle", "trivia", "rps", "poll", "hangman", "scramble", "wyr", "riddle"]), ("🎲 Random", ["flip", "roll", "random", "champion", "agent"]), ("🖥️ Server", ["minecraft", "ping", "health"]), ] @@ -1200,3 +1200,809 @@ async def cmd_health(client: AsyncClient, room_id: str, sender: str, args: str): @command("wordle", "Play Wordle! (!wordle help for details)") async def cmd_wordle(client: AsyncClient, room_id: str, sender: str, args: str): await handle_wordle(client, room_id, sender, args) + + +# --------------------------------------------------------------------------- +# Hangman +# --------------------------------------------------------------------------- + +_HANGMAN_GAMES: dict[str, dict] = {} + +_HANGMAN_STAGES = [ + # 0 wrong + "```\n +---+\n | |\n |\n |\n |\n |\n=========```", + # 1 wrong + "```\n +---+\n | |\n O |\n |\n |\n |\n=========```", + # 2 wrong + "```\n +---+\n | |\n O |\n | |\n |\n |\n=========```", + # 3 wrong + "```\n +---+\n | |\n O |\n /| |\n |\n |\n=========```", + # 4 wrong + "```\n +---+\n | |\n O |\n /|\\ |\n |\n |\n=========```", + # 5 wrong + "```\n +---+\n | |\n O |\n /|\\ |\n / |\n |\n=========```", + # 6 wrong (dead) + "```\n +---+\n | |\n O |\n /|\\ |\n / \\ |\n |\n=========```", +] + + +def _hangman_display(game: dict) -> str: + word = game["word"] + guessed = game["guessed_letters"] + return " ".join(c if c in guessed else "_" for c in word.upper()) + + +async def _generate_hangman_word() -> dict | None: + prompt = ( + "Generate a hangman word game. Pick a common English word between 5 and 8 letters. " + "Respond with ONLY valid JSON, no markdown: " + '{"word": "example", "hint": "a short category or hint about the word"}. ' + "The word must be all lowercase letters only, no spaces or hyphens." + ) + try: + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/generate", + json={"model": ASK_MODEL, "prompt": prompt, "stream": False}, + ) as response: + data = await response.json() + text = data.get("response", "").strip() + # Strip markdown fences + if "```" in text: + text = text.split("```")[1].lstrip("json").strip() + parsed = json.loads(text) + word = parsed.get("word", "").lower().strip() + hint = parsed.get("hint", "").strip() + if word.isalpha() and 5 <= len(word) <= 8 and hint: + return {"word": word, "hint": hint} + except Exception as e: + logger.error(f"hangman word generation error: {e}", exc_info=True) + return None + + +@command("hangman", "Play hangman! AI picks a word, guess letters with !guess") +async def cmd_hangman(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _HANGMAN_GAMES: + game = _HANGMAN_GAMES[room_id] + display = _hangman_display(game) + wrong = game["wrong_count"] + guessed = sorted(game["guessed_letters"]) + wrong_letters = [l for l in guessed if l not in game["word"]] + plain = ( + f"Hangman already in progress!\n" + f"{_HANGMAN_STAGES[wrong]}\n" + f"Word: {display}\n" + f"Hint: {game['hint']}\n" + f"Wrong guesses ({wrong}/6): {', '.join(wrong_letters) or 'none'}\n" + f"Use !guess or !guess " + ) + await send_text(client, room_id, plain) + return + + await send_text(client, room_id, "🎯 Picking a word...") + + word_data = await _generate_hangman_word() + if word_data is None: + await send_text(client, room_id, "Failed to generate a word. Try again later.") + return + + word = word_data["word"] + hint = word_data["hint"] + display = " ".join("_" for _ in word) + + game = { + "word": word, + "hint": hint, + "guessed_letters": set(), + "wrong_count": 0, + } + _HANGMAN_GAMES[room_id] = game + + plain = ( + f"🎯 Hangman!\n" + f"{_HANGMAN_STAGES[0]}\n" + f"Word: {display} ({len(word)} letters)\n" + f"Hint: {hint}\n" + f"Guess a letter or word with !guess \n" + f"Max 6 wrong guesses." + ) + html = ( + f'🎯 Hangman!
' + f'
{_HANGMAN_STAGES[0].replace("```", "")}
' + f'Word: {display} ({len(word)} letters)
' + f'Hint: {hint}
' + f'Guess with !guess <letter/word> — max 6 wrong guesses' + ) + await send_html(client, room_id, plain, html) + + +@command("guess", "Guess a letter or word in hangman (!guess )") +async def cmd_guess(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id not in _HANGMAN_GAMES: + await send_text(client, room_id, "No hangman game in progress. Start one with !hangman") + return + + game = _HANGMAN_GAMES[room_id] + guess = args.strip().lower() + + if not guess or not guess.isalpha(): + await send_text(client, room_id, "Please guess a letter or word (letters only).") + return + + word = game["word"] + + # Full word guess + if len(guess) > 1: + if guess == word: + del _HANGMAN_GAMES[room_id] + plain = f"🎉 {sender.split(':')[0].lstrip('@')} got it! The word was: {word.upper()}" + html = ( + f'🎉 Correct! The word was: {word.upper()}
' + f'Guessed by {sender.split(":")[0].lstrip("@")}!' + ) + await send_html(client, room_id, plain, html) + else: + game["wrong_count"] += 1 + if game["wrong_count"] >= 6: + del _HANGMAN_GAMES[room_id] + plain = f"❌ Wrong! Game over — the word was: {word.upper()}" + html = f'❌ Wrong! Game over — the word was: {word.upper()}' + await send_html(client, room_id, plain, html) + else: + remaining = 6 - game["wrong_count"] + await send_text(client, room_id, f"❌ '{guess.upper()}' is wrong! {remaining} wrong guesses remaining.") + return + + # Single letter guess + letter = guess + if letter in game["guessed_letters"]: + await send_text(client, room_id, f"You already guessed '{letter.upper()}'. Try a different letter.") + return + + game["guessed_letters"].add(letter) + + if letter in word: + display = _hangman_display(game) + # Check if fully revealed + if "_" not in display: + del _HANGMAN_GAMES[room_id] + plain = f"🎉 Solved! The word was: {word.upper()}" + html = f'🎉 Solved! The word was: {word.upper()}' + await send_html(client, room_id, plain, html) + return + + wrong_letters = sorted(l for l in game["guessed_letters"] if l not in word) + plain = ( + f"✅ '{letter.upper()}' is in the word!\n" + f"{_HANGMAN_STAGES[game['wrong_count']]}\n" + f"Word: {display}\n" + f"Hint: {game['hint']}\n" + f"Wrong guesses ({game['wrong_count']}/6): {', '.join(wrong_letters) or 'none'}" + ) + html = ( + f'✅ \'{letter.upper()}\' is in the word!
' + f'Word: {display}
' + f'Hint: {game["hint"]}
' + f'Wrong ({game["wrong_count"]}/6): {", ".join(wrong_letters) or "none"}' + ) + await send_html(client, room_id, plain, html) + else: + game["wrong_count"] += 1 + wrong_count = game["wrong_count"] + display = _hangman_display(game) + wrong_letters = sorted(l for l in game["guessed_letters"] if l not in word) + + if wrong_count >= 6: + del _HANGMAN_GAMES[room_id] + plain = ( + f"💀 '{letter.upper()}' is wrong! Game over!\n" + f"{_HANGMAN_STAGES[6]}\n" + f"The word was: {word.upper()}" + ) + html = ( + f'💀 Game Over!
' + f'The word was: {word.upper()}
' + f'Wrong letters: {", ".join(wrong_letters)}' + ) + await send_html(client, room_id, plain, html) + else: + remaining = 6 - wrong_count + plain = ( + f"❌ '{letter.upper()}' is not in the word. {remaining} wrong guesses remaining.\n" + f"{_HANGMAN_STAGES[wrong_count]}\n" + f"Word: {display}\n" + f"Hint: {game['hint']}\n" + f"Wrong guesses ({wrong_count}/6): {', '.join(wrong_letters)}" + ) + html = ( + f'❌ \'{letter.upper()}\' is not in the word!
' + f'Word: {display}
' + f'Hint: {game["hint"]}
' + f'Wrong ({wrong_count}/6): {", ".join(wrong_letters)}' + ) + await send_html(client, room_id, plain, html) + + +# --------------------------------------------------------------------------- +# Scramble +# --------------------------------------------------------------------------- + +_SCRAMBLE_GAMES: dict[str, dict] = {} + + +async def _generate_scramble_word() -> dict | None: + prompt = ( + "Pick a common English word between 4 and 8 letters. " + "Respond with ONLY valid JSON, no markdown: " + '{"word": "example"}. ' + "The word must be all lowercase letters only, no spaces or hyphens." + ) + try: + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/generate", + json={"model": ASK_MODEL, "prompt": prompt, "stream": False}, + ) as response: + data = await response.json() + text = data.get("response", "").strip() + if "```" in text: + text = text.split("```")[1].lstrip("json").strip() + parsed = json.loads(text) + word = parsed.get("word", "").lower().strip() + if word.isalpha() and 4 <= len(word) <= 8: + return {"word": word} + except Exception as e: + logger.error(f"scramble word generation error: {e}", exc_info=True) + return None + + +def _scramble_word(word: str) -> str: + """Scramble a word, ensuring the scrambled version differs from original.""" + letters = list(word) + scrambled = word + for _ in range(20): + random.shuffle(letters) + scrambled = "".join(letters) + if scrambled != word: + break + return scrambled + + +@command("scramble", "Unscramble a word! First to type the correct word wins") +async def cmd_scramble(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _SCRAMBLE_GAMES: + game = _SCRAMBLE_GAMES[room_id] + await send_text(client, room_id, f"A scramble is already active! Unscramble: **{game['scrambled'].upper()}**") + return + + await send_text(client, room_id, "🔀 Picking a word to scramble...") + + word_data = await _generate_scramble_word() + if word_data is None: + await send_text(client, room_id, "Failed to generate a word. Try again later.") + return + + word = word_data["word"] + scrambled = _scramble_word(word) + + game = { + "word": word, + "scrambled": scrambled, + "room_id": room_id, + "task": None, + } + _SCRAMBLE_GAMES[room_id] = game + + plain = f"🔀 Scramble!\nUnscramble this word: {scrambled.upper()}\nFirst to type the correct word wins! (45 seconds)" + html = ( + f'🔀 Scramble!
' + f'Unscramble: {scrambled.upper()}
' + f'First to type the correct word wins! 45 seconds on the clock.' + ) + await send_html(client, room_id, plain, html) + + async def auto_reveal(): + await asyncio.sleep(45) + if room_id in _SCRAMBLE_GAMES and _SCRAMBLE_GAMES[room_id]["word"] == word: + del _SCRAMBLE_GAMES[room_id] + await send_html( + client, room_id, + f"⏰ Time's up! The word was: {word.upper()}", + f'⏰ Time\'s up! The word was: {word.upper()}', + ) + + task = asyncio.create_task(auto_reveal()) + _SCRAMBLE_GAMES[room_id]["task"] = task + + +async def check_scramble_answer(client: AsyncClient, room_id: str, sender: str, body: str) -> bool: + """Check if a room message solves the active scramble. Returns True if solved.""" + if room_id not in _SCRAMBLE_GAMES: + return False + game = _SCRAMBLE_GAMES[room_id] + guess = body.strip().lower() + if guess == game["word"]: + task = game.get("task") + if task: + task.cancel() + del _SCRAMBLE_GAMES[room_id] + winner = sender.split(":")[0].lstrip("@") + plain = f"🎉 {winner} got it! The word was: {game['word'].upper()}" + html = ( + f'🎉 {winner} solved it!
' + f'The word was: {game["word"].upper()}' + ) + await send_html(client, room_id, plain, html) + return True + return False + + +# --------------------------------------------------------------------------- +# Would You Rather (WYR) +# --------------------------------------------------------------------------- + +async def _generate_wyr() -> dict | None: + prompt = ( + "Generate a fun 'Would You Rather' question with exactly two options. " + "Respond with ONLY valid JSON, no markdown: " + '{"question": "Would you rather...", "option_a": "...", "option_b": "..."}. ' + "Keep each option short (under 10 words). Make it fun and interesting." + ) + try: + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/generate", + json={"model": ASK_MODEL, "prompt": prompt, "stream": False}, + ) as response: + data = await response.json() + text = data.get("response", "").strip() + if "```" in text: + text = text.split("```")[1].lstrip("json").strip() + parsed = json.loads(text) + q = parsed.get("question", "").strip() + a = parsed.get("option_a", "").strip() + b = parsed.get("option_b", "").strip() + if q and a and b: + return {"question": q, "option_a": a, "option_b": b} + except Exception as e: + logger.error(f"WYR generation error: {e}", exc_info=True) + return None + + +@command("wyr", "Would You Rather — AI generates a dilemma, vote with reactions!") +async def cmd_wyr(client: AsyncClient, room_id: str, sender: str, args: str): + await send_text(client, room_id, "🤔 Generating a dilemma...") + + wyr = await _generate_wyr() + if wyr is None: + await send_text(client, room_id, "Failed to generate a WYR question. Try again later.") + return + + plain = ( + f"🤔 Would You Rather?\n" + f"{wyr['question']}\n" + f"🅰️ {wyr['option_a']}\n" + f"🅱️ {wyr['option_b']}\n" + f"React with 🅰️ or 🅱️ — results in 30 seconds!" + ) + html = ( + f'🤔 Would You Rather?
' + f'{wyr["question"]}

' + f'🅰️ {wyr["option_a"]}
' + f'🅱️ {wyr["option_b"]}

' + f'React with 🅰️ or 🅱️ — results in 30 seconds!' + ) + resp = await send_html(client, room_id, plain, html) + + if hasattr(resp, "event_id"): + await send_reaction(client, room_id, resp.event_id, "🅰️") + await send_reaction(client, room_id, resp.event_id, "🅱️") + event_id = resp.event_id + + async def reveal(): + await asyncio.sleep(30) + # Count reactions via room state — we just tally what we have + # Since we can't easily query reaction counts via nio without extra API calls, + # we announce the results and encourage re-voting awareness + plain_r = ( + f"⏰ WYR Results!\n" + f"🅰️ {wyr['option_a']}\n" + f"🅱️ {wyr['option_b']}\n" + f"Check the reactions above to see which won!" + ) + html_r = ( + f'⏰ WYR — Time\'s up!
' + f'🅰️ {wyr["option_a"]}
' + f'🅱️ {wyr["option_b"]}
' + f'Check the reactions on the poll above to see which option won!' + ) + await send_html(client, room_id, plain_r, html_r) + + asyncio.create_task(reveal()) + + +# --------------------------------------------------------------------------- +# Riddle +# --------------------------------------------------------------------------- + +_RIDDLE_ACTIVE: dict[str, dict] = {} + + +async def _generate_riddle() -> dict | None: + prompt = ( + "Generate a clever riddle and its answer. " + "Respond with ONLY valid JSON, no markdown: " + '{"riddle": "...", "answer": "..."}. ' + "The answer should be a short word or phrase (1-4 words). Make it interesting!" + ) + try: + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/generate", + json={"model": ASK_MODEL, "prompt": prompt, "stream": False}, + ) as response: + data = await response.json() + text = data.get("response", "").strip() + if "```" in text: + text = text.split("```")[1].lstrip("json").strip() + parsed = json.loads(text) + riddle = parsed.get("riddle", "").strip() + answer = parsed.get("answer", "").strip() + if riddle and answer: + return {"riddle": riddle, "answer": answer} + except Exception as e: + logger.error(f"riddle generation error: {e}", exc_info=True) + return None + + +@command("riddle", "AI generates a riddle — answer in chat within 60s!") +async def cmd_riddle(client: AsyncClient, room_id: str, sender: str, args: str): + if room_id in _RIDDLE_ACTIVE: + game = _RIDDLE_ACTIVE[room_id] + await send_text(client, room_id, f"A riddle is already active!\n{game['riddle']}") + return + + await send_text(client, room_id, "🧩 Generating a riddle...") + + riddle_data = await _generate_riddle() + if riddle_data is None: + await send_text(client, room_id, "Failed to generate a riddle. Try again later.") + return + + riddle = riddle_data["riddle"] + answer = riddle_data["answer"] + + _RIDDLE_ACTIVE[room_id] = { + "riddle": riddle, + "answer": answer, + "task": None, + } + + plain = f"🧩 Riddle!\n{riddle}\n\nType your answer in chat — 60 seconds!" + html = ( + f'🧩 Riddle!
' + f'
{riddle}
' + f'Type your answer in chat — 60 seconds on the clock!' + ) + await send_html(client, room_id, plain, html) + + async def auto_reveal(): + await asyncio.sleep(60) + if room_id in _RIDDLE_ACTIVE and _RIDDLE_ACTIVE[room_id]["answer"] == answer: + del _RIDDLE_ACTIVE[room_id] + await send_html( + client, room_id, + f"⏰ Time's up! The answer was: {answer}", + f'⏰ Time\'s up! The answer was: {answer}', + ) + + task = asyncio.create_task(auto_reveal()) + _RIDDLE_ACTIVE[room_id]["task"] = task + + +async def check_riddle_answer(client: AsyncClient, room_id: str, sender: str, body: str) -> bool: + """Check if a room message answers the active riddle. Returns True if correct.""" + if room_id not in _RIDDLE_ACTIVE: + return False + game = _RIDDLE_ACTIVE[room_id] + answer_lower = game["answer"].lower() + body_lower = body.strip().lower() + if answer_lower in body_lower: + task = game.get("task") + if task: + task.cancel() + del _RIDDLE_ACTIVE[room_id] + winner = sender.split(":")[0].lstrip("@") + plain = f"🎉 {winner} got it! The answer was: {game['answer']}" + html = ( + f'🎉 {winner} solved the riddle!
' + f'The answer was: {game["answer"]}' + ) + await send_html(client, room_id, plain, html) + return True + return False + + +# --------------------------------------------------------------------------- +# Roast +# --------------------------------------------------------------------------- + +_JARED_LORE = ( + "Jared is a 22-year-old male DCO Support Engineer at AWS. " + "He owns his own house, is very successful, enjoys walks in the park, " + "home renovations, and hosting cookouts and party game nights with friends." +) + +_WYNTER_LORE = ( + "Wynter is a 22-year-old female who was a DCO Tech 3 at AWS but performed poorly, " + "failed her performance improvement plan, and took a settlement to leave. " + "She cannot return to AWS for at least 5 years and has very few friends." +) + + +@command("roast", "Roast someone with AI — !roast @user") +async def cmd_roast(client: AsyncClient, room_id: str, sender: str, args: str): + if not args.strip(): + await send_text(client, room_id, f"Usage: {BOT_PREFIX}roast @user") + return + + target_raw = sanitize_input(args.strip()) + + # Determine display name and any lore context + target_lower = target_raw.lower() + lore = "" + if "jared" in target_lower or "@jared" in target_lower: + display_name = "Jared" + lore = _JARED_LORE + elif "wynter" in target_lower or "@wynter" in target_lower: + display_name = "Wynter" + lore = _WYNTER_LORE + else: + # Use the raw mention — strip Matrix ID syntax for display + display_name = target_raw.split(":")[0].lstrip("@") if target_raw.startswith("@") else target_raw + + context = f"Context about {display_name}: {lore} " if lore else "" + prompt = ( + f"{context}" + f"Write a savage but funny 1-2 sentence roast of {display_name}. " + f"Be creative, witty, and biting. No disclaimers, no apologies — just the roast." + ) + + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/generate", + json={"model": BALL_MODEL, "prompt": prompt, "stream": False}, + ) as response: + data = await response.json() + roast = data.get("response", "").strip() + if not roast: + raise ValueError("Empty roast response") + except Exception as e: + logger.error(f"roast generation error: {e}", exc_info=True) + await send_text(client, room_id, "Failed to generate a roast. Try again later.") + return + + plain = f"🔥 Roasting {display_name}...\n{roast}" + html = ( + f'🔥 Roasting {display_name}...
' + f'
{roast}
' + f'via {_model_label(BALL_MODEL)}' + ) + await send_html(client, room_id, plain, html) + + +# --------------------------------------------------------------------------- +# Story +# --------------------------------------------------------------------------- + +_STORY_ACTIVE: dict[str, dict] = {} + + +async def _generate_story_opener() -> str | None: + prompt = ( + "Write an intriguing, creative opening sentence for a collaborative story. " + "Keep it to 1-2 sentences. Be mysterious, adventurous, or funny. " + "Just the opening sentence, no explanation or title." + ) + try: + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/generate", + json={"model": ASK_MODEL, "prompt": prompt, "stream": False}, + ) as response: + data = await response.json() + text = data.get("response", "").strip().strip('"') + if text and len(text) > 10: + return text + except Exception as e: + logger.error(f"story opener generation error: {e}", exc_info=True) + return None + + +async def _generate_story_conclusion(lines: list[str]) -> str | None: + story_so_far = "\n".join(lines) + prompt = ( + f"Here is a collaborative story so far:\n\n{story_so_far}\n\n" + "Write a satisfying 2-3 sentence conclusion to this story. " + "Match the tone and style of the existing text. " + "Just the conclusion, no title or explanation." + ) + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/generate", + json={"model": ASK_MODEL, "prompt": prompt, "stream": False}, + ) as response: + data = await response.json() + text = data.get("response", "").strip() + if text and len(text) > 10: + return text + except Exception as e: + logger.error(f"story conclusion generation error: {e}", exc_info=True) + return None + + +@command("story", "Collaborative AI story — !story | !story add | !story end") +async def cmd_story(client: AsyncClient, room_id: str, sender: str, args: str): + parts = args.strip().split(None, 1) + subcmd = parts[0].lower() if parts else "" + sub_args = parts[1].strip() if len(parts) > 1 else "" + + if subcmd == "add": + if room_id not in _STORY_ACTIVE: + await send_text(client, room_id, "No story in progress! Start one with !story") + return + game = _STORY_ACTIVE[room_id] + if not sub_args: + await send_text(client, room_id, f"Usage: {BOT_PREFIX}story add ") + return + if len(game["lines"]) >= 10: + await send_text(client, room_id, "The story has reached its max length (10 lines). Use !story end to conclude it.") + return + line = sanitize_input(sub_args) + game["lines"].append(line) + count = len(game["lines"]) + plain = f"📖 Line {count} added!\n{line}\n\n({10 - count} lines remaining, or !story end to finish)" + html = ( + f'📖 Line {count} added
' + f'{line}
' + f'{10 - count} lines remaining — !story add <line> or !story end' + ) + await send_html(client, room_id, plain, html) + + elif subcmd == "end": + if room_id not in _STORY_ACTIVE: + await send_text(client, room_id, "No story in progress! Start one with !story") + return + game = _STORY_ACTIVE[room_id] + await send_text(client, room_id, "✍️ Writing the conclusion...") + conclusion = await _generate_story_conclusion(game["lines"]) + if conclusion: + game["lines"].append(conclusion) + full_story = "\n".join(game["lines"]) + del _STORY_ACTIVE[room_id] + plain = f"📖 The Story\n\n{full_story}" + story_html = "
".join(f"

{line}

" for line in game["lines"]) + html = ( + f'📖 The Complete Story
' + f'{story_html}' + ) + await send_html(client, room_id, plain, html) + + else: + # Start new story (no subcommand) + if room_id in _STORY_ACTIVE: + game = _STORY_ACTIVE[room_id] + story_so_far = "\n".join(game["lines"]) + plain = ( + f"📖 Story in progress ({len(game['lines'])} lines):\n\n" + f"{story_so_far}\n\n" + f"Add a line with !story add or finish with !story end" + ) + await send_text(client, room_id, plain) + return + + await send_text(client, room_id, "✍️ Starting a new story...") + opener = await _generate_story_opener() + if opener is None: + await send_text(client, room_id, "Failed to generate a story opener. Try again later.") + return + + _STORY_ACTIVE[room_id] = {"lines": [opener]} + plain = ( + f"📖 A New Story Begins!\n\n{opener}\n\n" + f"Continue with: !story add \n" + f"Finish with: !story end\n" + f"(Max 10 lines)" + ) + html = ( + f'📖 A New Story Begins!
' + f'
{opener}
' + f'Continue: !story add <your line>
' + f'Finish: !story end — max 10 lines' + ) + await send_html(client, room_id, plain, html) + + +# --------------------------------------------------------------------------- +# Debate +# --------------------------------------------------------------------------- + +@command("debate", "AI debates a topic with FOR and AGAINST arguments — !debate ") +async def cmd_debate(client: AsyncClient, room_id: str, sender: str, args: str): + if not args.strip(): + await send_text(client, room_id, f"Usage: {BOT_PREFIX}debate ") + return + + topic = sanitize_input(args.strip()) + if not topic: + await send_text(client, room_id, "Please provide a topic to debate.") + return + + await send_text(client, room_id, f"⚖️ Debating: {topic}...") + + prompt = ( + f"Debate the topic: \"{topic}\"\n\n" + "Write exactly 2-3 sentences FOR the topic, then exactly 2-3 sentences AGAINST the topic.\n" + "Format your response EXACTLY as:\n" + "FOR: \n" + "AGAINST: \n\n" + "No extra text, no markdown, no headers beyond FOR: and AGAINST:." + ) + + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{OLLAMA_URL}/api/generate", + json={"model": ASK_MODEL, "prompt": prompt, "stream": False}, + ) as response: + data = await response.json() + text = data.get("response", "").strip() + + # Parse FOR and AGAINST from the response + for_text = "" + against_text = "" + if "FOR:" in text and "AGAINST:" in text: + for_part = text.split("AGAINST:")[0] + against_part = text.split("AGAINST:")[1] + for_text = for_part.replace("FOR:", "").strip() + against_text = against_part.strip() + else: + # Fallback: try to split in half + lines = [l.strip() for l in text.split("\n") if l.strip()] + mid = len(lines) // 2 + for_text = " ".join(lines[:mid]) if lines else "No argument generated." + against_text = " ".join(lines[mid:]) if lines else "No argument generated." + + if not for_text: + for_text = "No argument generated." + if not against_text: + against_text = "No argument generated." + + plain = ( + f"⚖️ Debate: {topic}\n\n" + f"✅ FOR:\n{for_text}\n\n" + f"❌ AGAINST:\n{against_text}" + ) + html = ( + f'⚖️ Debate: {topic}

' + f'✅ FOR
' + f'
{for_text}
' + f'❌ AGAINST
' + f'
{against_text}
' + f'via {_model_label(ASK_MODEL)}' + ) + await send_html(client, room_id, plain, html) + + 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.")