bar: $nalej invents cocktails, $menu keeps the bar's growing lore
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
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
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>
This commit was merged in pull request #5.
This commit is contained in:
+183
@@ -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")
|
||||||
@@ -91,6 +91,7 @@ CORE_EXTENSIONS = [
|
|||||||
"administration_commands",
|
"administration_commands",
|
||||||
"ai_commands",
|
"ai_commands",
|
||||||
"other_commands",
|
"other_commands",
|
||||||
|
"bar_commands",
|
||||||
"latex_commands",
|
"latex_commands",
|
||||||
"voice_recognition_commands",
|
"voice_recognition_commands",
|
||||||
"conanjurer_commands",
|
"conanjurer_commands",
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ TRANSCRIPTS_PATH = os.getenv(
|
|||||||
os.path.join(os.path.dirname(LOGFILE) or ".", "transcripts") + os.sep,
|
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")
|
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")
|
RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321")
|
||||||
# Betoniarka (radio-operator service colocated with Liquidsoap). Falls back to
|
# 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(SETTINGS_FILE, "settings.json", "{}")
|
||||||
_seed_file(SYSTEM_GPT_SETTINGS, "system_gpt_settings.json", "{}")
|
_seed_file(SYSTEM_GPT_SETTINGS, "system_gpt_settings.json", "{}")
|
||||||
_seed_file(MEMORY_FIVE_SIARA, "pamiec.json", "[]")
|
_seed_file(MEMORY_FIVE_SIARA, "pamiec.json", "[]")
|
||||||
|
_seed_file(MENU_FILE, "menu.json", "[]")
|
||||||
_seed_file(MEMORY_FIVE_MUZYKA, "pamiec_muzyki.json", "[]")
|
_seed_file(MEMORY_FIVE_MUZYKA, "pamiec_muzyki.json", "[]")
|
||||||
_seed_file(ACCIDENT_LOG, "accident_log.json", "[]")
|
_seed_file(ACCIDENT_LOG, "accident_log.json", "[]")
|
||||||
|
|
||||||
|
|||||||
@@ -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() == []
|
||||||
Reference in New Issue
Block a user