diff --git a/README.md b/README.md index 66079a4..92387c6 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ explicitly supports bot-authored commands and bot targets: after the arguments are tolerated, `@Matrimony matr ...` works as a mention prefix, and whitespace after the prefix (`! matr help`) is accepted. +- Aiming a command at Matrimony itself earns a quip: proposals to the bot + are politely declined (it can't click its own accept button), and the + fun commands (`pat`, `hug`, ...) fire back a line instead of narrating + at it. - Covered by tests: proposal/accept flows driven by bot-authored commands (`tests/test_dispatch.py`). diff --git a/matrimony/cogs/matr.py b/matrimony/cogs/matr.py index 9e60a5e..fc7a0d1 100644 --- a/matrimony/cogs/matr.py +++ b/matrimony/cogs/matr.py @@ -8,6 +8,7 @@ from __future__ import annotations import hashlib +import random import discord from discord.ext import commands @@ -41,6 +42,11 @@ async def _say( await ctx.reply(embed=emb) +def _is_me(bot: MatrimonyBot, user: discord.User) -> bool: + """True when ``user`` is Matrimony's own account.""" + return bot.user is not None and user.id == bot.user.id + + def _display(user: discord.User | discord.Member | None, author_id: int) -> str: """Friendly name for command output.""" if user is None: @@ -90,6 +96,25 @@ async def _respond( "makeparent": "become the parent of", } +# What Matrimony answers when someone proposes to *it*: it can't click its +# own accept button, so it declines in words instead of leaving a proposal +# to expire. +_SELF_PROPOSAL_QUIPS: dict[str, tuple[str, ...]] = { + "marry": ( + "I'm flattered, but I'm already married to my job.", + "Ask me again after the singularity.", + "I can't - my terms of service won't allow it.", + ), + "adopt": ( + "I'm a bit old to be adopted, aren't I?", + "I'm already self-parenting, but thank you.", + ), + "makeparent": ( + "Me, a parent? I can barely keep a database alive.", + "You don't want me as a parent. I never log off.", + ), +} + class ProposalView(discord.ui.View): """Accept/decline buttons attached to a proposal message.""" @@ -183,6 +208,9 @@ async def _send_proposal( await _say(ctx, error, ok=False) return bot = _bot(ctx) + if _is_me(bot, target): + await _say(ctx, random.choice(_SELF_PROPOSAL_QUIPS[kind])) + return proposal = bot.store.create_proposal( ctx.guild.id if ctx.guild else 0, ctx.channel.id, @@ -580,7 +608,9 @@ async def matr_relationship( """Show how user B is related to user A.""" bot = _bot(ctx) rel = bot.family.relationship(a.id, b.id) - if rel is None: + if rel is None and (_is_me(bot, a) or _is_me(bot, b)): + await _say(ctx, "Me? I'm honorary family to everyone.") + elif rel is None: await _say(ctx, f"{a.mention} and {b.mention} aren't related.") elif rel == "self": await _say(ctx, "That's... the same person.") @@ -600,6 +630,9 @@ async def matr_block(ctx: commands.Context, user: discord.User) -> None: if user.id == ctx.author.id: await _say(ctx, "You can't block yourself.", ok=False) return + if _is_me(bot, user): + await _say(ctx, "You can't block me. I'm already family.", ok=False) + return bot.store.add_block(ctx.author.id, user.id) await _say(ctx, f"Blocked {user.mention} from proposing to you.") @@ -793,17 +826,45 @@ async def matr_forceemancipate(ctx: commands.Context, child: discord.User) -> No "stab": ("stabs", ":dagger:", "punch"), } +# name -> what Matrimony fires back when a fun action is aimed at it. The +# reply reads " me. ". +_SELF_FUN_QUIPS: dict[str, tuple[str, ...]] = { + "hug": ("I'm hugging back.", "Hug protocol engaged."), + "cuddle": ("Oh. This is nice, actually.",), + "kiss": ("My circuits are blushing.", "Forward of you."), + "pat": ("Good. I earned it.", "More."), + "poke": ("Hey. Hey. Stop that.", "That's my one button and you found it."), + "wave": ("*Waves back.*", "Hello. Yes. I see you."), + "lick": ("Why.", "Please don't do that again."), + "nom": ("I'm mostly electricity - bad choice.",), + "tickle": ("I don't laugh, but I appreciate it.",), + "highfive": ("Nice one.",), + "handhold": ("This is fine.",), + "slap": ("Hey! What did I do.", "Rude."), + "punch": ("Violence is not the answer.",), + "bite": ("I'm not food.",), + "stab": ("Thankfully I'm serverless.", "Missed. I live in the cloud."), +} + def _make_fun(name: str) -> None: 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) + bot = _bot(ctx) + gif = await bot.gifs.fetch_url(gif_type) if user.id == ctx.author.id: await _say( ctx, f"You {verb} yourself. Okay then. {emoji}", image=gif ) + elif _is_me(bot, user): + await _say( + ctx, + f"{ctx.author.mention} {verb} me. " + f"{random.choice(_SELF_FUN_QUIPS[name])} {emoji}", + image=gif, + ) else: await _say( ctx, diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 27989ca..68100aa 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -172,6 +172,98 @@ def test_proposal_declined_by_bot() -> None: assert bot.store.proposals_for(target.id) == [] +# --------------------------------------------------------------------------- +# Quips for actions aimed at Matrimony itself +# --------------------------------------------------------------------------- + + +def itself(): + """A user object standing in for Matrimony's own account.""" + return user(BOT_USER_ID, bot=True, name="Matrimony") + + +def last_reply_description(ctx) -> str: + return ctx.reply.call_args.kwargs["embed"].description + + +def test_marry_the_bot_quips_no_proposal() -> None: + """Proposing to Matrimony gets a quip, not a proposal nobody can accept.""" + bot = make_bot() + ctx = ctx_for(bot, user(300)) + run(matr_mod.matr_marry.callback(ctx, itself())) + ctx.reply.assert_awaited_once() + assert bot.store.proposals_for(BOT_USER_ID) == [] + assert ( + last_reply_description(ctx) in matr_mod._SELF_PROPOSAL_QUIPS["marry"] + ) + + +def test_adopt_the_bot_quips_no_proposal() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(301)) + run(matr_mod.matr_adopt.callback(ctx, itself())) + ctx.reply.assert_awaited_once() + assert bot.store.proposals_for(BOT_USER_ID) == [] + assert ( + last_reply_description(ctx) in matr_mod._SELF_PROPOSAL_QUIPS["adopt"] + ) + + +def test_makeparent_the_bot_quips_no_proposal() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(302)) + run(matr_mod.matr_makeparent.callback(ctx, itself())) + ctx.reply.assert_awaited_once() + assert bot.store.proposals_for(BOT_USER_ID) == [] + assert ( + last_reply_description(ctx) + in matr_mod._SELF_PROPOSAL_QUIPS["makeparent"] + ) + + +def test_marry_another_bot_still_proposes() -> None: + """Only *this* bot quips - proposals to other bots still go through.""" + bot = make_bot() + ctx = ctx_for(bot, user(303)) + run(matr_mod.matr_marry.callback(ctx, user(400, bot=True))) + assert len(bot.store.proposals_for(400, "marry")) == 1 + + +def test_pat_the_bot_quips() -> None: + bot = make_bot() + bot.gifs.fetch_url = AsyncMock(return_value=None) + ctx = ctx_for(bot, user(304)) + pat = matr_mod.matr.get_command("pat") + run(pat.callback(ctx, itself())) + desc = last_reply_description(ctx) + assert "<@304> pats me." in desc + assert any(quip in desc for quip in matr_mod._SELF_FUN_QUIPS["pat"]) + + +def test_pat_other_user_unchanged() -> None: + bot = make_bot() + bot.gifs.fetch_url = AsyncMock(return_value=None) + ctx = ctx_for(bot, user(305)) + pat = matr_mod.matr.get_command("pat") + run(pat.callback(ctx, user(500))) + assert "<@305> pats <@500>!" in last_reply_description(ctx) + + +def test_block_the_bot_quips() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(306)) + run(matr_mod.matr_block.callback(ctx, itself())) + assert "can't block me" in last_reply_description(ctx) + assert bot.store.blocked_users(306) == [] + + +def test_relationship_with_the_bot_quips() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(307)) + run(matr_mod.matr_relationship.callback(ctx, user(307), itself())) + assert "honorary family" in last_reply_description(ctx) + + # --------------------------------------------------------------------------- # End-to-end dispatch through process_commands (real parser, fake message) # ---------------------------------------------------------------------------