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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
65 changes: 63 additions & 2 deletions matrimony/cogs/matr.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import hashlib
import random

import discord
from discord.ext import commands
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.")
Expand All @@ -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.")

Expand Down Expand Up @@ -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 "<author> <verb> me. <quip> <emoji>".
_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,
Expand Down
92 changes: 92 additions & 0 deletions tests/test_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
Loading