feat: add 7 new commands — hangman, scramble, wyr, riddle, roast, story, debate
- !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
This commit is contained in:
+808
-2
@@ -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 <letter> or !guess <word>"
|
||||
)
|
||||
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 <letter/word>\n"
|
||||
f"Max 6 wrong guesses."
|
||||
)
|
||||
html = (
|
||||
f'<font color="#f59e0b"><strong>🎯 Hangman!</strong></font><br>'
|
||||
f'<pre>{_HANGMAN_STAGES[0].replace("```", "")}</pre>'
|
||||
f'<strong>Word:</strong> <code>{display}</code> ({len(word)} letters)<br>'
|
||||
f'<strong>Hint:</strong> {hint}<br>'
|
||||
f'<em>Guess with <code>!guess <letter/word></code> — max 6 wrong guesses</em>'
|
||||
)
|
||||
await send_html(client, room_id, plain, html)
|
||||
|
||||
|
||||
@command("guess", "Guess a letter or word in hangman (!guess <letter/word>)")
|
||||
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'<font color="#22c55e"><strong>🎉 Correct! The word was: {word.upper()}</strong></font><br>'
|
||||
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'<font color="#ef4444"><strong>❌ Wrong! Game over — the word was: {word.upper()}</strong></font>'
|
||||
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'<font color="#22c55e"><strong>🎉 Solved! The word was: {word.upper()}</strong></font>'
|
||||
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'<font color="#22c55e"><strong>✅ \'{letter.upper()}\' is in the word!</strong></font><br>'
|
||||
f'<strong>Word:</strong> <code>{display}</code><br>'
|
||||
f'<strong>Hint:</strong> {game["hint"]}<br>'
|
||||
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'<font color="#ef4444"><strong>💀 Game Over!</strong></font><br>'
|
||||
f'<strong>The word was: {word.upper()}</strong><br>'
|
||||
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'<font color="#ef4444"><strong>❌ \'{letter.upper()}\' is not in the word!</strong></font><br>'
|
||||
f'<strong>Word:</strong> <code>{display}</code><br>'
|
||||
f'<strong>Hint:</strong> {game["hint"]}<br>'
|
||||
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'<font color="#3b82f6"><strong>🔀 Scramble!</strong></font><br>'
|
||||
f'Unscramble: <strong><code>{scrambled.upper()}</code></strong><br>'
|
||||
f'<em>First to type the correct word wins! 45 seconds on the clock.</em>'
|
||||
)
|
||||
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'<font color="#f59e0b"><strong>⏰ Time\'s up!</strong></font> The word was: <strong>{word.upper()}</strong>',
|
||||
)
|
||||
|
||||
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'<font color="#22c55e"><strong>🎉 {winner} solved it!</strong></font><br>'
|
||||
f'The word was: <strong>{game["word"].upper()}</strong>'
|
||||
)
|
||||
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'<font color="#a855f7"><strong>🤔 Would You Rather?</strong></font><br>'
|
||||
f'<em>{wyr["question"]}</em><br><br>'
|
||||
f'🅰️ <strong>{wyr["option_a"]}</strong><br>'
|
||||
f'🅱️ <strong>{wyr["option_b"]}</strong><br><br>'
|
||||
f'<em>React with 🅰️ or 🅱️ — results in 30 seconds!</em>'
|
||||
)
|
||||
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'<font color="#a855f7"><strong>⏰ WYR — Time\'s up!</strong></font><br>'
|
||||
f'🅰️ <strong>{wyr["option_a"]}</strong><br>'
|
||||
f'🅱️ <strong>{wyr["option_b"]}</strong><br>'
|
||||
f'<em>Check the reactions on the poll above to see which option won!</em>'
|
||||
)
|
||||
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'<font color="#14b8a6"><strong>🧩 Riddle!</strong></font><br>'
|
||||
f'<blockquote>{riddle}</blockquote>'
|
||||
f'<em>Type your answer in chat — 60 seconds on the clock!</em>'
|
||||
)
|
||||
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'<font color="#f59e0b"><strong>⏰ Time\'s up!</strong></font> The answer was: <strong>{answer}</strong>',
|
||||
)
|
||||
|
||||
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'<font color="#22c55e"><strong>🎉 {winner} solved the riddle!</strong></font><br>'
|
||||
f'The answer was: <strong>{game["answer"]}</strong>'
|
||||
)
|
||||
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'<font color="#ef4444"><strong>🔥 Roasting {display_name}...</strong></font><br>'
|
||||
f'<blockquote>{roast}</blockquote>'
|
||||
f'<sup><em>via {_model_label(BALL_MODEL)}</em></sup>'
|
||||
)
|
||||
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 <line> | !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 <your line>")
|
||||
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'<font color="#3b82f6"><strong>📖 Line {count} added</strong></font><br>'
|
||||
f'<em>{line}</em><br>'
|
||||
f'<sup>{10 - count} lines remaining — <code>!story add <line></code> or <code>!story end</code></sup>'
|
||||
)
|
||||
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 = "<br>".join(f"<p>{line}</p>" for line in game["lines"])
|
||||
html = (
|
||||
f'<font color="#a855f7"><strong>📖 The Complete Story</strong></font><br>'
|
||||
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 <your line> 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 <your line>\n"
|
||||
f"Finish with: !story end\n"
|
||||
f"(Max 10 lines)"
|
||||
)
|
||||
html = (
|
||||
f'<font color="#a855f7"><strong>📖 A New Story Begins!</strong></font><br>'
|
||||
f'<blockquote><em>{opener}</em></blockquote>'
|
||||
f'Continue: <code>!story add <your line></code><br>'
|
||||
f'Finish: <code>!story end</code> — max 10 lines'
|
||||
)
|
||||
await send_html(client, room_id, plain, html)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@command("debate", "AI debates a topic with FOR and AGAINST arguments — !debate <topic>")
|
||||
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 <topic>")
|
||||
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: <your for argument here>\n"
|
||||
"AGAINST: <your against argument here>\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'<font color="#a855f7"><strong>⚖️ Debate: {topic}</strong></font><br><br>'
|
||||
f'<font color="#22c55e"><strong>✅ FOR</strong></font><br>'
|
||||
f'<blockquote>{for_text}</blockquote>'
|
||||
f'<font color="#ef4444"><strong>❌ AGAINST</strong></font><br>'
|
||||
f'<blockquote>{against_text}</blockquote>'
|
||||
f'<sup><em>via {_model_label(ASK_MODEL)}</em></sup>'
|
||||
)
|
||||
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.")
|
||||
|
||||
Reference in New Issue
Block a user