From e9e731e2bdad2862d268461a645b5063c089cd46 Mon Sep 17 00:00:00 2001 From: Polish Hammer Date: Fri, 31 Jul 2026 16:55:20 +0200 Subject: [PATCH] bar: $nalej invents cocktails, $menu keeps the bar's growing lore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bar_commands.py | 183 ++++++++++++++++++++++++++++++++ bot.py | 1 + constants.py | 8 ++ menu.json | 1 + tests/unit/test_bar_commands.py | 98 +++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 bar_commands.py create mode 100644 menu.json create mode 100644 tests/unit/test_bar_commands.py diff --git a/bar_commands.py b/bar_commands.py new file mode 100644 index 0000000..ef94de2 --- /dev/null +++ b/bar_commands.py @@ -0,0 +1,183 @@ +"""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") diff --git a/bot.py b/bot.py index d09afea..a36310b 100755 --- a/bot.py +++ b/bot.py @@ -91,6 +91,7 @@ CORE_EXTENSIONS = [ "administration_commands", "ai_commands", "other_commands", + "bar_commands", "latex_commands", "voice_recognition_commands", "conanjurer_commands", diff --git a/constants.py b/constants.py index 54ad9b0..fea991e 100644 --- a/constants.py +++ b/constants.py @@ -203,6 +203,13 @@ TRANSCRIPTS_PATH = os.getenv( os.path.join(os.path.dirname(LOGFILE) or ".", "transcripts") + os.sep, ) +# The bar's growing menu of AI-invented drinks (bar_commands). Lives next to the +# conversation memory by default; seeded empty on first run. +MENU_FILE = os.getenv( + "CONJURER_MENU_FILE", + os.path.join(os.path.dirname(MEMORY_FIVE_SIARA) or ".", "menu.json"), +) + FILE_SERVICE_ADDRESS = os.getenv("CONJURER_FILE_SERVICE", "http://192.168.1.15:5000") RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321") # Betoniarka (radio-operator service colocated with Liquidsoap). Falls back to @@ -272,6 +279,7 @@ def _ensure_runtime_layout() -> None: _seed_file(SETTINGS_FILE, "settings.json", "{}") _seed_file(SYSTEM_GPT_SETTINGS, "system_gpt_settings.json", "{}") _seed_file(MEMORY_FIVE_SIARA, "pamiec.json", "[]") + _seed_file(MENU_FILE, "menu.json", "[]") _seed_file(MEMORY_FIVE_MUZYKA, "pamiec_muzyki.json", "[]") _seed_file(ACCIDENT_LOG, "accident_log.json", "[]") diff --git a/menu.json b/menu.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/menu.json @@ -0,0 +1 @@ +[] diff --git a/tests/unit/test_bar_commands.py b/tests/unit/test_bar_commands.py new file mode 100644 index 0000000..f243ad8 --- /dev/null +++ b/tests/unit/test_bar_commands.py @@ -0,0 +1,98 @@ +"""Unit tests for bar_commands: drink-name parsing and menu persistence. + +The unit CI job installs only pytest, so bar_commands' heavy imports (discord, +ai_functions -> openai/tiktoken, communication_subroutine -> flask) are stubbed +only when genuinely absent - locally the real packages are used. +""" +import sys +import types + + +def _stub_if_missing(name, build): + if name in sys.modules: + return + try: + __import__(name) + except ImportError: + sys.modules[name] = build() + + +def _build_discord(): + mod = types.ModuleType("discord") + + class Object: + def __init__(self, id=None): # noqa: A002 - mirrors discord.Object + self.id = id + + mod.Object = Object + ext = types.ModuleType("discord.ext") + commands = types.ModuleType("discord.ext.commands") + + class Cog: + pass + + def hybrid_command(**_kwargs): + return lambda fn: fn + + commands.Cog = Cog + commands.hybrid_command = hybrid_command + ext.commands = commands + mod.ext = ext + sys.modules["discord.ext"] = ext + sys.modules["discord.ext.commands"] = commands + return mod + + +def _build_ai_functions(): + mod = types.ModuleType("ai_functions") + + async def handle_response(*_a, **_k): + return ("stub", []) + + mod.handle_response = handle_response + return mod + + +def _build_comm(): + mod = types.ModuleType("communication_subroutine") + mod.PREPPED_TRACKS = {"now_playing": ""} + return mod + + +_stub_if_missing("discord", _build_discord) +_stub_if_missing("ai_functions", _build_ai_functions) +_stub_if_missing("communication_subroutine", _build_comm) + +import bar_commands # noqa: E402 (after stubbing) + + +def test_extract_name_strips_markers_and_markdown(): + assert bar_commands._extract_name("🍸 Krew Wilka\nskładniki...") == "Krew Wilka" + assert bar_commands._extract_name("**Miód Odyna**\nfoo") == "Miód Odyna" + assert bar_commands._extract_name("1. Cichy Grom\nbar") == "Cichy Grom" + assert bar_commands._extract_name("\n\n Ostatni Skald \nx") == "Ostatni Skald" + + +def test_extract_name_fallback_on_empty(): + assert bar_commands._extract_name("") == "Bezimienny drink" + assert bar_commands._extract_name("\n \n") == "Bezimienny drink" + + +def test_menu_round_trip(tmp_path, monkeypatch): + menu_file = tmp_path / "menu.json" + monkeypatch.setattr(bar_commands, "MENU_FILE", str(menu_file)) + + assert bar_commands._load_menu() == [] # missing file -> empty + + bar_commands._append_menu({"nazwa": "Krew Wilka", "autor": "Hammer"}) + bar_commands._append_menu({"nazwa": "Miód Odyna", "autor": "Siara"}) + + menu = bar_commands._load_menu() + assert [d["nazwa"] for d in menu] == ["Krew Wilka", "Miód Odyna"] + + +def test_load_menu_survives_corrupt_file(tmp_path, monkeypatch): + menu_file = tmp_path / "menu.json" + menu_file.write_text("{ not json", encoding="utf-8") + monkeypatch.setattr(bar_commands, "MENU_FILE", str(menu_file)) + assert bar_commands._load_menu() == []