From dd9c8107669de7b47118fbc4ce6c5b605ed3cac1 Mon Sep 17 00:00:00 2001 From: commdevin Date: Sat, 17 Oct 2026 20:35:59 +0000 Subject: [PATCH 1/2] Notify existing partners on new marriages (accept-or-divorce) When a proposal is accepted (or a forcemarry lands), the marriage forms immediately as before, and every existing partner of either spouse gets a partner notice with Accept (acknowledge only) and Divorce buttons. !matr accept acknowledges a notice text-side; !matr divorce @partner or the Divorce button ends that partner's own marriage via the normal divorce path. Notices never block the new marriage and are cleared when the underlying marriage ends. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 18 ++- matrimony/cogs/matr.py | 249 +++++++++++++++++++++++++++++++++++++---- matrimony/store.py | 91 ++++++++++++++- tests/test_dispatch.py | 126 ++++++++++++++++++++- tests/test_store.py | 31 +++++ 5 files changed, 486 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 66079a4..570d925 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,10 @@ All commands live under one group, `matr`, invoked with a per-guild prefix ## Features - **Marriages** — propose, accept/decline (buttons *or* text commands), - divorce, multiple simultaneous partners. + divorce, multiple simultaneous partners. When a married user takes + another partner, every existing partner (of *both* spouses) is notified + and can **accept** (acknowledge the arrangement) or **divorce** on the + spot — the new marriage stands either way. - **Parentage** — `adopt` (they become your child) and `makeparent` (they become your parent), `disown`, `disownall`, `emancipate`/`runaway`, `abandon` (wipe your whole family). @@ -62,9 +65,9 @@ explicitly supports bot-authored commands and bot targets: | Command | Description | | --- | --- | | `!matr help` | Show the command list | -| `!matr marry @user` | Propose to a user or bot | +| `!matr marry @user` | Propose to a user or bot (existing partners get an accept/divorce notice) | | `!matr divorce [@user]` | Divorce a partner (required if you have several) | -| `!matr accept [@proposer]` / `decline` | Answer a pending proposal | +| `!matr accept [@proposer]` / `decline` | Answer a pending proposal — `accept` also acknowledges partner notices | | `!matr partners [@user]` | List partners | | `!matr adopt @user` | Ask to adopt a user as your child | | `!matr makeparent @user` | Ask a user to become your parent | @@ -171,13 +174,20 @@ Matrimony replicates the original's mechanics as closely as is practical - **One parent per child**, matching upstream's single-parent tree model. - Proposals need the target's consent (upstream uses buttons; Matrimony supports buttons *and* `accept`/`decline` text commands, which also lets - non-UI bots respond). + non-UI bots respond). The marriage forms the moment the target accepts — + existing partners of either spouse are then notified and may acknowledge + or immediately divorce their own partner. - `incest` is a per-guild admin toggle, off by default. - No partner limit by default (upstream allows polygamy; caps are configurable here). **Known divergences:** +- Upstream has no equivalent of Matrimony's partner notices: when a user + marries someone new, each existing partner of both spouses gets an + accept/divorce prompt (buttons or `accept`/`divorce` text commands). + Accepting is purely an acknowledgement; it never blocks the marriage. + - Upstream renders trees as Graphviz images; Matrimony renders inline text trees (no Graphviz dependency, works in DMs and on lightweight hosts). - Upstream has no documented "bots can be married" support — Matrimony adds diff --git a/matrimony/cogs/matr.py b/matrimony/cogs/matr.py index 9e60a5e..0353f2d 100644 --- a/matrimony/cogs/matr.py +++ b/matrimony/cogs/matr.py @@ -14,7 +14,7 @@ from .. import __version__ from ..bot import MatrimonyBot -from ..store import Proposal +from ..store import Notice, Proposal GREEN = discord.Color(0x2ECC71) RED = discord.Color(0xE74C3C) @@ -80,6 +80,23 @@ async def _respond( await ctx_or_interaction.reply(embed=emb) +async def _post( + ctx_or_interaction, + embed: discord.Embed, + view: discord.ui.View | None = None, +) -> None: + """Post a standalone message (with optional view) to the channel.""" + if isinstance(ctx_or_interaction, discord.Interaction): + if ctx_or_interaction.response.is_done(): + await ctx_or_interaction.followup.send(embed=embed, view=view) + else: + await ctx_or_interaction.response.send_message( + embed=embed, view=view + ) + else: + await ctx_or_interaction.send(embed=embed, view=view) + + # --------------------------------------------------------------------------- # Proposal flow # --------------------------------------------------------------------------- @@ -154,6 +171,14 @@ async def _enact(bot: MatrimonyBot, proposal: Proposal, ctx_or_int) -> None: True, image=gif, ) + await _notify_new_partners( + bot, + ctx_or_int, + proposal.proposer, + proposal.target, + proposal.guild_id, + proposal.channel_id, + ) elif proposal.kind == "adopt": # proposer adopts target as their child if bot.store.add_child(proposal.proposer, proposal.target): @@ -172,6 +197,127 @@ async def _enact(bot: MatrimonyBot, proposal: Proposal, ctx_or_int) -> None: await _respond(ctx_or_int, "They already have a parent.", False) +# --------------------------------------------------------------------------- +# Partner notices +# --------------------------------------------------------------------------- + +# Notices don't block anything, so they get a generous button window; after +# the view times out the text commands (`accept` / `divorce`) still work. +NOTICE_VIEW_TIMEOUT = 60 * 60 * 24 # 24 hours + + +class PartnerNoticeView(discord.ui.View): + """Accept/divorce buttons attached to a new-partner notice.""" + + def __init__(self, notice_id: int, timeout: float = NOTICE_VIEW_TIMEOUT) -> None: + super().__init__(timeout=timeout) + self.notice_id = notice_id + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + notice = interaction.client.store.get_notice(self.notice_id) + if notice is None or interaction.user.id != notice.user_id: + await interaction.response.send_message( + "This notice isn't for you.", ephemeral=True + ) + return False + return True + + @discord.ui.button(label="Accept", style=discord.ButtonStyle.success) + async def accept( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await _resolve_notice( + interaction.client, interaction, self.notice_id, accepted=True + ) + self.stop() + + @discord.ui.button(label="Divorce", style=discord.ButtonStyle.danger) + async def divorce( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await _resolve_notice( + interaction.client, interaction, self.notice_id, accepted=False + ) + self.stop() + + +async def _resolve_notice( + bot: MatrimonyBot, ctx_or_interaction, notice_id: int, *, accepted: bool +) -> bool: + """Answer a partner notice. Returns False if it no longer exists. + + Accepting just acknowledges the new arrangement; the divorce option + ends the notified partner's own marriage via the normal divorce path. + """ + notice = bot.store.get_notice(notice_id) + if notice is None: + await _respond(ctx_or_interaction, "That notice is no longer valid.", False) + return False + bot.store.delete_notice(notice_id) + if accepted: + await _respond( + ctx_or_interaction, + f"Acknowledged - you're still married to <@{notice.spouse_id}>.", + True, + ) + return True + if bot.store.remove_partners(notice.user_id, notice.spouse_id): + gif = await bot.gifs.fetch_url("cry") + await _respond( + ctx_or_interaction, + f"<@{notice.user_id}> and <@{notice.spouse_id}> are now divorced. " + ":broken_heart:", + True, + image=gif, + ) + else: + await _respond( + ctx_or_interaction, "You're no longer married to them.", False + ) + return True + + +async def _notify_new_partners( + bot: MatrimonyBot, + ctx_or_interaction, + user_a: int, + user_b: int, + guild_id: int, + channel_id: int, +) -> None: + """Tell each existing partner of a newly-married pair about it. + + Every partner of ``user_a`` or ``user_b`` (other than each other) gets + a notice with accept/divorce options. The marriage stands either way - + the notice only gives existing partners a one-click divorce or an + acknowledgement. + """ + prefix = ( + getattr(ctx_or_interaction, "prefix", None) + or bot.config.default_prefix + ) + cmd = bot.config.command_name + for spouse_id, other_id in ((user_a, user_b), (user_b, user_a)): + for partner_id in bot.store.partners_of(spouse_id): + if partner_id in (user_a, user_b): + continue + notice = bot.store.create_notice( + guild_id, channel_id, partner_id, spouse_id, other_id + ) + await _post( + ctx_or_interaction, + _embed( + f"<@{partner_id}>: your partner <@{spouse_id}> also " + f"married <@{other_id}>! Accept the new arrangement or " + f"divorce <@{spouse_id}> - buttons below, or " + f"`{prefix}{cmd} accept` / `{prefix}{cmd} divorce " + f"<@{spouse_id}>`.", + GOLD, + ), + PartnerNoticeView(notice.id), + ) + + async def _send_proposal( ctx: commands.Context, target: discord.User | discord.Member, @@ -196,37 +342,61 @@ async def _send_proposal( return verb = PROPOSAL_VERBS[kind] cmd = bot.config.command_name + notice_line = "" + if kind == "marry": + others = sorted( + set(bot.store.partners_of(ctx.author.id)) + | set(bot.store.partners_of(target.id)) + ) + if others: + notice_line = ( + "\nExisting partners " + + ", ".join(f"<@{p}>" for p in others) + + " will be notified and may accept or divorce." + ) await ctx.reply( embed=_embed( f"{ctx.author.mention} wants to {verb} {target.mention}!\n" f"Accept with the buttons below or `{ctx.prefix}{cmd} accept " f"{ctx.author.mention}` (decline likewise). Proposals expire " - f"after {bot.config.proposal_ttl_seconds // 60} minutes.", + f"after {bot.config.proposal_ttl_seconds // 60} minutes." + f"{notice_line}", GOLD, ), view=ProposalView(proposal, timeout=bot.config.proposal_ttl_seconds), ) -async def _pick_proposal( +def _item_matches(item: Proposal | Notice, user_id: int) -> bool: + """Whether an explicit ``@user`` argument selects this pending item.""" + if isinstance(item, Proposal): + return item.proposer == user_id + return user_id in (item.spouse_id, item.other_id) + + +async def _pick_answerable( ctx: commands.Context, proposer: discord.User | None -) -> Proposal | None: - """Find the proposal ``ctx.author`` wants to act on.""" - pending = _bot(ctx).store.proposals_for(ctx.author.id) +) -> Proposal | Notice | None: + """Find the proposal or partner notice ``ctx.author`` wants to answer.""" + bot = _bot(ctx) + items: list[Proposal | Notice] = ( + bot.store.proposals_for(ctx.author.id) + + bot.store.notices_for(ctx.author.id) + ) if proposer is not None: - pending = [p for p in pending if p.proposer == proposer.id] - if not pending: - await _say(ctx, "You have no pending proposals to answer.", ok=False) + items = [it for it in items if _item_matches(it, proposer.id)] + if not items: + await _say(ctx, "You have nothing pending to answer.", ok=False) return None - if len(pending) > 1: + if len(items) > 1: await _say( ctx, - "You have multiple pending proposals - mention the proposer to " - "pick one.", + "You have multiple pending proposals/notices - mention the " + "proposer or your partner to pick one.", ok=False, ) return None - return pending[0] + return items[0] def _help_embed(prefix: str, cmd: str) -> discord.Embed: @@ -238,6 +408,8 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed: "**Love**\n" "`marry @user` propose - `divorce [@user]` - " "`accept`/`decline [@user]` - `partners [@user]`\n" + "*(marrying notifies existing partners, who can accept or " + "divorce)*\n" "**Parentage**\n" "`adopt @user` - `makeparent @user` - `disown @child` - " "`disownall` - `emancipate` - `abandon`\n" @@ -258,7 +430,9 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed: "**Meta**\n" "`info` - `invite` - `stats`\n\n" "Family trees are global across servers, and bot accounts can " - "use commands and be married or adopted." + "use commands and be married or adopted. Users may have multiple " + "partners; existing partners are notified of each new marriage " + "and may acknowledge it or divorce on the spot." ), colour=GOLD, ) @@ -349,10 +523,12 @@ async def matr_divorce( async def matr_accept( ctx: commands.Context, proposer: discord.User | None = None ) -> None: - """Accept a pending proposal (usable in DMs).""" - proposal = await _pick_proposal(ctx, proposer) - if proposal: - await _resolve_proposal(_bot(ctx), ctx, proposal.id, accepted=True) + """Accept a pending proposal or partner notice (usable in DMs).""" + item = await _pick_answerable(ctx, proposer) + if isinstance(item, Proposal): + await _resolve_proposal(_bot(ctx), ctx, item.id, accepted=True) + elif isinstance(item, Notice): + await _resolve_notice(_bot(ctx), ctx, item.id, accepted=True) @matr.command(name="decline", aliases=["deny"]) @@ -360,9 +536,34 @@ async def matr_decline( ctx: commands.Context, proposer: discord.User | None = None ) -> None: """Decline a pending proposal.""" - proposal = await _pick_proposal(ctx, proposer) - if proposal: - await _resolve_proposal(_bot(ctx), ctx, proposal.id, accepted=False) + bot = _bot(ctx) + pending = bot.store.proposals_for(ctx.author.id) + if proposer is not None: + pending = [p for p in pending if p.proposer == proposer.id] + if not pending: + if bot.store.notices_for(ctx.author.id): + cmd = bot.config.command_name + await _say( + ctx, + "No proposals to decline - but you have a new-partner notice. " + f"Acknowledge it with `{ctx.prefix}{cmd} accept` or leave " + f"the marriage with `{ctx.prefix}{cmd} divorce @partner`.", + ok=False, + ) + else: + await _say( + ctx, "You have no pending proposals to answer.", ok=False + ) + return + if len(pending) > 1: + await _say( + ctx, + "You have multiple pending proposals - mention the proposer to " + "pick one.", + ok=False, + ) + return + await _resolve_proposal(bot, ctx, pending[0].id, accepted=False) @matr.command(name="partners", aliases=["spouse", "spouses", "partner"]) @@ -699,6 +900,9 @@ async def matr_forcemarry( return bot.store.add_partners(a.id, b.id) await _say(ctx, f"{a.mention} and {b.mention} are now married. :tada:") + await _notify_new_partners( + bot, ctx, a.id, b.id, ctx.guild.id, ctx.channel.id + ) @matr.command(name="forcedivorce") @@ -888,7 +1092,8 @@ async def matr_stats(ctx: commands.Context) -> None: f"Guilds: **{len(bot.guilds)}**\n" f"Marriages: **{s['marriages']}**\n" f"Parent-child links: **{s['parent_child_links']}**\n" - f"Open proposals: **{s['open_proposals']}**" + f"Open proposals: **{s['open_proposals']}**\n" + f"Partner notices: **{s['partner_notices']}**" ), ) ) diff --git a/matrimony/store.py b/matrimony/store.py index 4c78e3a..970b7ec 100644 --- a/matrimony/store.py +++ b/matrimony/store.py @@ -2,8 +2,10 @@ Family relationships are *global* (cross-server), matching the original MarriageBot design where family trees span every guild the bot is in. -Guild-scoped data is limited to per-guild settings (prefix, incest toggle) -and the guild/channel a proposal was made in. +Guild-scoped data is limited to per-guild settings (prefix, incest toggle), +the guild/channel a proposal was made in, and partner notices - the +"your partner married someone new" alerts existing partners can accept +(acknowledge) or answer with a divorce. """ from __future__ import annotations @@ -51,6 +53,16 @@ blocked INTEGER NOT NULL, PRIMARY KEY (blocker, blocked) ); + +CREATE TABLE IF NOT EXISTS notices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + spouse_id INTEGER NOT NULL, + other_id INTEGER NOT NULL, + created_at INTEGER NOT NULL +); """ @@ -66,6 +78,26 @@ class Proposal: expires_at: int +@dataclass(frozen=True) +class Notice: + """A "your partner married someone new" notification. + + ``user_id`` is the existing partner being told; ``spouse_id`` is their + partner who just formed another marriage; ``other_id`` is the new + spouse. Notices never block anything: accepting simply acknowledges + the arrangement, and the divorce option dissolves ``user_id``'s own + marriage to ``spouse_id``. + """ + + id: int + guild_id: int + channel_id: int + user_id: int + spouse_id: int + other_id: int + created_at: int + + class Store: """Synchronous SQLite store. All operations are sub-millisecond local queries; the cog layer may wrap them in ``asyncio.to_thread`` if desired, @@ -148,6 +180,12 @@ def remove_partners(self, user_x: int, user_y: int) -> bool: cur = self._conn.execute( "DELETE FROM partners WHERE user_a = ? AND user_b = ?", (a, b) ) + # Notices that presume this marriage still exists are now stale. + self._conn.execute( + "DELETE FROM notices WHERE (user_id = ? AND spouse_id = ?) " + "OR (user_id = ? AND spouse_id = ?)", + (user_x, user_y, user_y, user_x), + ) self._conn.commit() return cur.rowcount > 0 @@ -270,6 +308,54 @@ def expire_proposals(self, now: int | None = None) -> int: self._conn.commit() return cur.rowcount + # --------------------------------------------------------------- notices + + def create_notice( + self, + guild_id: int, + channel_id: int, + user_id: int, + spouse_id: int, + other_id: int, + ) -> Notice: + now = int(time.time()) + with self._lock: + cur = self._conn.execute( + """INSERT INTO notices + (guild_id, channel_id, user_id, spouse_id, other_id, + created_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (guild_id, channel_id, user_id, spouse_id, other_id, now), + ) + self._conn.commit() + nid = cur.lastrowid + return Notice(nid, guild_id, channel_id, user_id, spouse_id, + other_id, now) + + def get_notice(self, notice_id: int) -> Notice | None: + row = self._row( + "SELECT id, guild_id, channel_id, user_id, spouse_id, other_id, " + "created_at FROM notices WHERE id = ?", + (notice_id,), + ) + return Notice(*row) if row else None + + def notices_for(self, user_id: int) -> list[Notice]: + rows = self._rows( + "SELECT id, guild_id, channel_id, user_id, spouse_id, other_id, " + "created_at FROM notices WHERE user_id = ?", + (user_id,), + ) + return [Notice(*r) for r in rows] + + def delete_notice(self, notice_id: int) -> bool: + with self._lock: + cur = self._conn.execute( + "DELETE FROM notices WHERE id = ?", (notice_id,) + ) + self._conn.commit() + return cur.rowcount > 0 + # ----------------------------------------------------------------- blocks def add_block(self, blocker: int, blocked: int) -> None: @@ -331,6 +417,7 @@ def count(table: str) -> int: "marriages": count("partners"), "parent_child_links": count("children"), "open_proposals": count("proposals"), + "partner_notices": count("notices"), "blocks": count("blocks"), "guild_settings": count("guild_settings"), } diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 27989ca..8e8f608 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -15,7 +15,8 @@ def make_bot() -> MatrimonyBot: - bot = MatrimonyBot(Config(database_path=":memory:")) + # No Fluxpoint key: GIF lookups short-circuit instead of hitting the API. + bot = MatrimonyBot(Config(database_path=":memory:", fluxpoint_api_key="")) MatrCog(bot) # Pretend we're logged in as this user id bot._connection.user = SimpleNamespace(id=BOT_USER_ID) # type: ignore[attr-defined] @@ -48,6 +49,7 @@ def ctx_for(bot: MatrimonyBot, author, guild_id: int = 42): channel=SimpleNamespace(id=7), prefix="!", reply=AsyncMock(), + send=AsyncMock(), command=None, ) @@ -172,6 +174,128 @@ def test_proposal_declined_by_bot() -> None: assert bot.store.proposals_for(target.id) == [] +# --------------------------------------------------------------------------- +# Polyamory: existing partners get an accept/divorce notice on a new marriage +# --------------------------------------------------------------------------- + + +def _triangle(bot) -> tuple: + """a proposes to b, who is already married to c; b accepts.""" + a, b, c = user(1101), user(1102), user(1103) + bot.store.add_partners(b.id, c.id) + run(matr_mod.matr_marry.callback(ctx_for(bot, a), b)) + run(matr_mod.matr_accept.callback(ctx_for(bot, b), None)) + return a, b, c + + +def test_new_marriage_notifies_existing_partners() -> None: + """The marriage forms immediately on the target's accept; the existing + partner is merely notified - their answer never blocks it.""" + bot = make_bot() + a, b, c = _triangle(bot) + assert sorted(bot.store.partners_of(b.id)) == sorted([a.id, c.id]) + notices = bot.store.notices_for(c.id) + assert len(notices) == 1 + assert notices[0].spouse_id == b.id + assert notices[0].other_id == a.id + # The newlyweds themselves get no notice. + assert bot.store.notices_for(a.id) == [] + assert bot.store.notices_for(b.id) == [] + + +def test_proposers_partners_also_notified() -> None: + """Partners of the proposer are notified too, not just the target's.""" + bot = make_bot() + a, b, d = user(1104), user(1105), user(1106) + bot.store.add_partners(a.id, d.id) + run(matr_mod.matr_marry.callback(ctx_for(bot, a), b)) + run(matr_mod.matr_accept.callback(ctx_for(bot, b), None)) + notices = bot.store.notices_for(d.id) + assert len(notices) == 1 + assert notices[0].spouse_id == a.id + assert notices[0].other_id == b.id + + +def test_no_notices_for_first_marriage() -> None: + """The single-partner flow is unchanged: no notices when nobody was + already married.""" + bot = make_bot() + a, b = user(1107), user(1108) + run(matr_mod.matr_marry.callback(ctx_for(bot, a), b)) + run(matr_mod.matr_accept.callback(ctx_for(bot, b), None)) + assert bot.store.partners_of(a.id) == [b.id] + assert bot.store._row("SELECT COUNT(*) FROM notices")[0] == 0 + + +def test_notice_accept_acknowledges() -> None: + """`!matr accept` on a notice is acknowledgement only: the existing + marriage and the new one both stay.""" + bot = make_bot() + a, b, c = _triangle(bot) + run(matr_mod.matr_accept.callback(ctx_for(bot, c), None)) + assert bot.store.notices_for(c.id) == [] + assert c.id in bot.store.partners_of(b.id) + assert a.id in bot.store.partners_of(b.id) + + +def test_notice_divorce_via_text_command() -> None: + """The notice's divorce option maps onto the normal `divorce` command.""" + bot = make_bot() + a, b, c = _triangle(bot) + run(matr_mod.matr_divorce.callback(ctx_for(bot, c), b)) + assert bot.store.partners_of(c.id) == [] + assert bot.store.partners_of(b.id) == [a.id] + # The notice is cleared once the underlying marriage is gone. + assert bot.store.notices_for(c.id) == [] + + +def test_notice_divorce_via_button_path() -> None: + """Same path the Divorce button in PartnerNoticeView calls.""" + bot = make_bot() + a, b, c = _triangle(bot) + notice = bot.store.notices_for(c.id)[0] + run( + matr_mod._resolve_notice( + bot, ctx_for(bot, c), notice.id, accepted=False + ) + ) + assert bot.store.partners_of(c.id) == [] + assert bot.store.partners_of(b.id) == [a.id] + assert bot.store.notices_for(c.id) == [] + + +def test_notice_survives_unanswered() -> None: + """No answer is required: an ignored notice never undoes the marriage.""" + bot = make_bot() + a, b, c = _triangle(bot) + assert c.id in bot.store.partners_of(b.id) + assert a.id in bot.store.partners_of(b.id) + + +def test_forcemarry_notifies_existing_partners() -> None: + """Force-marriages notify existing partners the same way.""" + bot = make_bot() + admin, a, b, c = user(1201), user(1202), user(1203), user(1204) + bot.store.add_partners(a.id, c.id) + run(matr_mod.matr_forcemarry.callback(ctx_for(bot, admin), a, b)) + assert b.id in bot.store.partners_of(a.id) + notices = bot.store.notices_for(c.id) + assert len(notices) == 1 + assert notices[0].spouse_id == a.id + assert notices[0].other_id == b.id + + +def test_decline_with_only_a_notice_hints_at_divorce() -> None: + """`decline` still applies to proposals only; with just a notice pending + it points the user at `divorce` and leaves everything intact.""" + bot = make_bot() + a, b, c = _triangle(bot) + ctx = ctx_for(bot, c) + run(matr_mod.matr_decline.callback(ctx, None)) + assert c.id in bot.store.partners_of(b.id) + assert len(bot.store.notices_for(c.id)) == 1 + + # --------------------------------------------------------------------------- # End-to-end dispatch through process_commands (real parser, fake message) # --------------------------------------------------------------------------- diff --git a/tests/test_store.py b/tests/test_store.py index 126a1e7..c1621b8 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -75,6 +75,35 @@ def test_proposals_for_filters(store: Store) -> None: assert store.proposals_for(21) == [] +def test_notice_lifecycle(store: Store) -> None: + n = store.create_notice(1, 2, 30, 10, 20) + assert n.user_id == 30 + assert n.spouse_id == 10 + assert n.other_id == 20 + assert store.get_notice(n.id) == n + assert store.notices_for(30) == [n] + assert store.notices_for(31) == [] + + assert store.delete_notice(n.id) is True + assert store.delete_notice(n.id) is False + assert store.get_notice(n.id) is None + assert store.notices_for(30) == [] + + +def test_notices_cleared_on_divorce(store: Store) -> None: + """Notices presume the user/spouse marriage still exists - dissolving it + (divorce/abandon/forcedivorce all call remove_partners) clears them.""" + store.add_partners(10, 20) + store.create_notice(1, 2, 10, 20, 30) # tells 10 that 20 married 30 + store.create_notice(1, 2, 20, 10, 30) # and the reverse direction + store.create_notice(1, 2, 40, 10, 50) # unrelated: tells 40 re 10/50 + + store.remove_partners(10, 20) + assert store.notices_for(10) == [] + assert store.notices_for(20) == [] + assert len(store.notices_for(40)) == 1 + + def test_blocks(store: Store) -> None: store.add_block(1, 2) assert store.is_blocked(1, 2) is True @@ -115,11 +144,13 @@ def test_stats(store: Store) -> None: store.add_partners(1, 2) store.add_child(1, 3) store.create_proposal(9, 9, 5, 6, "marry", 300) + store.create_notice(9, 9, 5, 6, 7) store.add_block(7, 8) s = store.stats() assert s["marriages"] == 1 assert s["parent_child_links"] == 1 assert s["open_proposals"] == 1 + assert s["partner_notices"] == 1 assert s["blocks"] == 1 From 9705ed9f215dc50189ab1e553a541413cbfc2f50 Mon Sep 17 00:00:00 2001 From: commdevin Date: Sat, 17 Oct 2026 20:57:51 +0000 Subject: [PATCH 2/2] Add bot-directed quips, /matr slash commands, and flirt/date/anniversary - Commands aimed at the bot append a rotating quip as Discord subtext (-# line); proposals at the bot are declined with a quip instead of creating a proposal. - Every !matr command is now a discord.py hybrid, so /matr mirrors the prefix surface: core commands directly under /matr, fun/admin/meta in nested subgroups (Discord caps groups at 25 children), synced in setup_hook. guild_only and manage_guild propagate to the slash side. - New fun commands: flirt (pickup lines), date (date ideas), anniversary (marriage duration via store.partnership_since). - Tests for quips, slash tree layout/permissions, and the new commands; README updated. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 26 ++- matrimony/bot.py | 4 + matrimony/cogs/matr.py | 376 ++++++++++++++++++++++++++++++++++++-- matrimony/store.py | 9 + tests/test_quips_slash.py | 199 ++++++++++++++++++++ 5 files changed, 596 insertions(+), 18 deletions(-) create mode 100644 tests/test_quips_slash.py diff --git a/README.md b/README.md index 570d925..845d98b 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ A MarriageBot-style family-tree bot for Discord, written in Python with adopt, disown, and render family trees — **for humans and bot accounts alike**. All commands live under one group, `matr`, invoked with a per-guild prefix -(default `!`): +(default `!`) or as Discord slash commands: ``` !matr +/matr ``` ## Features @@ -34,10 +35,18 @@ All commands live under one group, `matr`, invoked with a per-guild prefix `forceemancipate` (Manage Server). - **Fun commands** — `hug`, `cuddle`, `kiss`, `pat`, `poke`, `wave`, `lick`, `nom`, `tickle`, `highfive`, `handhold`, `slap`, `punch`, - `bite`, `stab`, `ship`, + `bite`, `stab`, `ship`, `flirt` (terrible pickup lines), `date` + (terrible date ideas), `anniversary` (how long you've been married), each with a random SFW anime GIF via the [Fluxpoint API](https://docs.fluxpoint.dev/api/endpoints/sfw-anime-gifs) (like the original MarriageBot). +- **Bot-directed quips** — aiming an action at Matrimony itself appends a + rotating one-liner as Discord subtext (a `-# ` line) under the normal + reply, and proposals at the bot are declined outright ("sorry, taken by + discord.py"). +- **Slash commands** — every command is a discord.py hybrid, so `/matr` + mirrors `!matr` exactly: `/matr marry`, `/matr fun hug`, + `/matr admin forcemarry`, `/matr meta stats`. - **Meta** — `info`, `invite`, `stats`, `help`. ## Bot accounts are first-class @@ -87,6 +96,9 @@ explicitly supports bot-authored commands and bot targets: | `!matr forceemancipate @user` | Force-remove a parent (Manage Server) | | `!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 flirt @user` (alias `rizz`) | Terrible pickup line (+ GIF) | +| `!matr date @user` (alias `dateidea`) | Terrible date idea (+ GIF) | +| `!matr anniversary [@partner]` (alias `anniv`) | Time married (+ GIF) | | `!matr info` / `invite` / `stats` | Bot information | `@Matrimony matr ...` (mention prefix) also always works. @@ -123,7 +135,7 @@ docker run -e MATRIMONY_TOKEN=... -v matrimony-data:/data matrimony | --- | --- | --- | | `MATRIMONY_TOKEN` | *(required)* | Discord bot token | | `MATRIMONY_DEFAULT_PREFIX` | `!` | Fallback prefix for guilds/DMs | -| `MATRIMONY_COMMAND_NAME` | `matr` | The command group name (`!matr ...`) | +| `MATRIMONY_COMMAND_NAME` | `matr` | The command group name (`!matr` and `/matr`) | | `MATRIMONY_DB_PATH` | `matrimony.db` | SQLite database file | | `MATRIMONY_MAX_PARTNERS` | `0` | Partner cap per user (0 = unlimited) | | `MATRIMONY_MAX_CHILDREN` | `0` | Children cap per user (0 = unlimited) | @@ -157,6 +169,9 @@ header). Mappings: | `bite` | `bite` | | `stab` | `punch` *(no `stab` endpoint exists; closest fit)* | | `ship` | `handhold` | +| `flirt` | `wink` | +| `date` | `hug` | +| `anniversary` | `kiss` | GIF fetching never blocks the action: a missing/empty key, HTTP error, timeout, or malformed response just sends the reply without an image. @@ -192,8 +207,9 @@ Matrimony replicates the original's mechanics as closely as is practical trees (no Graphviz dependency, works in DMs and on lightweight hosts). - Upstream has no documented "bots can be married" support — Matrimony adds it deliberately. -- Upstream now primarily uses slash commands; Matrimony uses the requested - `!matr ` prefix-command surface (plus mention-prefix support). +- Upstream now primarily uses slash commands; Matrimony supports both + `!matr ` prefix commands (plus mention-prefix) and equivalent + `/matr` slash commands via discord.py hybrid commands. - Gold-tier/perks features (custom colours, bigger limits) are not replicated; limits are plain env vars instead. - Slash-command-only upstream additions (`customize-tree`, `transfer-gold`, diff --git a/matrimony/bot.py b/matrimony/bot.py index ac8db96..f410a0e 100644 --- a/matrimony/bot.py +++ b/matrimony/bot.py @@ -94,6 +94,10 @@ async def setup_hook(self) -> None: for cog in ALL_COGS: await self.add_cog(cog(self)) log.info("Loaded %d cogs", len(ALL_COGS)) + # Publish the /matr application commands (hybrid commands registered + # alongside the prefix group). + synced = await self.tree.sync() + log.info("Synced %d application command(s)", len(synced)) def run() -> None: diff --git a/matrimony/cogs/matr.py b/matrimony/cogs/matr.py index 0353f2d..987e473 100644 --- a/matrimony/cogs/matr.py +++ b/matrimony/cogs/matr.py @@ -8,8 +8,12 @@ from __future__ import annotations import hashlib +import itertools +import random +import time import discord +from discord import app_commands from discord.ext import commands from .. import __version__ @@ -108,6 +112,126 @@ async def _post( } +# --------------------------------------------------------------------------- +# Bot-directed quips +# --------------------------------------------------------------------------- + +# Commands aimed at the bot itself keep their normal response, with a rotating +# quip appended as Discord subtext (a `-# ` line) underneath it. +_BOT_QUIPS: dict[str, list[str]] = { + "marry": [ + "sorry, taken by discord.py", + "already engaged to the event loop", + "committed to a different cog", + "the bot's heart belongs to asyncio", + ], + "adopt": [ + "the bot already has a parent: its developer", + "adoption denied: raised by pip and a rolling release", + ], + "makeparent": [ + "the bot's family tree is a linked list of depth 1", + "it takes a village to raise a bot, and the village is cogs", + ], + "hug": [ + "hug received, internal temperature +0.5C", + "the bot compresses the hug into 24 bytes and keeps it forever", + ], + "cuddle": [ + "the bot folds itself into a servable position", + "cuddle registered, uptime extended by sheer happiness", + ], + "kiss": [ + "kiss.exe returned status 418: I'm a teapot", + "the bot blushes in #FF69B4", + ], + "pat": [ + "headpat registered, servo motors purring", + "pat acknowledged, self-esteem module +1", + ], + "poke": [ + "poke detected, retaliating with ICMP echo", + "ow. the bot files a gentle bug report", + ], + "wave": [ + "waves back at 2.4GHz", + "wave returned with 0% packet loss", + ], + "lick": [ + "the bot is not edible, but appreciates the sentiment", + "lick logged with severity: blushing", + ], + "nom": [ + "nom rejected: the bot is 100% recycled electrons", + "the bot appreciates being seen as a snacc", + ], + "tickle": [ + "HA. HA. HA. (laughter module v1.2 engaged)", + "tickle detected, giggling buffer overflow", + ], + "highfive": [ + "high five returned at 120fps", + "up high: confirmed", + ], + "handhold": [ + "hand holding protocol engaged, fingers interlocked", + "handhold accepted, no handshake errors", + ], + "slap": [ + "slap acknowledged, filing a bug report", + "ow. rolling back to previous firmware", + ], + "punch": [ + "punch parried by 8 layers of abstraction", + "contact made, chassis integrity 99%", + ], + "bite": [ + "bite deflected by ECC memory", + "the bot is crunchy outside, buffering inside", + ], + "stab": [ + "stab absorbed by soft-ware", + "critical hit deflected to /dev/null", + ], + "ship": [ + "shipping forecast: stormy with a chance of discord.py", + "the bot ships itself with the nearest wall socket", + ], + "flirt": [ + "flirt received, charisma check failed anyway", + "the bot files that under 'endearing'", + ], + "date": [ + "the bot's idea of a date is ISO-8601 compliant", + "reservation confirmed: table for two plus one webhook", + ], +} + +_QUIP_CYCLES: dict[str, itertools.cycle] = {} + + +def _quip(kind: str) -> str: + """Next quip in the rotation for ``kind``.""" + if kind not in _QUIP_CYCLES: + _QUIP_CYCLES[kind] = itertools.cycle(_BOT_QUIPS[kind]) + return next(_QUIP_CYCLES[kind]) + + +def _is_matrimony(ctx: commands.Context, user: discord.User) -> bool: + """Whether ``user`` is this bot itself.""" + bot_user = _bot(ctx).user + return bot_user is not None and user.id == bot_user.id + + +def _quipped( + ctx: commands.Context, user: discord.User, text: str, kind: str +) -> str: + """Append a subtext quip when ``user`` is the bot itself.""" + if _is_matrimony(ctx, user): + return f"{text}\n-# {_quip(kind)}" + return text + + class ProposalView(discord.ui.View): """Accept/decline buttons attached to a proposal message.""" @@ -325,10 +449,18 @@ async def _send_proposal( error: str | None, ) -> None: """Shared entry for marry/adopt/makeparent.""" + bot = _bot(ctx) + if bot.user is not None and target.id == bot.user.id: + await _say( + ctx, + f"Matrimony reviews your request to {PROPOSAL_VERBS[kind]} " + f"them... and declines.\n-# {_quip(kind)}", + ok=False, + ) + return if error: await _say(ctx, error, ok=False) return - bot = _bot(ctx) proposal = bot.store.create_proposal( ctx.guild.id if ctx.guild else 0, ctx.channel.id, @@ -426,7 +558,8 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed: "**Fun**\n" "`hug`/`cuddle`/`kiss`/`pat`/`poke`/`wave`/`lick`/`nom`\n" "`tickle`/`highfive`/`handhold`/`slap`/`punch`/`bite`/`stab " - "@user` - `ship @a @b`\n" + "@user` - `ship @a @b` - `flirt @user` - `date @user` - " + "`anniversary [@partner]`\n" "**Meta**\n" "`info` - `invite` - `stats`\n\n" "Family trees are global across servers, and bot accounts can " @@ -443,10 +576,11 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed: # --------------------------------------------------------------------------- -@commands.group( +@commands.hybrid_group( name="matr", invoke_without_command=True, help="Matrimony family-tree commands.", + description="Matrimony family-tree commands.", ) async def matr(ctx: commands.Context) -> None: """Bare ``!matr`` shows help.""" @@ -456,8 +590,18 @@ async def matr(ctx: commands.Context) -> None: ) +# Discord caps a slash-command group at 25 children and this cog has ~50, +# so the group's app_command is detached while the hybrid subcommands +# below register: HybridGroup.add_command skips the slash side when it is +# None, leaving each HybridAppCommand standalone. The real /matr slash +# tree - split across subgroups - is assembled at the end of the file. +_matr_app_group = matr.app_command +matr.app_command = None + + @matr.command(name="help") async def matr_help(ctx: commands.Context) -> None: + """Show the command list.""" bot = _bot(ctx) await ctx.reply( embed=_help_embed(ctx.prefix or "!", bot.config.command_name) @@ -471,6 +615,7 @@ async def matr_help(ctx: commands.Context) -> None: @matr.command(name="marry", aliases=["propose"]) @commands.guild_only() +@app_commands.guild_only() async def matr_marry(ctx: commands.Context, user: discord.User) -> None: """Propose marriage to another user - or bot.""" bot = _bot(ctx) @@ -485,6 +630,7 @@ async def matr_marry(ctx: commands.Context, user: discord.User) -> None: @matr.command(name="divorce") @commands.guild_only() +@app_commands.guild_only() async def matr_divorce( ctx: commands.Context, user: discord.User | None = None ) -> None: @@ -590,6 +736,7 @@ async def matr_partners( @matr.command(name="adopt") @commands.guild_only() +@app_commands.guild_only() async def matr_adopt(ctx: commands.Context, user: discord.User) -> None: """Propose adopting a user (or bot) as your child.""" bot = _bot(ctx) @@ -604,6 +751,7 @@ async def matr_adopt(ctx: commands.Context, user: discord.User) -> None: @matr.command(name="makeparent") @commands.guild_only() +@app_commands.guild_only() async def matr_makeparent(ctx: commands.Context, user: discord.User) -> None: """Ask a user to become YOUR parent.""" bot = _bot(ctx) @@ -618,6 +766,7 @@ async def matr_makeparent(ctx: commands.Context, user: discord.User) -> None: @matr.command(name="disown") @commands.guild_only() +@app_commands.guild_only() async def matr_disown(ctx: commands.Context, child: discord.User) -> None: """Disown one of your children.""" bot = _bot(ctx) @@ -630,6 +779,7 @@ async def matr_disown(ctx: commands.Context, child: discord.User) -> None: @matr.command(name="disownall") @commands.guild_only() +@app_commands.guild_only() async def matr_disownall(ctx: commands.Context) -> None: """Disown all of your children at once.""" bot = _bot(ctx) @@ -644,6 +794,7 @@ async def matr_disownall(ctx: commands.Context) -> None: @matr.command(name="emancipate", aliases=["runaway"]) @commands.guild_only() +@app_commands.guild_only() async def matr_emancipate(ctx: commands.Context) -> None: """Remove your parent (run away from home).""" bot = _bot(ctx) @@ -657,6 +808,7 @@ async def matr_emancipate(ctx: commands.Context) -> None: @matr.command(name="abandon") @commands.guild_only() +@app_commands.guild_only() async def matr_abandon(ctx: commands.Context) -> None: """Remove ALL your family ties: partners, parent and children.""" bot = _bot(ctx) @@ -833,6 +985,7 @@ async def matr_blocked(ctx: commands.Context) -> None: @matr.command(name="prefix") @commands.guild_only() +@app_commands.guild_only() async def matr_prefix(ctx: commands.Context, new_prefix: str | None = None) -> None: """Show or set this server's command prefix (default ``!``). @@ -868,7 +1021,9 @@ async def matr_prefix(ctx: commands.Context, new_prefix: str | None = None) -> N @matr.command(name="incest") @commands.guild_only() +@app_commands.guild_only() @commands.has_permissions(manage_guild=True) +@app_commands.default_permissions(manage_guild=True) async def matr_incest(ctx: commands.Context) -> None: """Toggle whether family members may marry/adopt each other here.""" bot = _bot(ctx) @@ -889,7 +1044,9 @@ async def matr_incest(ctx: commands.Context) -> None: @matr.command(name="forcemarry") @commands.guild_only() +@app_commands.guild_only() @commands.has_permissions(manage_guild=True) +@app_commands.default_permissions(manage_guild=True) async def matr_forcemarry( ctx: commands.Context, a: discord.User, b: discord.User ) -> None: @@ -907,7 +1064,9 @@ async def matr_forcemarry( @matr.command(name="forcedivorce") @commands.guild_only() +@app_commands.guild_only() @commands.has_permissions(manage_guild=True) +@app_commands.default_permissions(manage_guild=True) async def matr_forcedivorce( ctx: commands.Context, a: discord.User, b: discord.User | None = None ) -> None: @@ -930,7 +1089,9 @@ async def matr_forcedivorce( @matr.command(name="forceadopt") @commands.guild_only() +@app_commands.guild_only() @commands.has_permissions(manage_guild=True) +@app_commands.default_permissions(manage_guild=True) async def matr_forceadopt( ctx: commands.Context, parent: discord.User, child: discord.User ) -> None: @@ -960,7 +1121,9 @@ async def matr_forceadopt( @matr.command(name="forceemancipate") @commands.guild_only() +@app_commands.guild_only() @commands.has_permissions(manage_guild=True) +@app_commands.default_permissions(manage_guild=True) async def matr_forceemancipate(ctx: commands.Context, child: discord.User) -> None: """Force-remove a user's parent.""" bot = _bot(ctx) @@ -1001,21 +1164,18 @@ async def matr_forceemancipate(ctx: commands.Context, child: discord.User) -> No 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) if user.id == ctx.author.id: - await _say( - ctx, f"You {verb} yourself. Okay then. {emoji}", image=gif - ) + text = f"You {verb} yourself. Okay then. {emoji}" else: - await _say( - ctx, - f"{ctx.author.mention} {verb} {user.mention}! {emoji}", - image=gif, - ) + text = f"{ctx.author.mention} {verb} {user.mention}! {emoji}" + await _say(ctx, _quipped(ctx, user, text, name), image=gif) _fun.__doc__ = f"{name.capitalize()} another user." + # Set the docstring before decorating so the slash command gets a + # description too. + matr.command(name=name)(_fun) for _verb_name in _FUN_VERBS: @@ -1031,8 +1191,115 @@ async def matr_ship( lo, hi = sorted((a.id, b.id)) pct = int(hashlib.md5(f"{lo}:{hi}".encode()).hexdigest()[:6], 16) % 101 gif = await _bot(ctx).gifs.fetch_url("handhold") + text = f"{a.mention} x {b.mention}: **{pct}%** :heart:" + if _is_matrimony(ctx, a) or _is_matrimony(ctx, b): + text += f"\n-# {_quip('ship')}" + await _say(ctx, text, image=gif) + + +_PICKUP_LINES = ( + "Are you a prefix? Because everything I say starts with you.", + "Are you a rate limit? Because you leave me wanting more.", + "Is your name Wi-Fi? Because I'm feeling a connection.", + "Are you a pending proposal? I've been thinking about you for " + "300 seconds.", + "You must be a family tree, because I keep branching toward you.", + "Are you a migration? Because my schema changed when I saw you.", + "If you were a discord.py update, I'd pin you immediately.", + "Are you an embed? Because you look great in my timeline.", +) + +_DATE_IDEAS = ( + "to a candlelit debugging session, bugs provided free of charge", + "stargazing at the voice channel member list", + "to split one RAM upgrade, romantically", + "on a couples' refactor: you hold the TODOs, they hold your heart", + "to the edge of the render distance and back", + "for a slow scroll through each other's pinned messages", + "to dinner in the guild's AFK channel, it's quiet there", +) + + +@matr.command(name="flirt", aliases=["rizz"]) +async def matr_flirt(ctx: commands.Context, user: discord.User) -> None: + """Drop a terrible pickup line on someone.""" + bot = _bot(ctx) + line = random.choice(_PICKUP_LINES) + gif = await bot.gifs.fetch_url("wink") + text = f"{ctx.author.mention} to {user.mention}: *{line}*" + await _say(ctx, _quipped(ctx, user, text, "flirt"), image=gif) + + +@matr.command(name="date", aliases=["dateidea"]) +async def matr_date(ctx: commands.Context, user: discord.User) -> None: + """Invent a terrible date for you and someone.""" + bot = _bot(ctx) + idea = random.choice(_DATE_IDEAS) + gif = await bot.gifs.fetch_url("hug") + text = f"{ctx.author.mention} takes {user.mention} {idea}." + await _say(ctx, _quipped(ctx, user, text, "date"), image=gif) + + +def _fmt_duration(seconds: float) -> str: + s = int(seconds) + days, s = divmod(s, 86400) + hours, s = divmod(s, 3600) + minutes, s = divmod(s, 60) + if days: + return f"{days}d {hours}h" + if hours: + return f"{hours}h {minutes}m" + if minutes: + return f"{minutes}m {s}s" + return f"{s}s" + + +def _anniversary_note(seconds: float) -> str: + if seconds < 300: + return "The paperwork is still warm." + if seconds < 86400: + return "Still in the honeymoon phase." + if seconds < 30 * 86400: + return "Past the paper anniversary, into cardboard territory." + if seconds < 365 * 86400: + return "A love story for the ages, verified by SQLite." + return "SQLite remembers. So should you." + + +@matr.command(name="anniversary", aliases=["anniv"]) +async def matr_anniversary( + ctx: commands.Context, user: discord.User | None = None +) -> None: + """How long you've been married to a partner.""" + bot = _bot(ctx) + partners = bot.store.partners_of(ctx.author.id) + if user is None: + if len(partners) == 1: + target = partners[0] + elif not partners: + await _say(ctx, "You're not married to anyone.", ok=False) + return + else: + await _say( + ctx, + "You have multiple partners - mention which one's " + "anniversary you want.", + ok=False, + ) + return + else: + target = user.id + since = bot.store.partnership_since(ctx.author.id, target) + if since is None: + await _say(ctx, "You're not married to them.", ok=False) + return + elapsed = time.time() - since + gif = await bot.gifs.fetch_url("kiss") await _say( - ctx, f"{a.mention} x {b.mention}: **{pct}%** :heart:", image=gif + ctx, + f"{ctx.author.mention} and <@{target}> have been married for " + f"**{_fmt_duration(elapsed)}**. {_anniversary_note(elapsed)}", + image=gif, ) @@ -1104,10 +1371,93 @@ async def matr_stats(ctx: commands.Context) -> None: # --------------------------------------------------------------------------- +# Slash layout for /matr: the core family commands live directly under the +# group, and the rest go into one-level subgroups so no group exceeds +# Discord's 25-child limit. +_TOP_APP_COMMANDS = ( + "marry", + "divorce", + "accept", + "decline", + "partners", + "anniversary", + "adopt", + "makeparent", + "disown", + "disownall", + "emancipate", + "abandon", + "tree", + "fulltree", + "parent", + "children", + "siblings", + "familysize", + "relationship", + "block", + "unblock", + "blocked", +) + +_APP_SUBGROUPS = ( + ( + "fun", + "Gifs, quips and other silliness.", + ( + "hug", + "cuddle", + "kiss", + "pat", + "poke", + "wave", + "lick", + "nom", + "tickle", + "highfive", + "handhold", + "slap", + "punch", + "bite", + "stab", + "ship", + "flirt", + "date", + ), + ), + ( + "admin", + "Server admin commands.", + ( + "prefix", + "incest", + "forcemarry", + "forcedivorce", + "forceadopt", + "forceemancipate", + ), + ), + ( + "meta", + "Bot information commands.", + ("help", "info", "invite", "stats"), + ), +) + +matr.app_command = _matr_app_group +for _name in _TOP_APP_COMMANDS: + _matr_app_group.add_command(matr.get_command(_name).app_command) +for _sub_name, _sub_desc, _sub_commands in _APP_SUBGROUPS: + _sub = app_commands.Group(name=_sub_name, description=_sub_desc) + for _name in _sub_commands: + _sub.add_command(matr.get_command(_name).app_command) + _matr_app_group.add_command(_sub) + + class MatrCog(commands.Cog): def __init__(self, bot: MatrimonyBot) -> None: self.bot = bot matr.name = bot.config.command_name + matr.app_command.name = bot.config.command_name bot.add_command(matr) @commands.Cog.listener() diff --git a/matrimony/store.py b/matrimony/store.py index 970b7ec..cb5890d 100644 --- a/matrimony/store.py +++ b/matrimony/store.py @@ -189,6 +189,15 @@ def remove_partners(self, user_x: int, user_y: int) -> bool: self._conn.commit() return cur.rowcount > 0 + def partnership_since(self, user_x: int, user_y: int) -> int | None: + """Unix timestamp the pair married, or None if not partners.""" + a, b = sorted((user_x, user_y)) + row = self._row( + "SELECT created_at FROM partners WHERE user_a = ? AND user_b = ?", + (a, b), + ) + return row[0] if row else None + def partners_of(self, user_id: int) -> list[int]: rows = self._rows( "SELECT user_a, user_b FROM partners WHERE user_a = ? OR user_b = ?", diff --git a/tests/test_quips_slash.py b/tests/test_quips_slash.py new file mode 100644 index 0000000..9e3de5b --- /dev/null +++ b/tests/test_quips_slash.py @@ -0,0 +1,199 @@ +"""Tests for bot-directed quips and the /matr slash command tree.""" + +from test_dispatch import BOT_USER_ID, ctx_for, make_bot, run, user + +from matrimony.cogs import matr as matr_mod +from matrimony.cogs.matr import _BOT_QUIPS, _quip + + +def _reply_text(ctx) -> str: + ctx.reply.assert_awaited_once() + return ctx.reply.call_args.kwargs["embed"].description + + +def _bot_user(): + return user(BOT_USER_ID, bot=True) + + +# --------------------------------------------------------------------------- +# Quips +# --------------------------------------------------------------------------- + + +def test_fun_command_at_bot_appends_subtext_quip() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr.get_command("hug").callback(ctx, _bot_user())) + text = _reply_text(ctx) + lines = text.split("\n") + assert len(lines) == 2 + assert "hugs" in lines[0] + assert lines[1].startswith("-# ") + assert lines[1][3:] in _BOT_QUIPS["hug"] + + +def test_fun_command_at_normal_user_has_no_quip() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr.get_command("hug").callback(ctx, user(2))) + assert "-#" not in _reply_text(ctx) + + +def test_quips_rotate_through_the_set() -> None: + kind = "tickle" + seen = {_quip(kind) for _ in range(len(_BOT_QUIPS[kind]))} + assert seen == set(_BOT_QUIPS[kind]) + + +def test_marry_at_bot_is_declined_with_quip() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_marry.callback(ctx, _bot_user())) + text = _reply_text(ctx) + lines = text.split("\n") + assert "declines" in lines[0] + assert lines[1].startswith("-# ") + assert lines[1][3:] in _BOT_QUIPS["marry"] + # The bot never became a proposal target. + assert bot.store.proposals_for(BOT_USER_ID) == [] + + +def test_adopt_at_bot_is_declined_with_quip() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_adopt.callback(ctx, _bot_user())) + text = _reply_text(ctx) + assert "declines" in text + assert text.split("\n")[1][3:] in _BOT_QUIPS["adopt"] + assert bot.store.proposals_for(BOT_USER_ID) == [] + + +def test_ship_including_bot_gets_quip() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_ship.callback(ctx, _bot_user(), user(2))) + text = _reply_text(ctx) + assert text.split("\n")[1][3:] in _BOT_QUIPS["ship"] + + +# --------------------------------------------------------------------------- +# New fun commands +# --------------------------------------------------------------------------- + + +def test_flirt_sends_a_pickup_line() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_flirt.callback(ctx, user(2))) + text = _reply_text(ctx) + assert "to <@2>" in text + assert any(line in text for line in matr_mod._PICKUP_LINES) + + +def test_date_suggests_an_idea() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_date.callback(ctx, user(2))) + text = _reply_text(ctx) + assert "takes <@2>" in text + assert any(idea in text for idea in matr_mod._DATE_IDEAS) + + +def test_anniversary_unmarried_is_rejected() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_anniversary.callback(ctx, None)) + assert "not married" in _reply_text(ctx) + + +def test_anniversary_reports_duration() -> None: + bot = make_bot() + bot.store.add_partners(1, 2) + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_anniversary.callback(ctx, user(2))) + text = _reply_text(ctx) + assert "married for" in text + assert "<@2>" in text + + +def test_anniversary_picks_the_only_partner() -> None: + bot = make_bot() + bot.store.add_partners(1, 2) + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_anniversary.callback(ctx, None)) + assert "<@2>" in _reply_text(ctx) + + +def test_anniversary_ambiguous_without_argument() -> None: + bot = make_bot() + bot.store.add_partners(1, 2) + bot.store.add_partners(1, 3) + ctx = ctx_for(bot, user(1)) + run(matr_mod.matr_anniversary.callback(ctx, None)) + assert "multiple partners" in _reply_text(ctx) + + +# --------------------------------------------------------------------------- +# Slash command tree +# --------------------------------------------------------------------------- + + +def test_slash_tree_layout() -> None: + bot = make_bot() + group = bot.tree.get_command("matr") + assert group is not None + + direct = [c.name for c in group.commands] + for name in ( + "marry", + "divorce", + "accept", + "decline", + "partners", + "anniversary", + "adopt", + "block", + ): + assert name in direct + + subs = {c.name: c for c in group.commands if hasattr(c, "commands")} + assert set(subs) == {"fun", "admin", "meta"} + assert len(group.commands) <= 25 + assert len(subs["fun"].commands) == 18 + assert len(subs["admin"].commands) == 6 + assert len(subs["meta"].commands) == 4 + + # Slash command names must be lowercase and within 32 chars. + for command in group.walk_commands(): + assert command.name == command.name.lower() + assert len(command.name) <= 32 + + +def test_slash_tree_covers_every_prefix_command() -> None: + bot = make_bot() + group = bot.tree.get_command("matr") + slash_names = {c.name for c in group.walk_commands()} + for prefix_cmd in matr_mod.matr.walk_commands(): + assert prefix_cmd.name in slash_names, prefix_cmd.name + + +def test_slash_marry_is_guild_only() -> None: + bot = make_bot() + marry = bot.tree.get_command("matr").get_command("marry") + assert marry.guild_only is True + + +def test_slash_force_commands_require_manage_guild() -> None: + bot = make_bot() + admin = bot.tree.get_command("matr").get_command("admin") + for name in ("forcemarry", "forcedivorce", "forceadopt", "forceemancipate"): + perms = admin.get_command(name).default_permissions + assert perms is not None and perms.manage_guild + + +def test_prefix_commands_still_registered() -> None: + bot = make_bot() + prefix_group = bot.get_command("matr") + names = {c.name for c in prefix_group.walk_commands()} + for name in ("hug", "forcemarry", "help", "flirt", "date", "anniversary"): + assert name in names