diff --git a/bot.py b/bot.py index fc4ab18..c6cbf1f 100755 --- a/bot.py +++ b/bot.py @@ -93,6 +93,7 @@ CORE_EXTENSIONS = [ "other_commands", "bar_commands", "lore_commands", + "oracle_commands", "latex_commands", "voice_recognition_commands", "conanjurer_commands", diff --git a/oracle_commands.py b/oracle_commands.py new file mode 100644 index 0000000..d27e7c9 --- /dev/null +++ b/oracle_commands.py @@ -0,0 +1,168 @@ +"""Wyrocznia Conjurera: rune readings in the bot's mythology voice. + +The persona leans hard on Slavic/Norse/Celtic myth and drops Old Norse phrases, +so this fits like a glove: `$runy [pytanie]` draws three Elder Futhark runes +(with upright/reversed orientation) and asks the ACTIVE AI backend to read the +spread in Conjurer's voice; `$runa_dnia` gives a stable per-user, per-day rune. + +The draw is pure and unit-tested. The AI is only the interpretation layer - if it +fails, the reading falls back to the runes' own canned meanings so the command +always answers. +""" +import hashlib +import logging +import random +from datetime import date + +import discord +from discord.ext import commands + +from ai_functions import handle_response +from constants import GPT_SETTINGS, GUILD_ID + +# Elder Futhark. `odwrocone` empty ("") marks a non-invertible (symmetric) rune, +# which is therefore always drawn upright. +RUNES = [ + {"symbol": "ᚠ", "nazwa": "Fehu", "znaczenie": "bogactwo, dobytek, początek", "odwrocone": "strata, chciwość, zahamowanie"}, + {"symbol": "ᚢ", "nazwa": "Uruz", "znaczenie": "dzika siła, zdrowie, wola", "odwrocone": "słabość, choroba, przemoc"}, + {"symbol": "ᚦ", "nazwa": "Thurisaz", "znaczenie": "ochrona, konflikt, olbrzym", "odwrocone": "zagrożenie, gniew, zdrada"}, + {"symbol": "ᚨ", "nazwa": "Ansuz", "znaczenie": "mądrość, słowo, głos Odyna", "odwrocone": "kłamstwo, niezrozumienie, manipulacja"}, + {"symbol": "ᚱ", "nazwa": "Raidho", "znaczenie": "podróż, ruch, właściwa droga", "odwrocone": "zastój, przymus, kryzys w drodze"}, + {"symbol": "ᚲ", "nazwa": "Kenaz", "znaczenie": "ogień wiedzy, twórczość, natchnienie", "odwrocone": "ciemność, wypalenie, złudzenie"}, + {"symbol": "ᚷ", "nazwa": "Gebo", "znaczenie": "dar, wymiana, więź", "odwrocone": ""}, + {"symbol": "ᚹ", "nazwa": "Wunjo", "znaczenie": "radość, harmonia, spełnienie", "odwrocone": "smutek, zniechęcenie, niezgoda"}, + {"symbol": "ᚺ", "nazwa": "Hagalaz", "znaczenie": "grad, gwałtowna zmiana, żywioł", "odwrocone": ""}, + {"symbol": "ᚾ", "nazwa": "Nauthiz", "znaczenie": "potrzeba, ograniczenie, wytrwałość", "odwrocone": "desperacja, brak, przymus losu"}, + {"symbol": "ᛁ", "nazwa": "Isa", "znaczenie": "lód, zastój, cierpliwość", "odwrocone": ""}, + {"symbol": "ᛃ", "nazwa": "Jera", "znaczenie": "plon, cykl, sprawiedliwa nagroda", "odwrocone": ""}, + {"symbol": "ᛇ", "nazwa": "Eihwaz", "znaczenie": "oś świata, przemiana, obrona", "odwrocone": ""}, + {"symbol": "ᛈ", "nazwa": "Perthro", "znaczenie": "los, tajemnica, gra przypadku", "odwrocone": "rozczarowanie, ukryty sekret, pech"}, + {"symbol": "ᛉ", "nazwa": "Algiz", "znaczenie": "ochrona, instynkt, tarcza", "odwrocone": "bezbronność, zagrożenie, utrata czujności"}, + {"symbol": "ᛊ", "nazwa": "Sowilo", "znaczenie": "słońce, zwycięstwo, moc życia", "odwrocone": ""}, + {"symbol": "ᛏ", "nazwa": "Tiwaz", "znaczenie": "honor, sprawiedliwość, wojownik Tyr", "odwrocone": "niesprawiedliwość, porażka, słabnąca wola"}, + {"symbol": "ᛒ", "nazwa": "Berkano", "znaczenie": "wzrost, płodność, narodziny", "odwrocone": "stagnacja, jałowość, kryzys rodzinny"}, + {"symbol": "ᛖ", "nazwa": "Ehwaz", "znaczenie": "koń, ruch, wierne partnerstwo", "odwrocone": "niepokój, zdrada, rozłam"}, + {"symbol": "ᛗ", "nazwa": "Mannaz", "znaczenie": "człowiek, wspólnota, jaźń", "odwrocone": "izolacja, wróg, zaślepienie"}, + {"symbol": "ᛚ", "nazwa": "Laguz", "znaczenie": "woda, intuicja, przepływ", "odwrocone": "lęk, chaos, utonięcie w emocjach"}, + {"symbol": "ᛜ", "nazwa": "Ingwaz", "znaczenie": "nasienie, potencjał, spełnienie", "odwrocone": ""}, + {"symbol": "ᛞ", "nazwa": "Dagaz", "znaczenie": "świt, przełom, przebudzenie", "odwrocone": ""}, + {"symbol": "ᛟ", "nazwa": "Othala", "znaczenie": "dziedzictwo, dom, ród", "odwrocone": "bezdomność, uprzedzenie, zerwanie z korzeniami"}, +] + +_POSITIONS = ("Przeszłość", "Teraźniejszość", "Przyszłość") +_MAX_MSG = 1900 + + +def _meaning(rune: dict, reversed_: bool) -> str: + return rune["odwrocone"] if (reversed_ and rune["odwrocone"]) else rune["znaczenie"] + + +def draw_runes(n: int, rng: random.Random): + """Draw `n` distinct runes as (rune, reversed) pairs. Non-invertible runes + (empty 'odwrocone') are always upright.""" + n = max(1, min(n, len(RUNES))) + picked = rng.sample(RUNES, n) + result = [] + for rune in picked: + reversed_ = bool(rune["odwrocone"]) and rng.random() < 0.5 + result.append((rune, reversed_)) + return result + + +def _rng_for_day(user_id: int, day: str) -> random.Random: + """Deterministic RNG per user per day (stable if asked repeatedly).""" + seed = int.from_bytes(hashlib.sha256(f"{user_id}:{day}".encode("utf-8")).digest()[:8], "big") + return random.Random(seed) + + +def format_draw(drawn) -> str: + lines = [] + for i, (rune, reversed_) in enumerate(drawn): + pos = _POSITIONS[i] + ": " if i < len(_POSITIONS) and len(drawn) == len(_POSITIONS) else "" + orient = " (odwrócona)" if reversed_ else "" + lines.append(f"{rune['symbol']} {pos}**{rune['nazwa']}**{orient} — {_meaning(rune, reversed_)}") + return "\n".join(lines) + + +def _persona() -> str: + 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, barmanem-wyrocznią baru mechawojowników. Mówisz po polsku, odnosisz się do mitologii." + + +class OracleModule(commands.Cog): + """Rune readings.""" + + 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="runy", + description="Conjurer rzuca trzy runy i wróży (opcjonalnie na Twoje pytanie)", + guild=discord.Object(id=GUILD_ID), + ) + async def runy(self, ctx, *, pytanie: str = ""): + async with ctx.channel.typing(): + drawn = draw_runes(3, random.Random()) # nosec B311 - divination flavour + display = format_draw(drawn) + pytanie = (pytanie or "").strip() + + instructions = ( + "Jesteś wyrocznią run. Rzuciłeś układ trzech run (przeszłość / teraźniejszość / " + "przyszłość):\n" + f"{display}\n\n" + f"Pytanie pytającego: {pytanie or '(brak - odczyt ogólny)'}\n\n" + "Zinterpretuj ten układ w swoim stylu, wiążąc runy ze sobą i z pytaniem. Zwięźle, " + "klimatycznie, po polsku, w postaci Conjurera." + ) + prompt = [ + {"role": "system", "content": _persona()}, + {"role": "user", "content": instructions}, + ] + reading = "" + try: + reading, _ = await handle_response("", True, True, [], ctx.author.name, "NONE", none_request=prompt) + except Exception as exc: # pylint: disable=broad-except + self.logger.exception("runy: AI failed: %s", exc) + + header = "*Conjurer wysypuje runy na blat baru:*\n" + display + if reading and reading.strip(): + await self._send_chunked(ctx, header + "\n\n*Mruży oko i wykłada:*\n" + reading.strip()) + else: + # AI down - the runes still speak for themselves. + await self._send_chunked( + ctx, header + "\n\n*Conjurer stuka w blat* Wyrocznia dziś małomówna - runy mówią same za siebie." + ) + + @commands.hybrid_command( + name="runa_dnia", + description="Twoja jedna runa na dziś (stała przez cały dzień)", + guild=discord.Object(id=GUILD_ID), + ) + async def runa_dnia(self, ctx): + async with ctx.channel.typing(): + today = date.today().isoformat() + rng = _rng_for_day(ctx.author.id, today) + rune, reversed_ = draw_runes(1, rng)[0] + orient = " (odwrócona)" if reversed_ else "" + name = ctx.author.nick if getattr(ctx.author, "nick", None) else ctx.author.name + await ctx.send( + f"*Conjurer sięga do skórzanego woreczka i wyciąga dla Ciebie, {name}, jedną runę na dziś:*\n" + f"{rune['symbol']} **{rune['nazwa']}**{orient} — {_meaning(rune, reversed_)}" + ) + + +async def setup(bot): + logger = logging.getLogger("discord") + await bot.add_cog(OracleModule(bot)) + logger.info("Loading oracle commands module done") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..3420ae9 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,72 @@ +"""Shared stubs for the unit tests. + +Several bot modules under test import ``discord`` (and ``discord.ext.commands`` / +``discord.ext.tasks``) at import time. The pytest-only unit CI job doesn't +install discord, so the test modules stub it themselves - but each stubbed only +the pieces IT needed, and because they share ``sys.modules`` the first one to run +won (skip-if-present), leaving a later module that needs e.g. ``discord.ext.tasks`` +with an incomplete stub. That made collection order-dependent and flaky (e.g. +test_bar_commands, whose stub omits ``tasks``, shadowing test_lore_commands, +which needs it). + +Stubbing ``discord`` here - once, completely, before any test module is imported +- removes the ordering dependency. The per-file stubs then simply skip. Only +``discord`` is stubbed centrally; ai_functions / communication_subroutine stay +per-file because different modules legitimately want the real vs a stubbed one. +""" +import sys +import types + + +def _ensure_discord_stub() -> None: + if "discord" in sys.modules: + return + try: # real discord present (local dev) -> use it + import discord # noqa: F401 + + return + except ImportError: + pass + + mod = types.ModuleType("discord") + + class Object: + def __init__(self, id=None): # noqa: A002 - mirrors discord.Object + self.id = id + + class Member: + pass + + mod.Object = Object + mod.Member = Member + + ext = types.ModuleType("discord.ext") + commands = types.ModuleType("discord.ext.commands") + tasks = types.ModuleType("discord.ext.tasks") + + class Cog: + pass + + commands.Cog = Cog + commands.hybrid_command = lambda **_kwargs: (lambda fn: fn) + + def loop(**_kwargs): + def decorator(fn): + fn.before_loop = lambda f: f + return fn + + return decorator + + tasks.loop = loop + commands.tasks = tasks + ext.commands = commands + ext.tasks = tasks + mod.ext = ext + + sys.modules["discord"] = mod + sys.modules["discord.ext"] = ext + sys.modules["discord.ext.commands"] = commands + sys.modules["discord.ext.tasks"] = tasks + + +_ensure_discord_stub() diff --git a/tests/unit/test_oracle_commands.py b/tests/unit/test_oracle_commands.py new file mode 100644 index 0000000..b7d3b58 --- /dev/null +++ b/tests/unit/test_oracle_commands.py @@ -0,0 +1,98 @@ +"""Unit tests for oracle_commands' pure rune-draw logic. + +Heavy imports (discord, ai_functions) are stubbed only when genuinely absent, +matching the pytest-only unit CI job. +""" +import random +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 + self.id = id + + mod.Object = Object + ext = types.ModuleType("discord.ext") + commands = types.ModuleType("discord.ext.commands") + + class Cog: + pass + + commands.Cog = Cog + commands.hybrid_command = lambda **_k: (lambda fn: fn) + 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 reading", []) + + mod.handle_response = handle_response + return mod + + +_stub_if_missing("discord", _build_discord) +_stub_if_missing("ai_functions", _build_ai_functions) + +import oracle_commands # noqa: E402 (after stubbing) + + +def test_full_elder_futhark_present(): + assert len(oracle_commands.RUNES) == 24 + # every rune has the required fields + for r in oracle_commands.RUNES: + assert {"symbol", "nazwa", "znaczenie", "odwrocone"} <= set(r) + + +def test_draw_returns_distinct_runes(): + drawn = oracle_commands.draw_runes(3, random.Random(1)) + names = [r["nazwa"] for r, _ in drawn] + assert len(names) == 3 + assert len(set(names)) == 3 # distinct + + +def test_non_invertible_runes_are_never_reversed(): + # Run many draws of the whole deck; any rune with empty 'odwrocone' must + # never come up reversed. + for seed in range(200): + for rune, reversed_ in oracle_commands.draw_runes(24, random.Random(seed)): + if not rune["odwrocone"]: + assert reversed_ is False, f"{rune['nazwa']} was reversed but is non-invertible" + + +def test_runa_dnia_is_stable_per_user_per_day(): + # Same user + same day -> identical draw; different day -> RNG differs. + r1 = oracle_commands.draw_runes(1, oracle_commands._rng_for_day(42, "2026-07-31"))[0] + r2 = oracle_commands.draw_runes(1, oracle_commands._rng_for_day(42, "2026-07-31"))[0] + assert r1[0]["nazwa"] == r2[0]["nazwa"] and r1[1] == r2[1] + + seeds = { + oracle_commands._rng_for_day(uid, "2026-07-31").random() + for uid in range(50) + } + assert len(seeds) > 1 # different users get different streams + + +def test_meaning_falls_back_to_upright_for_non_invertible(): + gebo = next(r for r in oracle_commands.RUNES if r["nazwa"] == "Gebo") + # even if asked "reversed", a non-invertible rune yields its upright meaning + assert oracle_commands._meaning(gebo, True) == gebo["znaczenie"]