Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions matrimony/cogs/matr.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from __future__ import annotations

import hashlib
import random
import re

import discord
from discord.ext import commands
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions tests/test_quips.py
Original file line number Diff line number Diff line change
@@ -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()
Loading