Files
conjurer/bar_commands.py
gitea e9e731e2bd
CI / compile (pull_request) Successful in 10s
CI / unit (pull_request) Successful in 16s
CI / integration (pull_request) Successful in 11s
build / build (push) Successful in 42s
CI / compile (push) Successful in 9s
CI / unit (push) Successful in 14s
CI / integration (push) Successful in 11s
bar: $nalej invents cocktails, $menu keeps the bar's growing lore
The most in-character capability the bot has: the persona is literally a 200kg
bartender who mixes strong drinks with intriguing names. New always-loaded cog
bar_commands:

* $nalej [motyw] asks the ACTIVE AI backend (whatever $gadaj_teraz selects) to
  invent one themed cocktail in Conjurer's voice - persona reused from
  GPT_SETTINGS[0] as a system message, instructions as the user turn, via
  handle_response request_type NONE so it never pollutes the bar's conversation
  memory. Empty motyw = a surprise; "radio"/"pod muzykę" themes the drink on the
  track currently playing (PREPPED_TRACKS["now_playing"]).
* every drink is appended to menu.json (new seeded state file, CONJURER_MENU_FILE
  overridable) with name/theme/author/timestamp/full text - emergent bar lore.
* $menu lists the invented drinks and pours one at random from the archive.

Text-only for now; a DALL-E drink image is an easy follow-up (the render path
already exists in ai_commands, OpenAI-only).

constants gains MENU_FILE (next to pamiec.json by default) + its seed; bot.py
registers bar_commands as a core cog. Verified: tests/unit/test_bar_commands.py
covers name extraction (markers/markdown/fallback) and the menu round-trip
incl. corrupt-file tolerance; unit job 32 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 16:55:20 +02:00

184 lines
7.5 KiB
Python

"""Bar commands: Conjurer invents cocktails and keeps a growing menu.
The single most in-character thing the bot does - the persona is literally a
200kg bartender who mixes strong drinks with intriguing names. `$nalej` asks the
active AI backend (whatever $gadaj_teraz points at) to invent a themed cocktail
in Conjurer's voice; every drink is appended to a menu.json that becomes
emergent bar lore, browsable with `$menu`.
"""
import json
import logging
import random
from datetime import datetime
import discord
from discord.ext import commands
from ai_functions import handle_response
from communication_subroutine import PREPPED_TRACKS
from constants import ENCODING, GPT_SETTINGS, GUILD_ID, MENU_FILE
# Keywords that mean "theme the drink on whatever is playing on the radio".
_RADIO_THEMES = {"pod muzykę", "pod muzyke", "radio", "muzyka", "muzyką"}
_MAX_MSG = 1900 # Discord caps messages at 2000 chars.
_MENU_LIST_LIMIT = 15 # how many drink names $menu shows at once
def _persona() -> str:
"""Conjurer's bartender persona (reused from the AI settings, not duplicated)."""
try:
if isinstance(GPT_SETTINGS, list) and GPT_SETTINGS and isinstance(GPT_SETTINGS[0], dict):
return GPT_SETTINGS[0].get("content", "") or ""
except Exception: # pylint: disable=broad-except
pass
return (
"Jesteś Conjurerem, 200-kilowym barmanem i wykidajłą w klimatycznym barze "
"mechawojowników. Mówisz po polsku, jesteś rubaszny, wtrącasz staronorweskie "
"i jidysz powiedzonka."
)
def _load_menu() -> list:
try:
with open(MENU_FILE, "r", encoding=ENCODING) as handle:
data = json.load(handle)
return data if isinstance(data, list) else []
except (FileNotFoundError, json.JSONDecodeError, OSError):
return []
def _append_menu(entry: dict) -> None:
menu = _load_menu()
menu.append(entry)
try:
with open(MENU_FILE, "w", encoding=ENCODING) as handle:
json.dump(menu, handle, indent=2, ensure_ascii=False)
except OSError as exc:
logging.getLogger("discord").warning("Nie mogę zapisać menu do %s: %s", MENU_FILE, exc)
def _extract_name(text: str) -> str:
"""First non-empty line, stripped of the name marker / markdown, as the drink name."""
for line in text.splitlines():
stripped = line.strip().lstrip("🍸#*-–—•0123456789. ").strip().strip("*_`")
if stripped:
return stripped[:120]
return "Bezimienny drink"
class BarModule(commands.Cog):
"""Drink generation + the bar menu."""
def __init__(self, bot):
self.bot = bot
self.logger = logging.getLogger("discord")
async def _send_chunked(self, ctx, text: str) -> None:
text = text or ""
while text:
await ctx.send(text[:_MAX_MSG])
text = text[_MAX_MSG:]
@commands.hybrid_command(
name="nalej",
description="Conjurer wymyśla drinka (opcjonalnie na motyw, albo 'radio' pod obecny kawałek)",
guild=discord.Object(id=GUILD_ID),
)
async def nalej(self, ctx, *, motyw: str = ""):
"""Invent a cocktail themed on `motyw`. Empty = a surprise; 'radio' / 'pod
muzykę' themes it on whatever is currently playing on the radio."""
async with ctx.channel.typing():
motyw = (motyw or "").strip()
author = ctx.author.nick if getattr(ctx.author, "nick", None) else ctx.author.name
if motyw.lower() in _RADIO_THEMES:
now = (PREPPED_TRACKS.get("now_playing") or "").strip()
if now:
theme_desc = f"pod obecnie grany na radiu kawałek: „{now}”"
stored_theme = f"radio: {now}"
else:
theme_desc = "zaskakujący, bo na radiu akurat cisza"
stored_theme = "radio (cisza)"
elif motyw:
theme_desc = f"na motyw: „{motyw}”"
stored_theme = motyw
else:
theme_desc = "całkowicie zaskakujący, wymyślony spontanicznie"
stored_theme = "(niespodzianka)"
instructions = (
f"Wymyśl JEDEN autorski koktajl {theme_desc}. Ma być mocny i mieć intrygującą, "
"klimatyczną nazwę. Format odpowiedzi:\n"
"- pierwsza linia: sama nazwa drinka (bez nagłówków, bez 'Nazwa:'),\n"
"- potem lista składników,\n"
"- potem krótki sposób przygotowania,\n"
"- na końcu jedno-, dwuzdaniowy klimatyczny opis w Twoim stylu.\n"
"Całość po polsku, w postaci Conjurera. Bądź zwięzły."
)
# request_type NONE + a proper [system, user] message list: persona as
# system (so the drink is in-character), instructions as user, and NO
# write to the bar's conversation memory.
prompt = [
{"role": "system", "content": _persona()},
{"role": "user", "content": instructions},
]
try:
result, _ = await handle_response(
"", True, True, [], author, "NONE", none_request=prompt
)
except Exception as exc: # pylint: disable=broad-except
self.logger.exception("nalej: AI failed: %s", exc)
await ctx.send("*Conjurer upuszcza shaker* Coś mi się rozlało - spróbuj jeszcze raz.")
return
name = _extract_name(result)
_append_menu(
{
"nazwa": name,
"motyw": stored_theme,
"autor": author,
"kiedy": datetime.now().isoformat(timespec="seconds"),
"tekst": result,
}
)
self.logger.info("Nowy drink w menu: %s (motyw: %s)", name, stored_theme)
await self._send_chunked(ctx, result)
@commands.hybrid_command(
name="menu",
description="Pokazuje menu wymyślonych do tej pory drinków",
guild=discord.Object(id=GUILD_ID),
)
async def menu(self, ctx):
"""List the bar's invented drinks (names), and pour one from the archive."""
async with ctx.channel.typing():
menu = _load_menu()
if not menu:
await ctx.send(
"*Conjurer stuka w pustą tablicę* Menu jeszcze świeci pustkami. "
"Rzuć `$nalej`, a coś wymyślę."
)
return
total = len(menu)
recent = menu[-_MENU_LIST_LIMIT:]
lines = [
f"🍸 **Menu Baru** — {total} drink(ów) w archiwum"
+ (f" (ostatnie {len(recent)}):" if total > len(recent) else ":")
]
base = total - len(recent)
for i, drink in enumerate(recent, start=1):
lines.append(f"{base + i}. {drink.get('nazwa', '(bez nazwy)')} — od {drink.get('autor', '?')}")
lines.append("\n*Conjurer poleca dziś z archiwum:*")
await self._send_chunked(ctx, "\n".join(lines))
# ...and pour one at random from the whole archive, in full.
pick = random.choice(menu) # nosec B311 - flavour, not security
await self._send_chunked(ctx, pick.get("tekst", pick.get("nazwa", "")))
async def setup(bot):
logger = logging.getLogger("discord")
await bot.add_cog(BarModule(bot))
logger.info("Loading bar commands module done")