diff --git a/.env.example b/.env.example index 752ce8f..ffc1bb9 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,8 @@ MATRIMONY_MAX_CHILDREN=0 MATRIMONY_MAX_TREE_DEPTH=10 # Seconds a proposal stays open (buttons + accept/decline commands) MATRIMONY_PROPOSAL_TTL=300 + +# Fluxpoint API token for action GIFs (hug/kiss/slap/...). +# Defaults to the provider's shared public-tier key; set your own token here, +# or leave empty to disable GIFs. +# FLUXPOINT_API_KEY= diff --git a/README.md b/README.md index bdfbb1e..66079a4 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,12 @@ All commands live under one group, `matr`, invoked with a per-guild prefix - **Blocks** — users can `block`/`unblock` others from proposing to them. - **Admin force commands** — `forcemarry`, `forcedivorce`, `forceadopt`, `forceemancipate` (Manage Server). -- **Fun commands** — `hug`, `kiss`, `slap`, `punch`, `bite`, `stab`, `ship`. +- **Fun commands** — `hug`, `cuddle`, `kiss`, `pat`, `poke`, `wave`, + `lick`, `nom`, `tickle`, `highfive`, `handhold`, `slap`, `punch`, + `bite`, `stab`, `ship`, + each with a random SFW anime GIF via the + [Fluxpoint API](https://docs.fluxpoint.dev/api/endpoints/sfw-anime-gifs) + (like the original MarriageBot). - **Meta** — `info`, `invite`, `stats`, `help`. ## Bot accounts are first-class @@ -40,9 +45,15 @@ explicitly supports bot-authored commands and bot targets: - Humans can marry/adopt bots, bots can marry/adopt humans, and bots can marry/adopt each other. - The message listener does **not** ignore `message.author.bot`; other bots' - command messages are dispatched exactly like human ones. + command messages are dispatched exactly like human ones. (This requires + overriding `process_commands`: discord.py drops all bot-authored + messages in its default dispatch.) - The only exclusion is Matrimony's *own* messages — enough to prevent self-command loops while leaving every other bot free to participate. +- Command parsing is lenient: leading/trailing whitespace and extra text + after the arguments are tolerated, `@Matrimony matr ...` works as a + mention prefix, and whitespace after the prefix (`! matr help`) is + accepted. - Covered by tests: proposal/accept flows driven by bot-authored commands (`tests/test_dispatch.py`). @@ -71,8 +82,8 @@ explicitly supports bot-authored commands and bot targets: | `!matr forcedivorce @a [@b]` | Force-divorce (Manage Server) | | `!matr forceadopt @parent @child` | Force adoption (Manage Server) | | `!matr forceemancipate @user` | Force-remove a parent (Manage Server) | -| `!matr hug`/`kiss`/`slap`/`punch`/`bite`/`stab @user` | Flavour actions | -| `!matr ship @a [@b]` | Deterministic ship percentage | +| `!matr hug`/`cuddle`/`kiss`/`pat`/`poke`/`wave`/`lick`/`nom`/`tickle`/`highfive`/`handhold`/`slap`/`punch`/`bite`/`stab @user` | Flavour actions (+ GIF) | +| `!matr ship @a [@b]` | Deterministic ship percentage (+ GIF) | | `!matr info` / `invite` / `stats` | Bot information | `@Matrimony matr ...` (mention prefix) also always works. @@ -115,6 +126,37 @@ docker run -e MATRIMONY_TOKEN=... -v matrimony-data:/data matrimony | `MATRIMONY_MAX_CHILDREN` | `0` | Children cap per user (0 = unlimited) | | `MATRIMONY_MAX_TREE_DEPTH` | `10` | Depth limit when rendering trees | | `MATRIMONY_PROPOSAL_TTL` | `300` | Seconds before a proposal expires | +| `FLUXPOINT_API_KEY` | Fluxpoint's shared public key | Fluxpoint API token for action GIFs; set to empty to disable | + +## Action GIFs + +Action replies embed a random SFW anime GIF fetched from the Fluxpoint API +(`GET https://api.fluxpoint.dev/sfw/gif/`, token in the `Authorization` +header). Mappings: + +| Command | Fluxpoint endpoint | +| --- | --- | +| `marry` (marriage completed) | `kiss` | +| `divorce` | `cry` | +| `hug` | `hug` | +| `cuddle` | `hug` *(no `cuddle` endpoint exists; closest fit)* | +| `kiss` | `kiss` | +| `pat` | `pat` | +| `poke` | `poke` | +| `wave` | `wave` | +| `lick` | `lick` | +| `nom` | `feed` *(no `nom` endpoint exists; closest fit)* | +| `tickle` | `tickle` | +| `highfive` | `highfive` | +| `handhold` | `handhold` | +| `slap` | `slap` | +| `punch` | `punch` | +| `bite` | `bite` | +| `stab` | `punch` *(no `stab` endpoint exists; closest fit)* | +| `ship` | `handhold` | + +GIF fetching never blocks the action: a missing/empty key, HTTP error, +timeout, or malformed response just sends the reply without an image. ## Fidelity to the original MarriageBot @@ -146,6 +188,9 @@ Matrimony replicates the original's mechanics as closely as is practical replicated; limits are plain env vars instead. - Slash-command-only upstream additions (`customize-tree`, `transfer-gold`, `runaway` alias kept as `emancipate` alias) are out of scope. +- Upstream sources GIFs from its own store; Matrimony uses Fluxpoint's SFW + anime GIF API (provider's public-tier key by default, override via + `FLUXPOINT_API_KEY`). **Sources:** diff --git a/matrimony/bot.py b/matrimony/bot.py index fee55ee..ac8db96 100644 --- a/matrimony/bot.py +++ b/matrimony/bot.py @@ -9,6 +9,7 @@ from .config import Config from .family import FamilyView +from .gifs import FluxpointClient from .store import Store log = logging.getLogger("matrimony") @@ -31,11 +32,13 @@ def __init__(self, config: Config, store: Store | None = None) -> None: command_prefix=self._get_prefix, intents=intents, case_insensitive=True, + strip_after_prefix=True, help_command=None, ) self.config = config self.store = store or Store(config.database_path) self.family = FamilyView(self.store) + self.gifs = FluxpointClient(config.fluxpoint_api_key) # ------------------------------------------------------------- prefixes @@ -59,6 +62,21 @@ def should_process(self, message: discord.Message) -> bool: accept, and adopt like human users.""" return self.user is None or message.author.id != self.user.id + async def process_commands(self, message: discord.Message) -> None: + """Dispatch ``message`` as a command. + + discord.py's built-in version silently drops every message whose + author is a bot; bots are first-class users here, so the parser only + skips this bot's own messages (``should_process``). Stray whitespace + around the command text is tolerated.""" + if not self.should_process(message): + return + content = message.content.strip() + if content != message.content: + message.content = content + ctx = await self.get_context(message) + await self.invoke(ctx) + async def on_message(self, message: discord.Message) -> None: if not self.should_process(message): return @@ -66,6 +84,10 @@ async def on_message(self, message: discord.Message) -> None: # --------------------------------------------------------------- setup + async def close(self) -> None: + await self.gifs.close() + await super().close() + async def setup_hook(self) -> None: from .cogs import ALL_COGS diff --git a/matrimony/cogs/matr.py b/matrimony/cogs/matr.py index cd24fa9..9e60a5e 100644 --- a/matrimony/cogs/matr.py +++ b/matrimony/cogs/matr.py @@ -29,8 +29,16 @@ def _embed(text: str, colour: discord.Color = GREEN) -> discord.Embed: return discord.Embed(description=text, colour=colour) -async def _say(ctx: commands.Context, text: str, ok: bool = True) -> None: - await ctx.reply(embed=_embed(text, GREEN if ok else RED)) +async def _say( + ctx: commands.Context, + text: str, + ok: bool = True, + image: str | None = None, +) -> None: + emb = _embed(text, GREEN if ok else RED) + if image: + emb.set_image(url=image) + await ctx.reply(embed=emb) def _display(user: discord.User | discord.Member | None, author_id: int) -> str: @@ -54,8 +62,15 @@ def name_of(uid: int) -> str: return name_of -async def _respond(ctx_or_interaction, text: str, ok: bool) -> None: +async def _respond( + ctx_or_interaction, + text: str, + ok: bool, + image: str | None = None, +) -> None: emb = _embed(text, GREEN if ok else RED) + if image: + emb.set_image(url=image) if isinstance(ctx_or_interaction, discord.Interaction): if ctx_or_interaction.response.is_done(): await ctx_or_interaction.followup.send(embed=emb) @@ -132,10 +147,12 @@ async def _enact(bot: MatrimonyBot, proposal: Proposal, ctx_or_int) -> None: target = _name_of(bot, None)(proposal.target) if proposal.kind == "marry": bot.store.add_partners(proposal.proposer, proposal.target) + gif = await bot.gifs.fetch_url("kiss") await _respond( ctx_or_int, f"{proposer} and {target} are now married! :tada:", True, + image=gif, ) elif proposal.kind == "adopt": # proposer adopts target as their child @@ -235,7 +252,9 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed: "`forcedivorce @a [@b]` - `forceadopt @parent @child` - " "`forceemancipate @user`\n" "**Fun**\n" - "`hug`/`kiss`/`slap`/`punch`/`bite`/`stab @user` - `ship @a @b`\n" + "`hug`/`cuddle`/`kiss`/`pat`/`poke`/`wave`/`lick`/`nom`\n" + "`tickle`/`highfive`/`handhold`/`slap`/`punch`/`bite`/`stab " + "@user` - `ship @a @b`\n" "**Meta**\n" "`info` - `invite` - `stats`\n\n" "Family trees are global across servers, and bot accounts can " @@ -317,10 +336,12 @@ async def matr_divorce( await _say(ctx, "You're not married to them.", ok=False) return bot.store.remove_partners(ctx.author.id, target) + gif = await bot.gifs.fetch_url("cry") await _say( ctx, f"{ctx.author.mention} and <@{target}> are now divorced. " ":broken_heart:", + image=gif, ) @@ -750,25 +771,45 @@ async def matr_forceemancipate(ctx: commands.Context, child: discord.User) -> No # Fun (simulation) commands # --------------------------------------------------------------------------- +# name -> (past-tense verb, emoji, Fluxpoint gif type) _FUN_VERBS = { - "hug": ("hugs", ":hugging:"), - "kiss": ("kisses", ":kissing_heart:"), - "slap": ("slaps", ":raised_back_of_hand:"), - "punch": ("punches", ":punch:"), - "bite": ("bites", ":tooth:"), - "stab": ("stabs", ":dagger:"), + "hug": ("hugs", ":hugging:", "hug"), + # Fluxpoint has no "cuddle" endpoint; hug is the same gesture. + "cuddle": ("cuddles", ":people_hugging:", "hug"), + "kiss": ("kisses", ":kissing_heart:", "kiss"), + "pat": ("pats", ":hand_splayed:", "pat"), + "poke": ("pokes", ":point_right:", "poke"), + "wave": ("waves at", ":wave:", "wave"), + "lick": ("licks", ":tongue:", "lick"), + # Fluxpoint has no "nom" endpoint; feed is the same gesture. + "nom": ("noms", ":yum:", "feed"), + "tickle": ("tickles", ":raised_hand:", "tickle"), + "highfive": ("high-fives", ":raised_hands:", "highfive"), + "handhold": ("holds hands with", ":handshake:", "handhold"), + "slap": ("slaps", ":raised_back_of_hand:", "slap"), + "punch": ("punches", ":punch:", "punch"), + "bite": ("bites", ":tooth:", "bite"), + # Fluxpoint has no "stab" endpoint; punch is the closest violent action. + "stab": ("stabs", ":dagger:", "punch"), } def _make_fun(name: str) -> None: - verb, emoji = _FUN_VERBS[name] + verb, emoji, gif_type = _FUN_VERBS[name] @matr.command(name=name) async def _fun(ctx: commands.Context, user: discord.User) -> None: + gif = await _bot(ctx).gifs.fetch_url(gif_type) if user.id == ctx.author.id: - await _say(ctx, f"You {verb} yourself. Okay then. {emoji}") + await _say( + ctx, f"You {verb} yourself. Okay then. {emoji}", image=gif + ) else: - await _say(ctx, f"{ctx.author.mention} {verb} {user.mention}! {emoji}") + await _say( + ctx, + f"{ctx.author.mention} {verb} {user.mention}! {emoji}", + image=gif, + ) _fun.__doc__ = f"{name.capitalize()} another user." @@ -785,7 +826,10 @@ async def matr_ship( b = b or ctx.author lo, hi = sorted((a.id, b.id)) pct = int(hashlib.md5(f"{lo}:{hi}".encode()).hexdigest()[:6], 16) % 101 - await _say(ctx, f"{a.mention} x {b.mention}: **{pct}%** :heart:") + gif = await _bot(ctx).gifs.fetch_url("handhold") + await _say( + ctx, f"{a.mention} x {b.mention}: **{pct}%** :heart:", image=gif + ) # --------------------------------------------------------------------------- diff --git a/matrimony/config.py b/matrimony/config.py index bc5859c..bb62151 100644 --- a/matrimony/config.py +++ b/matrimony/config.py @@ -5,6 +5,10 @@ import os from dataclasses import dataclass +# Fluxpoint's shared public-tier key: the API provider ships it for bots to +# use as a default (see https://fluxpoint.dev). Override with FLUXPOINT_API_KEY. +DEFAULT_FLUXPOINT_API_KEY = "FP-Public-naEjca70OhKMtq67WpzaN8Gs" + @dataclass(frozen=True) class Config: @@ -22,6 +26,10 @@ class Config: max_children: Maximum children per user. 0 = unlimited. max_tree_depth: Depth limit applied when rendering family trees. proposal_ttl_seconds: How long a proposal stays open. + fluxpoint_api_key: Fluxpoint API token used to fetch action GIFs. + Defaults to the provider's public-tier key; set + ``FLUXPOINT_API_KEY`` to your own token, or to an empty string + to disable GIF fetching entirely. """ token: str = "" @@ -32,6 +40,7 @@ class Config: max_children: int = 0 max_tree_depth: int = 10 proposal_ttl_seconds: int = 300 + fluxpoint_api_key: str = DEFAULT_FLUXPOINT_API_KEY @classmethod def from_env(cls, env: dict[str, str] | None = None) -> Config: @@ -45,4 +54,7 @@ def from_env(cls, env: dict[str, str] | None = None) -> Config: max_children=int(env.get("MATRIMONY_MAX_CHILDREN", "0")), max_tree_depth=int(env.get("MATRIMONY_MAX_TREE_DEPTH", "10")), proposal_ttl_seconds=int(env.get("MATRIMONY_PROPOSAL_TTL", "300")), + fluxpoint_api_key=env.get( + "FLUXPOINT_API_KEY", DEFAULT_FLUXPOINT_API_KEY + ), ) diff --git a/matrimony/gifs.py b/matrimony/gifs.py new file mode 100644 index 0000000..cd56843 --- /dev/null +++ b/matrimony/gifs.py @@ -0,0 +1,119 @@ +"""Random SFW anime GIFs from the Fluxpoint API. + +Endpoints documented at https://docs.fluxpoint.dev/api/endpoints/sfw-anime-gifs: +``GET https://api.fluxpoint.dev/sfw/gif/`` with the API token in the +``Authorization`` header returns JSON shaped like:: + + {"success": true, "code": 200, "message": "", "id": "...", "file": "https://..."} + +Every failure mode - missing key, HTTP errors, timeouts, malformed payloads - +resolves to ``None`` so a command can still reply without a GIF. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp + +log = logging.getLogger("matrimony.gifs") + +BASE_URL = "https://api.fluxpoint.dev/sfw/gif" +DEFAULT_TIMEOUT = 10.0 + +# Fluxpoint endpoints used by Matrimony's action commands - the full set +# documented at the URL above. +GIF_TYPES = ( + "baka", + "bite", + "blush", + "cry", + "dance", + "feed", + "fluff", + "grab", + "handhold", + "highfive", + "hug", + "kiss", + "laugh", + "lick", + "neko", + "pat", + "poke", + "punch", + "shrug", + "slap", + "smug", + "stare", + "tickle", + "wag", + "wasted", + "wave", + "wink", +) + + +class FluxpointClient: + """Tiny async client for Fluxpoint's ``/sfw/gif/`` endpoints. + + An ``aiohttp.ClientSession`` is created lazily on first use and reused; + one may be injected for tests. ``close()`` is safe to call repeatedly. + """ + + def __init__( + self, + api_key: str = "", + *, + session: Any = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + self.api_key = api_key + self._session = session + self._owns_session = session is None + self._timeout = aiohttp.ClientTimeout(total=timeout) + + async def _get_session(self) -> Any: + if self._session is None or getattr(self._session, "closed", False): + self._session = aiohttp.ClientSession() + self._owns_session = True + return self._session + + async def fetch_url(self, image_type: str) -> str | None: + """Return a random GIF URL for ``image_type``, or ``None`` on failure. + + Never raises: network errors, non-200 responses, timeouts, and + unexpected payloads are logged and turned into a GIF-less reply. + """ + if not self.api_key: + return None + session = await self._get_session() + try: + async with session.get( + f"{BASE_URL}/{image_type}", + headers={"Authorization": self.api_key}, + timeout=self._timeout, + ) as resp: + if resp.status != 200: + log.warning( + "Fluxpoint gif %r: HTTP %s", image_type, resp.status + ) + return None + data = await resp.json(content_type=None) + except Exception as exc: # aiohttp errors, timeouts, bad JSON + log.warning("Fluxpoint gif %r failed: %s", image_type, exc) + return None + if not isinstance(data, dict) or not data.get("success"): + log.warning("Fluxpoint gif %r: unexpected payload %r", image_type, data) + return None + url = data.get("file") + if not isinstance(url, str) or not url: + log.warning("Fluxpoint gif %r: no file URL in %r", image_type, data) + return None + return url + + async def close(self) -> None: + session, self._session = self._session, None + if session is not None and self._owns_session: + await session.close() diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 472eab3..27989ca 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -172,6 +172,124 @@ def test_proposal_declined_by_bot() -> None: assert bot.store.proposals_for(target.id) == [] +# --------------------------------------------------------------------------- +# End-to-end dispatch through process_commands (real parser, fake message) +# --------------------------------------------------------------------------- + + +def wire_message(bot, author, content: str, guild_id: int = 42): + """A message fake enough to run through the real command parser.""" + return SimpleNamespace( + id=1, + author=author, + content=content, + guild=SimpleNamespace(id=guild_id, get_member=lambda uid: None), + channel=SimpleNamespace(id=7), + to_message_reference_dict=lambda: {"message_id": 1}, + attachments=[], + _state=bot._connection, + ) + + +def stub_send(bot) -> AsyncMock: + """Intercept the HTTP layer so replies don't hit the network.""" + bot._connection.http.send_message = AsyncMock(return_value={"id": "2"}) + # Skip real discord.Message construction for the sent reply. + bot._connection.create_message = ( + lambda channel, data: SimpleNamespace(id=int(data.get("id", 0))) + ) + return bot._connection.http.send_message + + +def test_process_commands_dispatches_bot_author_end_to_end() -> None: + """Regression: discord.py's built-in process_commands silently drops + every message whose author is a bot. Ours must reach the command and + produce a reply - this is what lets other bots use ``!matr``.""" + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(2, bot=True), "!matr help") + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_process_commands_human_author_end_to_end() -> None: + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(1), "!matr help") + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_process_commands_skips_own_messages() -> None: + """The self-command loop guard still applies.""" + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(BOT_USER_ID, bot=True), "!matr help") + run(bot.process_commands(msg)) + send.assert_not_awaited() + + +def test_process_commands_ignores_non_command() -> None: + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(1), "just chatting") + run(bot.process_commands(msg)) + send.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Lenient parsing: whitespace, extra text, mentions +# --------------------------------------------------------------------------- + + +def test_whitespace_after_prefix() -> None: + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(1), "! matr help") + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_whitespace_between_group_and_subcommand() -> None: + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(1), "!matr help") + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_surrounding_whitespace() -> None: + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(1), " !matr help ") + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_extra_text_after_arguments() -> None: + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(1), "!matr help and some trailing text") + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_mention_prefix() -> None: + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(1), f"<@{BOT_USER_ID}> matr help") + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_mention_prefix_from_bot_author() -> None: + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(3, bot=True), f"<@{BOT_USER_ID}> matr help") + run(bot.process_commands(msg)) + send.assert_awaited_once() + + # --------------------------------------------------------------------------- # Prefix resolution # --------------------------------------------------------------------------- @@ -187,3 +305,20 @@ def test_custom_guild_prefix() -> None: # DM/other guild falls back to default other = message(user(1), guild=SimpleNamespace(id=99)) assert "!" in run(bot._get_prefix(bot, other)) + + +def test_custom_guild_prefix_end_to_end() -> None: + bot = make_bot() + send = stub_send(bot) + bot.store.set_prefix(42, "?") + msg = wire_message(bot, user(1), "?matr help", guild_id=42) + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_case_insensitive_command() -> None: + bot = make_bot() + send = stub_send(bot) + msg = wire_message(bot, user(1), "!MATR HELP") + run(bot.process_commands(msg)) + send.assert_awaited_once() diff --git a/tests/test_gifs.py b/tests/test_gifs.py new file mode 100644 index 0000000..15d6b6d --- /dev/null +++ b/tests/test_gifs.py @@ -0,0 +1,295 @@ +"""Fluxpoint GIF client and GIF attachments on the action commands. + +The client's HTTP layer is a fake session injected in place of aiohttp, and +command tests stub ``bot.gifs.fetch_url`` - no real network traffic. +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from matrimony.bot import MatrimonyBot +from matrimony.cogs import matr as matr_mod +from matrimony.cogs.matr import MatrCog +from matrimony.config import DEFAULT_FLUXPOINT_API_KEY, Config +from matrimony.gifs import GIF_TYPES, FluxpointClient + +GIF_URL = "https://img.fluxpoint.dev/123.gif" +PAYLOAD = { + "success": True, + "code": 200, + "message": "", + "id": "123", + "file": GIF_URL, +} + + +def run(coro): + return asyncio.get_event_loop_policy().new_event_loop().run_until_complete(coro) + + +# --------------------------------------------------------------------------- +# Fake aiohttp session/response +# --------------------------------------------------------------------------- + + +class FakeResp: + def __init__(self, status: int = 200, data=None, json_error=None): + self.status = status + self._data = PAYLOAD if data is None else data + self._json_error = json_error + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def json(self, content_type=None): + if self._json_error is not None: + raise self._json_error + return self._data + + +class FakeSession: + """Duck-typed stand-in for aiohttp.ClientSession.""" + + closed = False + + def __init__(self, resp=None, exc=None): + self._resp = resp or FakeResp() + self._exc = exc + self.requests = [] + + def get(self, url, headers=None, timeout=None): + self.requests.append((url, headers)) + if self._exc is not None: + raise self._exc + return self._resp + + +def make_bot(config: Config | None = None) -> MatrimonyBot: + bot = MatrimonyBot(config or Config(database_path=":memory:")) + MatrCog(bot) + return bot + + +def user(uid: int): + return SimpleNamespace( + id=uid, + bot=False, + name=f"user{uid}", + display_name=f"user{uid}", + mention=f"<@{uid}>", + guild_permissions=SimpleNamespace(manage_guild=False), + ) + + +def ctx_for(bot: MatrimonyBot, author): + return SimpleNamespace( + bot=bot, + author=author, + guild=SimpleNamespace(id=42, get_member=lambda uid: None), + channel=SimpleNamespace(id=7), + prefix="!", + reply=AsyncMock(), + command=None, + ) + + +def last_embed(ctx) -> object: + return ctx.reply.await_args.kwargs["embed"] + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + + +def test_default_fluxpoint_key() -> None: + assert Config().fluxpoint_api_key == DEFAULT_FLUXPOINT_API_KEY + + +def test_fluxpoint_key_env_override() -> None: + cfg = Config.from_env({"FLUXPOINT_API_KEY": "FP-test-abc"}) + assert cfg.fluxpoint_api_key == "FP-test-abc" + + +def test_fluxpoint_key_env_empty_disables() -> None: + cfg = Config.from_env({"FLUXPOINT_API_KEY": ""}) + assert cfg.fluxpoint_api_key == "" + + +# --------------------------------------------------------------------------- +# FluxpointClient.fetch_url +# --------------------------------------------------------------------------- + + +def test_fetch_url_success() -> None: + session = FakeSession() + client = FluxpointClient("KEY", session=session) + assert run(client.fetch_url("hug")) == GIF_URL + url, headers = session.requests[0] + assert url == "https://api.fluxpoint.dev/sfw/gif/hug" + assert headers["Authorization"] == "KEY" + + +def test_fetch_url_no_key_makes_no_request() -> None: + session = FakeSession() + client = FluxpointClient("", session=session) + assert run(client.fetch_url("hug")) is None + assert session.requests == [] + + +def test_fetch_url_http_error() -> None: + client = FluxpointClient("KEY", session=FakeSession(resp=FakeResp(500))) + assert run(client.fetch_url("hug")) is None + + +def test_fetch_url_timeout() -> None: + client = FluxpointClient( + "KEY", session=FakeSession(exc=asyncio.TimeoutError()) + ) + assert run(client.fetch_url("hug")) is None + + +def test_fetch_url_bad_json() -> None: + resp = FakeResp(json_error=ValueError("not json")) + client = FluxpointClient("KEY", session=FakeSession(resp=resp)) + assert run(client.fetch_url("hug")) is None + + +def test_fetch_url_non_dict_payload() -> None: + client = FluxpointClient("KEY", session=FakeSession(resp=FakeResp(data=[]))) + assert run(client.fetch_url("hug")) is None + + +def test_fetch_url_unsuccessful_payload() -> None: + resp = FakeResp(data={"success": False, "code": 403, "message": "denied"}) + client = FluxpointClient("KEY", session=FakeSession(resp=resp)) + assert run(client.fetch_url("hug")) is None + + +def test_fetch_url_missing_file_field() -> None: + resp = FakeResp(data={"success": True, "code": 200, "message": "", "id": "1"}) + client = FluxpointClient("KEY", session=FakeSession(resp=resp)) + assert run(client.fetch_url("hug")) is None + + +# --------------------------------------------------------------------------- +# GIFs attached to action commands +# --------------------------------------------------------------------------- + + +def stub_gifs(bot: MatrimonyBot, url: str | None = GIF_URL) -> AsyncMock: + bot.gifs.fetch_url = AsyncMock(return_value=url) # type: ignore[method-assign] + return bot.gifs.fetch_url + + +def test_fun_command_embeds_gif() -> None: + bot = make_bot() + fetch = stub_gifs(bot) + ctx = ctx_for(bot, user(1)) + hug = next(c for c in matr_mod.matr.commands if c.name == "hug") + run(hug.callback(ctx, user(2))) + fetch.assert_awaited_once_with("hug") + assert last_embed(ctx).image.url == GIF_URL + + +def test_stab_uses_punch_endpoint() -> None: + """Fluxpoint has no 'stab' GIF - the punch endpoint is the mapped fit.""" + bot = make_bot() + fetch = stub_gifs(bot) + ctx = ctx_for(bot, user(1)) + stab = next(c for c in matr_mod.matr.commands if c.name == "stab") + run(stab.callback(ctx, user(2))) + fetch.assert_awaited_once_with("punch") + assert last_embed(ctx).image.url == GIF_URL + + +def test_fun_command_works_when_gif_unavailable() -> None: + """A failed fetch must not break the action: reply still goes out, + just without an image.""" + bot = make_bot() + stub_gifs(bot, url=None) + ctx = ctx_for(bot, user(1)) + kiss = next(c for c in matr_mod.matr.commands if c.name == "kiss") + run(kiss.callback(ctx, user(2))) + emb = last_embed(ctx) + assert emb.image.url is None + assert "kisses" in emb.description + + +def test_ship_attaches_gif() -> None: + bot = make_bot() + fetch = stub_gifs(bot) + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_ship.callback(ctx, user(2), user(3))) + fetch.assert_awaited_once_with("handhold") + assert last_embed(ctx).image.url == GIF_URL + + +def test_marriage_completion_attaches_gif() -> None: + bot = make_bot() + fetch = stub_gifs(bot) + a, b = user(10), user(11) + run(matr_mod.matr_marry.callback(ctx_for(bot, a), b)) + ctx_b = ctx_for(bot, b) + run(matr_mod.matr_accept.callback(ctx_b, None)) + fetch.assert_awaited_with("kiss") + assert last_embed(ctx_b).image.url == GIF_URL + + +def test_divorce_attaches_gif() -> None: + bot = make_bot() + fetch = stub_gifs(bot) + a, b = user(20), user(21) + run(matr_mod.matr_marry.callback(ctx_for(bot, a), b)) + run(matr_mod.matr_accept.callback(ctx_for(bot, b), None)) + ctx_a = ctx_for(bot, a) + run(matr_mod.matr_divorce.callback(ctx_a, b)) + fetch.assert_awaited_with("cry") + assert last_embed(ctx_a).image.url == GIF_URL + + +def test_proposal_still_works_when_gif_unavailable() -> None: + bot = make_bot() + stub_gifs(bot, url=None) + a, b = user(30), user(31) + run(matr_mod.matr_marry.callback(ctx_for(bot, a), b)) + ctx_b = ctx_for(bot, b) + run(matr_mod.matr_accept.callback(ctx_b, None)) + assert bot.store.partners_of(a.id) == [b.id] + assert last_embed(ctx_b).image.url is None + + +# --------------------------------------------------------------------------- +# Action command coverage +# --------------------------------------------------------------------------- + + +def test_fun_commands_all_registered() -> None: + """Every entry in _FUN_VERBS becomes a real ``matr`` subcommand.""" + names = {c.name for c in matr_mod.matr.commands} + assert set(matr_mod._FUN_VERBS) <= names + + +def test_fun_gif_types_are_documented_endpoints() -> None: + """Every command's GIF type must be a real Fluxpoint endpoint - a typo + here would silently produce GIF-less replies in production.""" + for name, (_, _, gif_type) in matr_mod._FUN_VERBS.items(): + assert gif_type in GIF_TYPES, name + + +def test_every_fun_command_fetches_and_embeds_its_gif() -> None: + """Each action command hits its mapped endpoint and embeds the URL.""" + for name, (verb, _, gif_type) in matr_mod._FUN_VERBS.items(): + bot = make_bot() + fetch = stub_gifs(bot) + ctx = ctx_for(bot, user(1)) + cmd = next(c for c in matr_mod.matr.commands if c.name == name) + run(cmd.callback(ctx, user(2))) + fetch.assert_awaited_once_with(gif_type) + embed = last_embed(ctx) + assert embed.image.url == GIF_URL, name + assert verb in embed.description, name