feat: add 7 new commands — hangman, scramble, wyr, riddle, roast, story, debate
Lint / Shell (shellcheck) (push) Successful in 13s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Failing after 5s
Lint / Python deps (pip-audit) (push) Successful in 1m16s
Lint / Secret scan (gitleaks) (push) Successful in 5s

- !hangman: AI picks a 5-8 letter word with hint; players !guess letters/words, 6 wrong = dead
- !scramble: AI picks a word, scrambles it; first correct answer in chat wins (45s timeout)
- !wyr: AI generates Would You Rather with 🅰️/🅱️ reaction voting, 30s reveal
- !riddle: AI generates riddle monitored for 60s, substring match in chat wins
- !roast: AI roasts a target using BALL_MODEL with special Jared/Wynter lore
- !story: collaborative story with !story add <line> and !story end (AI conclusion, max 10 lines)
- !debate: AI writes FOR/AGAINST arguments for any topic using ASK_MODEL
- callbacks.py: route all non-command messages through scramble/riddle answer checkers
- help: updated categories to include all new commands
This commit is contained in:
2026-04-22 00:35:19 -04:00
parent 9015338a1c
commit 973e422678
2 changed files with 816 additions and 3 deletions
+808 -2
View File
@@ -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 &lt;letter/word&gt;</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 &lt;line&gt;</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 &lt;your line&gt;</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.")