diff --git a/README.md b/README.md index 66079a4..fdfe933 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ All commands live under one group, `matr`, invoked with a per-guild prefix each with a random SFW anime GIF via the [Fluxpoint API](https://docs.fluxpoint.dev/api/endpoints/sfw-anime-gifs) (like the original MarriageBot). +- **Easter egg** — accuse the bot of fathering your child (pregnancy, + paternity, child support...) and it will deny everything, citing its + empty `children` list. - **Meta** — `info`, `invite`, `stats`, `help`. ## Bot accounts are first-class diff --git a/matrimony/cogs/matr.py b/matrimony/cogs/matr.py index 9e60a5e..782fae9 100644 --- a/matrimony/cogs/matr.py +++ b/matrimony/cogs/matr.py @@ -8,6 +8,8 @@ from __future__ import annotations import hashlib +import random +import re import discord from discord.ext import commands @@ -832,6 +834,61 @@ async def matr_ship( ) +# --------------------------------------------------------------------------- +# Easter egg: paternity denials +# --------------------------------------------------------------------------- + +# An accusation needs BOTH a bot reference (a mention, "matrimony", or the +# word "bot") and a paternity keyword, so ordinary chatter about kids or +# child support doesn't set it off. +_BOT_REFERENCE_RE = re.compile(r"\b(?:matrimony|bot)\b", re.IGNORECASE) +_PATERNITY_RE = re.compile( + r"pregnan|impregnat|paternit|child[ -]?support|baby[ -]?(daddy|mama)|" + r"father of my|knocked (me|her|you) up", + re.IGNORECASE, +) + +_PATERNITY_QUIPS = ( + "I'm a bot. The only thing I can father is a stack trace.", + "Child support? I run on a VPS that costs less than a sandwich.", + "I wasn't born, I was `pip install`-ed. Take it up with PyPI.", + "The paternity test came back: `01001110 01101111`.", + "Impossible. My love language is JSON.", + "Please direct all paternity suits to my legal team: a `try`/`except` " + "block.", + "My `{prefix}{cmd} children` list is empty. Case closed.", +) + + +def _paternity_accusation(content: str, bot_user_id: int | None) -> bool: + """Whether ``content`` accuses this bot of fathering a child.""" + if not _PATERNITY_RE.search(content): + return False + if _BOT_REFERENCE_RE.search(content): + return True + return bot_user_id is not None and ( + f"<@{bot_user_id}>" in content or f"<@!{bot_user_id}>" in content + ) + + +async def _maybe_paternity_quip( + bot: MatrimonyBot, message: discord.Message +) -> None: + """Reply with a random denial when someone accuses the bot of fatherhood.""" + if not bot.should_process(message): + return + bot_id = bot.user.id if bot.user is not None else None + if not _paternity_accusation(message.content, bot_id): + return + prefix = bot.config.default_prefix + if message.guild is not None: + prefix = bot.store.get_prefix(message.guild.id) or prefix + quip = random.choice(_PATERNITY_QUIPS).format( + prefix=prefix, cmd=bot.config.command_name + ) + await message.reply(embed=_embed(quip)) + + # --------------------------------------------------------------------------- # Meta # --------------------------------------------------------------------------- @@ -905,6 +962,11 @@ def __init__(self, bot: MatrimonyBot) -> None: matr.name = bot.config.command_name bot.add_command(matr) + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Easter egg: the bot denies all paternity suits.""" + await _maybe_paternity_quip(self.bot, message) + @commands.Cog.listener() async def on_command_error( self, ctx: commands.Context, error: commands.CommandError diff --git a/tests/test_quips.py b/tests/test_quips.py new file mode 100644 index 0000000..516e5dd --- /dev/null +++ b/tests/test_quips.py @@ -0,0 +1,130 @@ +"""Tests for the paternity-denial Easter egg: detection rules and the +reply behaviour of the ``on_message`` listener helper.""" + +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 Config + +BOT_USER_ID = 999999 + + +def make_bot() -> MatrimonyBot: + bot = MatrimonyBot(Config(database_path=":memory:")) + MatrCog(bot) + # Pretend we're logged in as this user id + bot._connection.user = SimpleNamespace(id=BOT_USER_ID) # type: ignore[attr-defined] + return bot + + +def user(uid: int, *, bot: bool = False): + return SimpleNamespace( + id=uid, + bot=bot, + name=f"user{uid}", + display_name=f"user{uid}", + mention=f"<@{uid}>", + ) + + +def message(author, content: str, guild=None): + return SimpleNamespace( + author=author, + content=content, + guild=guild, + reply=AsyncMock(), + ) + + +def run(coro): + return asyncio.get_event_loop_policy().new_event_loop().run_until_complete(coro) + + +# --------------------------------------------------------------------------- +# Accusation detection +# --------------------------------------------------------------------------- + + +def test_detects_paternity_accusations() -> None: + """Claims that the bot fathered a child must trigger the quip.""" + cases = [ + f"<@{BOT_USER_ID}> got me pregnant", + f"<@!{BOT_USER_ID}> got me pregnant", + "@Matrimony is the father of my child", + "the bot impregnated me, i swear", + "does the bot pay child support??", + "MATRIMONY IS MY BABY DADDY", + "bot paternity test when", + "the bot knocked me up", + ] + for text in cases: + assert matr_mod._paternity_accusation(text, BOT_USER_ID), text + + +def test_ignores_non_accusations() -> None: + """Needs BOTH a bot reference and a paternity keyword.""" + cases = [ + "i'm pregnant!!!", # keyword but no bot reference + "the bot is my friend", # bot reference but no keyword + "child support laws are confusing", # keyword but no bot reference + "my cat is the baby daddy", # no bot reference + "<@12345> got me pregnant", # mentioning a DIFFERENT user + "!matr adopt @someone", + "just chatting", + ] + for text in cases: + assert not matr_mod._paternity_accusation(text, BOT_USER_ID), text + + +# --------------------------------------------------------------------------- +# Reply behaviour +# --------------------------------------------------------------------------- + + +def test_easter_egg_replies_to_accusation() -> None: + bot = make_bot() + msg = message(user(1), f"<@{BOT_USER_ID}> you got me pregnant") + run(matr_mod._maybe_paternity_quip(bot, msg)) + msg.reply.assert_awaited_once() + embed = msg.reply.await_args.kwargs["embed"] + # Whatever quip was picked, it must be one of ours (with the prefix + # and command name filled in). + filled = { + q.format(prefix="!", cmd=bot.config.command_name) + for q in matr_mod._PATERNITY_QUIPS + } + assert embed.description in filled + + +def test_easter_egg_uses_guild_prefix_in_quip() -> None: + """The `children` quip cites the guild's configured prefix.""" + bot = make_bot() + bot.store.set_prefix(42, "?") + guild = SimpleNamespace(id=42) + msg = message(user(1), "the bot got me pregnant", guild=guild) + run(matr_mod._maybe_paternity_quip(bot, msg)) + embed = msg.reply.await_args.kwargs["embed"] + filled = { + q.format(prefix="?", cmd=bot.config.command_name) + for q in matr_mod._PATERNITY_QUIPS + } + assert embed.description in filled + + +def test_easter_egg_ignores_normal_messages() -> None: + bot = make_bot() + msg = message(user(1), "just chatting") + run(matr_mod._maybe_paternity_quip(bot, msg)) + msg.reply.assert_not_awaited() + + +def test_easter_egg_skips_own_messages() -> None: + """The self-loop guard applies to the Easter egg too.""" + bot = make_bot() + msg = message(user(BOT_USER_ID, bot=True), "the bot got me pregnant") + run(matr_mod._maybe_paternity_quip(bot, msg)) + msg.reply.assert_not_awaited()