From c9dfb067c3ef1f3cb5f98e541f9dfe3e29ce0e28 Mon Sep 17 00:00:00 2001 From: commdevin Date: Sun, 18 Oct 2026 16:36:21 +0000 Subject: [PATCH] Add restraining orders, jail, and guild-clear command - restrain: 1h orders blocking all targeted actions; violation prompts the protected user to press charges (button view, 120s) - jail: severity-scaled sentences (light fun random 1-5m; slap 10, adopt/makeparent 15, bite/punch 20, stab 25, marry 30); inmates can only act on other inmates via ephemeral jail_partners/jail_children tables dissolved on release - tasks.loop sweep (20s, configurable) handles order expiry, release, and dissolution notices; core is jail.sweep_expiry(store, now) - clear: Manage-Server-only, Confirm/Cancel view (60s); resets guild-scoped state only - global family tree and blocks untouched - slash tree: restrain top-level, clear in admin, anniversary moved to fun subgroup (25-child cap) - 42 new tests; 182 total, ruff clean Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 85 ++++++ matrimony/cogs/matr.py | 554 +++++++++++++++++++++++++++++++++++- matrimony/config.py | 10 + matrimony/jail.py | 145 ++++++++++ matrimony/store.py | 405 +++++++++++++++++++++++++- tests/test_jail.py | 582 ++++++++++++++++++++++++++++++++++++++ tests/test_quips_slash.py | 9 +- 7 files changed, 1765 insertions(+), 25 deletions(-) create mode 100644 matrimony/jail.py create mode 100644 tests/test_jail.py diff --git a/README.md b/README.md index 85686f5..0a9d73a 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,17 @@ All commands live under one group, `matr`, invoked with a per-guild prefix - **Incest toggle** — `!matr incest` lets server admins permit proposals between family members (off by default, as upstream). - **Blocks** — users can `block`/`unblock` others from proposing to them. +- **Restraining orders** — `restrain` blocks every targeted action a user + aims at you for one hour; a violation prompts you to press charges, + which sends the offender to jail. +- **Jail** — inmates can't act on free users, but can marry, adopt, and + interact with each other in ephemeral jail-only relationships that + dissolve the moment the sentence ends. - **Admin force commands** — `forcemarry`, `forcedivorce`, `forceadopt`, `forceemancipate` (Manage Server). +- **Guild reset** — `clear` wipes this server's settings, proposals, + notices, restraining orders, and jail sentences behind a confirmation + prompt (Manage Server). - **Fun commands** — `hug`, `cuddle`, `kiss`, `pat`, `poke`, `wave`, `lick`, `nom`, `tickle`, `highfive`, `handhold`, `slap`, `punch`, `bite`, `stab`, `ship`, `flirt` (terrible pickup lines), `date` @@ -101,12 +110,14 @@ explicitly supports bot-authored commands and bot targets: | `!matr familysize [@user]` | Size of a user's tree | | `!matr relationship @a @b` | How B relates to A | | `!matr block` / `unblock @user` / `blocked` | Manage proposal blocks | +| `!matr restrain @user` | File a 1-hour restraining order against a user | | `!matr prefix [new]` | View/set guild prefix (Manage Server) | | `!matr incest` | Toggle incest proposals (Manage Server) | | `!matr forcemarry @a @b` | Force-marry a pair (Manage Server) | | `!matr forcedivorce @a [@b]` | Force-divorce (Manage Server) | | `!matr forceadopt @parent @child` | Force adoption (Manage Server) | | `!matr forceemancipate @user` | Force-remove a parent (Manage Server) | +| `!matr clear` | Reset this guild's state after a Confirm/Cancel prompt (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]` | Drifting ship percentage (pair + time + channel chatter; + GIF) | | `!matr flirt @user` (alias `rizz`) | Terrible pickup line (+ GIF) | @@ -116,6 +127,78 @@ explicitly supports bot-authored commands and bot targets: `@Matrimony matr ...` (mention prefix) also always works. +On the slash side, `restrain` is `/matr restrain` and `clear` lives under +the admin subgroup as `/matr admin clear`; `anniversary` is under +`/matr fun anniversary`. + +## Restraining orders and jail + +`!matr restrain @user` files a restraining order: for the next hour +(`MATRIMONY_RESTRAINING_ORDER_TTL`, default 3600s) every targeted action +the restrained user aims at the protected one is blocked — proposals +(`marry`, `adopt`, `makeparent`) and all targeted flavour actions (`hug`, +`slap`, `poke`, `punch`, `stab`, `flirt`, `date`, ...). `ship` is exempt +since it only reports a number. Refiling renews the hour. Enforcement +lives in the shared command entry point, so prefix and slash invocations +are blocked identically. + +A violation posts a **Press charges / Let it slide** button prompt +(addressed to the protected user only, 120s timeout). Pressing charges +sends the offender to jail; declining or letting it lapse leaves the +restraining order standing until it expires. + +**Sentencing scales by severity.** Light interactions are minor and roll +a random 1-5 minutes; serious actions have fixed sentences: + +| Action | Sentence | +| --- | --- | +| light fun (poke, hug, cuddle, kiss, pat, wave, lick, nom, tickle, highfive, handhold, flirt, date) | random 1-5 min | +| slap | 10 min | +| adopt / makeparent | 15 min | +| bite / punch | 20 min | +| stab | 25 min | +| marry | 30 min | +| any unlisted action | 15 min (default major) | + +**While jailed**, any targeted action aimed at a non-jailed user is +blocked with an error embed and a rotating quip — this covers proposals +*and* `divorce`, `disown`, and `block` attempts on free users. Inmates +can still `marry`, `adopt`, `makeparent`, and use fun actions on **other +inmates**; those proposals are flagged and create **jail relationships** +stored in separate `jail_partners`/`jail_children` tables, never in the +real family tree. + +**Jail relationships are ephemeral.** When a sentence ends, the inmate is +released, every jail marriage and jail adoption they were part of is +dissolved, and both parties are told they were divorced or abandoned on +release — nothing carries over to the real tree. + +A background sweep (`MATRIMONY_JAIL_SWEEP`, every 20s by default) expires +restraining orders (announced where they were filed), releases inmates, +and posts release + dissolution notices. The expiry core is a pure +function of the store and a clock (`jail.sweep_expiry`), unit-tested with +an injected clock. + +## Resetting a guild + +`!matr clear` (slash: `/matr admin clear`) is restricted to users with +**Manage Server** and never acts immediately: it shows a warning embed +listing the scope with **Confirm reset** / **Cancel** buttons (60s +timeout, invoking admin only; timeout or Cancel changes nothing). + +On confirm it deletes, for that guild only, and replies with counts: + +- guild settings (custom prefix, incest flag) +- pending proposals and partner notices filed in the guild +- restraining orders filed in the guild +- jail sentences handed down in the guild — inmates are released and + their jail relationships dissolved + +It does **not** clear partners, children, or block lists: the family tree +and personal blocks are global across servers, not guild state, so wiping +them would erase other guilds' data — the narrowest reading of "reset +this guild". + ## Running ### Requirements @@ -156,6 +239,8 @@ docker run -e MATRIMONY_TOKEN=... -v matrimony-data:/data matrimony | `MATRIMONY_PROPOSAL_TTL` | `300` | Seconds before a proposal expires | | `MATRIMONY_ALLOWED_CHANNELS` | *(unset)* | JSON object mapping guild ids to lists of allowed channel ids (see below) | | `MATRIMONY_ALLOWED_CHANNELS_FILE` | *(unset)* | Path to a JSON file with the same mapping; used when `MATRIMONY_ALLOWED_CHANNELS` is unset | +| `MATRIMONY_RESTRAINING_ORDER_TTL` | `3600` | Seconds a restraining order lasts | +| `MATRIMONY_JAIL_SWEEP` | `20` | Seconds between expiry sweeps (order expiry, jail release) | | `FLUXPOINT_API_KEY` | Fluxpoint's shared public key | Fluxpoint API token for action GIFs; set to empty to disable | ### Channel restrictions diff --git a/matrimony/cogs/matr.py b/matrimony/cogs/matr.py index 357f95c..24746ea 100644 --- a/matrimony/cogs/matr.py +++ b/matrimony/cogs/matr.py @@ -7,18 +7,20 @@ from __future__ import annotations +import contextlib import itertools import random import time import discord from discord import app_commands -from discord.ext import commands +from discord.ext import commands, tasks from .. import __version__ +from .. import jail as jail_mod from ..bot import MatrimonyBot from ..ship import ship_score -from ..store import Notice, Proposal +from ..store import Notice, Proposal, RestrainingOrder GREEN = discord.Color(0x2ECC71) RED = discord.Color(0xE74C3C) @@ -221,7 +223,47 @@ async def _post( "you misspelled it so confidently we're letting it slide", ] -_QUIP_SETS: dict[str, list[str]] = {**_BOT_QUIPS, "unknown": _UNKNOWN_QUIPS} +# When a restrained user tries to act on the protected user anyway. +_RESTRAIN_QUIPS = [ + "the law is literally one message away", + "personal space, now legally enforced", + "the paperwork holds - this time", + "restraining order: active and petty", +] + +# When a jailed user tries to act on somebody on the outside. +_JAIL_QUIPS = [ + "the walls have no DMs", + "yard time doesn't include them", + "the bars only bend for other inmates", + "solitary means solitary", + "you can look but you can't touch - literally", +] + +# The release announcement when a sentence ends. +_RELEASE_QUIPS = [ + "time served, tree trimmed", + "the gates swing open; the love stays inside", + "parole granted - family status: complicated no longer", + "free to marry someone who isn't a co-defendant", +] + +# Appended to jail-relationship dissolution notices. +_DISSOLVE_QUIPS = [ + "it was never meant to last past lockup", + "what happens in jail stays in jail", + "the prison chapel issues no certificates", + "conjugal visits end when the sentence does", +] + +_QUIP_SETS: dict[str, list[str]] = { + **_BOT_QUIPS, + "unknown": _UNKNOWN_QUIPS, + "restrain": _RESTRAIN_QUIPS, + "jail": _JAIL_QUIPS, + "release": _RELEASE_QUIPS, + "dissolve": _DISSOLVE_QUIPS, +} _QUIP_CYCLES: dict[str, itertools.cycle] = {} @@ -300,6 +342,9 @@ async def _resolve_proposal( async def _enact(bot: MatrimonyBot, proposal: Proposal, ctx_or_int) -> None: """Apply a proposal's effect to the family tree.""" + if proposal.jail: + await _enact_jail(bot, proposal, ctx_or_int) + return proposer = _name_of(bot, None)(proposal.proposer) target = _name_of(bot, None)(proposal.target) if proposal.kind == "marry": @@ -337,6 +382,63 @@ async def _enact(bot: MatrimonyBot, proposal: Proposal, ctx_or_int) -> None: await _respond(ctx_or_int, "They already have a parent.", False) +async def _enact_jail( + bot: MatrimonyBot, proposal: Proposal, ctx_or_int +) -> None: + """Apply a jail proposal to the jail relationship tables. + + Only enacts while BOTH parties are still inside - a jail proposal + accepted after someone's release lapses instead of writing to the + permanent family tree.""" + now = int(time.time()) + both_inside = ( + bot.store.is_jailed(proposal.proposer, now) + and bot.store.is_jailed(proposal.target, now) + ) + if not both_inside: + await _respond( + ctx_or_int, + "Someone was released first - jail relationships only form " + "inside. Propose again on the outside.", + False, + ) + return + proposer = _name_of(bot, None)(proposal.proposer) + target = _name_of(bot, None)(proposal.target) + if proposal.kind == "marry": + bot.store.add_jail_partners(proposal.proposer, proposal.target) + await _respond( + ctx_or_int, + f"{proposer} and {target} are now jail-married! :tada: " + "(dissolved on release)", + True, + ) + elif proposal.kind == "adopt": + if bot.store.add_jail_child(proposal.proposer, proposal.target): + await _respond( + ctx_or_int, + f"{target} is now {proposer}'s jail child. " + "(dissolved on release)", + True, + ) + else: + await _respond( + ctx_or_int, "They already have a jail parent.", False + ) + elif proposal.kind == "makeparent": + if bot.store.add_jail_child(proposal.target, proposal.proposer): + await _respond( + ctx_or_int, + f"{target} is now {proposer}'s jail parent. " + "(dissolved on release)", + True, + ) + else: + await _respond( + ctx_or_int, "They already have a jail parent.", False + ) + + # --------------------------------------------------------------------------- # Partner notices # --------------------------------------------------------------------------- @@ -458,11 +560,240 @@ async def _notify_new_partners( ) +# --------------------------------------------------------------------------- +# Restraining orders and jail +# --------------------------------------------------------------------------- + + +async def _jail_block(ctx: commands.Context, target_id: int) -> bool: + """True (and replies) when the author is jailed and ``target_id`` is + not - inmates may only aim actions at fellow inmates.""" + bot = _bot(ctx) + now = int(time.time()) + sentence = bot.store.sentence_for(ctx.author.id, now) + if sentence is None or bot.store.is_jailed(target_id, now): + return False + await _say( + ctx, + f"You're in jail - actions only reach other inmates. " + f"{_quip('jail')}\nReleased .", + ok=False, + ) + return True + + +PRESS_CHARGES_TIMEOUT = 120 # seconds to answer a violation prompt + + +class PressChargesView(discord.ui.View): + """Yes/No prompt offered to the protected user after a violation. + + Pressing charges jails the offender for the action's sentence; + declining (or letting it time out) changes nothing - the restraining + order keeps standing until its own expiry.""" + + def __init__(self, order: RestrainingOrder, action: str) -> None: + super().__init__(timeout=PRESS_CHARGES_TIMEOUT) + self.order = order + self.action = action + self.message: discord.Message | None = None + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.order.protected: + await interaction.response.send_message( + "This restraining order isn't yours to enforce.", + ephemeral=True, + ) + return False + return True + + async def on_timeout(self) -> None: + for item in self.children: + item.disabled = True + if self.message is not None: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) + + @discord.ui.button(label="Press charges", style=discord.ButtonStyle.danger) + async def press( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await _resolve_charge( + interaction.client, interaction, self.order, self.action, + pressed=True, + ) + self.stop() + + @discord.ui.button(label="Let it slide", style=discord.ButtonStyle.secondary) + async def decline( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await _resolve_charge( + interaction.client, interaction, self.order, self.action, + pressed=False, + ) + self.stop() + + +async def _resolve_charge( + bot: MatrimonyBot, + ctx_or_int, + order: RestrainingOrder, + action: str, + *, + pressed: bool, +) -> bool: + """Answer a press-charges prompt. ``pressed=True`` jails the offender + for ``action``'s sentence; the order itself always keeps standing.""" + if not pressed: + await _respond( + ctx_or_int, + "No charges pressed - the restraining order still stands " + "until it expires.", + False, + ) + return True + if bot.store.restraining_order(order.protected, order.restrained) is None: + # Lapsed between the prompt and the button press. + await _respond( + ctx_or_int, + "That restraining order already expired - no charges filed.", + False, + ) + return False + seconds = jail_mod.sentence_seconds(action) + bot.store.jail_inmate( + order.restrained, + order.guild_id, + order.channel_id, + action, + order.protected, + seconds, + ) + await _respond( + ctx_or_int, + f"<@{order.restrained}> is going to jail for " + f"**{_fmt_duration(seconds)}** for trying to **{action}** " + f"<@{order.protected}>.", + True, + ) + return True + + +async def _restrain_block( + ctx: commands.Context, target: discord.User, action: str +) -> bool: + """True (and posts the press-charges prompt) when ``target`` holds an + active restraining order against the author for ``action``.""" + bot = _bot(ctx) + order = bot.store.restraining_order(target.id, ctx.author.id) + if order is None: + return False + view = PressChargesView(order, action) + view.message = await ctx.send( + embed=_embed( + f"{target.mention}: {ctx.author.mention} just tried to " + f"**{action}** you in violation of your restraining order " + f"(expires ). Press charges?\n" + f"-# {_quip('restrain')}", + RED, + ), + view=view, + ) + return True + + +# --------------------------------------------------------------------------- +# Guild reset (`matr clear`) +# --------------------------------------------------------------------------- + +CLEAR_CONFIRM_TIMEOUT = 60 # seconds to confirm the reset + +_CLEAR_SCOPE = ( + "This permanently deletes **this server's**:\n" + "\u00b7 guild settings (custom prefix, incest toggle)\n" + "\u00b7 open proposals filed here\n" + "\u00b7 partner notices created here\n" + "\u00b7 restraining orders filed here\n" + "\u00b7 jail sentences handed down here (inmates are released and " + "their jail relationships dissolved)\n\n" + "**Not touched:** the global family tree (marriages, adoptions and " + "parent/child links span every server - they aren't guild state) and " + "personal block lists. Use the force commands or the members' own " + "`divorce`/`disown`/`emancipate`/`abandon` for those." +) + + +class ClearConfirmView(discord.ui.View): + """Confirm/Cancel prompt gating `matr clear`. Only the invoking + admin's presses count; on timeout the buttons disable and nothing + is cleared.""" + + def __init__(self, invoker_id: int, guild_id: int) -> None: + super().__init__(timeout=CLEAR_CONFIRM_TIMEOUT) + self.invoker_id = invoker_id + self.guild_id = guild_id + self.message: discord.Message | None = None + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.invoker_id: + await interaction.response.send_message( + "Only the admin who ran the command can answer this.", + ephemeral=True, + ) + return False + return True + + async def on_timeout(self) -> None: + for item in self.children: + item.disabled = True + if self.message is not None: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) + + @discord.ui.button(label="Confirm reset", style=discord.ButtonStyle.danger) + async def confirm( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await _clear_guild(interaction.client, interaction, self.guild_id) + self.stop() + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary) + async def cancel( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await _respond( + interaction, "Reset cancelled - nothing was cleared.", False + ) + self.stop() + + +async def _clear_guild(bot: MatrimonyBot, ctx_or_int, guild_id: int) -> None: + """Run the guild reset and post the counts.""" + counts = bot.store.clear_guild(guild_id) + await _respond( + ctx_or_int, + "Guild state reset.\n" + f"\u00b7 guild settings: **{counts['guild_settings']}**\n" + f"\u00b7 proposals: **{counts['proposals']}**\n" + f"\u00b7 partner notices: **{counts['notices']}**\n" + f"\u00b7 restraining orders: **{counts['restraining_orders']}**\n" + f"\u00b7 jail sentences: **{counts['jail_sentences']}** " + f"(jail marriages dissolved: **{counts['jail_partners']}**, " + f"jail adoptions dissolved: **{counts['jail_children']}**)\n\n" + "Global family relationships and personal block lists were " + "left untouched.", + True, + ) + + async def _send_proposal( ctx: commands.Context, target: discord.User | discord.Member, kind: str, error: str | None, + *, + jail_pair: bool = False, ) -> None: """Shared entry for marry/adopt/makeparent.""" bot = _bot(ctx) @@ -474,6 +805,10 @@ async def _send_proposal( ok=False, ) return + if await _jail_block(ctx, target.id): + return + if await _restrain_block(ctx, target, kind): + return if error: await _say(ctx, error, ok=False) return @@ -484,6 +819,7 @@ async def _send_proposal( target.id, kind, bot.config.proposal_ttl_seconds, + jail=jail_pair, ) if proposal is None: await _say(ctx, "You already have a pending proposal for them.", ok=False) @@ -491,7 +827,12 @@ async def _send_proposal( verb = PROPOSAL_VERBS[kind] cmd = bot.config.command_name notice_line = "" - if kind == "marry": + if jail_pair: + notice_line = ( + "\nThis is a **jail relationship** - it dissolves the moment " + "either of you is released." + ) + elif kind == "marry": others = sorted( set(bot.store.partners_of(ctx.author.id)) | set(bot.store.partners_of(target.id)) @@ -601,12 +942,13 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed: "`tree [@user]` - `fulltree [@user]` - `parent [@user]` - " "`children [@user]` - `siblings [@user]` - `familysize [@user]` - " "`relationship @a @b`\n" - "**Blocks**\n" - "`block @user` - `unblock @user` - `blocked`\n" + "**Blocks & law**\n" + "`block @user` - `unblock @user` - `blocked` - " + "`restrain @user` (1h order - violations offer jail time)\n" "**Server** *(Manage Server)*\n" "`prefix ` - `incest` - `forcemarry @a @b` - " "`forcedivorce @a [@b]` - `forceadopt @parent @child` - " - "`forceemancipate @user`\n" + "`forceemancipate @user` - `clear`\n" "**Fun**\n" "`hug`/`cuddle`/`kiss`/`pat`/`poke`/`wave`/`lick`/`nom`\n" "`tickle`/`highfive`/`handhold`/`slap`/`punch`/`bite`/`stab " @@ -687,6 +1029,16 @@ async def matr_help(ctx: commands.Context) -> None: async def matr_marry(ctx: commands.Context, user: discord.User) -> None: """Propose marriage to another user - or bot.""" bot = _bot(ctx) + now = int(time.time()) + if bot.store.is_jailed(ctx.author.id, now) and bot.store.is_jailed( + user.id, now + ): + # Two inmates: jail rules (no incest/cap checks), jail tables. + error = jail_mod.check_jail_marry( + bot.store, ctx.author.id, user.id + ) + await _send_proposal(ctx, user, "marry", error, jail_pair=True) + return error = bot.family.check_marry( ctx.author.id, user.id, @@ -723,6 +1075,8 @@ async def matr_divorce( if target not in partners: await _say(ctx, "You're not married to them.", ok=False) return + if await _jail_block(ctx, target): + return bot.store.remove_partners(ctx.author.id, target) gif = await bot.gifs.fetch_url("cry") await _say( @@ -808,6 +1162,15 @@ async def matr_partners( async def matr_adopt(ctx: commands.Context, user: discord.User) -> None: """Propose adopting a user (or bot) as your child.""" bot = _bot(ctx) + now = int(time.time()) + if bot.store.is_jailed(ctx.author.id, now) and bot.store.is_jailed( + user.id, now + ): + error = jail_mod.check_jail_adopt( + bot.store, ctx.author.id, user.id + ) + await _send_proposal(ctx, user, "adopt", error, jail_pair=True) + return error = bot.family.check_adopt( ctx.author.id, user.id, @@ -823,6 +1186,17 @@ async def matr_adopt(ctx: commands.Context, user: discord.User) -> None: async def matr_makeparent(ctx: commands.Context, user: discord.User) -> None: """Ask a user to become YOUR parent.""" bot = _bot(ctx) + now = int(time.time()) + if bot.store.is_jailed(ctx.author.id, now) and bot.store.is_jailed( + user.id, now + ): + error = jail_mod.check_jail_adopt( + bot.store, user.id, ctx.author.id + ) + await _send_proposal( + ctx, user, "makeparent", error, jail_pair=True + ) + return error = bot.family.check_adopt( user.id, # target becomes the parent ctx.author.id, # you become the child @@ -838,6 +1212,8 @@ async def matr_makeparent(ctx: commands.Context, user: discord.User) -> None: async def matr_disown(ctx: commands.Context, child: discord.User) -> None: """Disown one of your children.""" bot = _bot(ctx) + if await _jail_block(ctx, child.id): + return if bot.store.parent_of(child.id) != ctx.author.id: await _say(ctx, "They're not your child.", ok=False) return @@ -1018,6 +1394,8 @@ async def matr_relationship( async def matr_block(ctx: commands.Context, user: discord.User) -> None: """Block a user from sending you proposals.""" bot = _bot(ctx) + if await _jail_block(ctx, user.id): + return if user.id == ctx.author.id: await _say(ctx, "You can't block yourself.", ok=False) return @@ -1046,6 +1424,53 @@ async def matr_blocked(ctx: commands.Context) -> None: await _say(ctx, "Blocked: " + ", ".join(f"<@{u}>" for u in users)) +# --------------------------------------------------------------------------- +# Restraining orders +# --------------------------------------------------------------------------- + + +@matr.command(name="restrain", aliases=["restrainingorder"]) +@commands.guild_only() +@app_commands.guild_only() +async def matr_restrain(ctx: commands.Context, user: discord.User) -> None: + """File a restraining order against a user for one hour. + + While it stands, any action they aim at you (marry, adopt, + makeparent, hug/slap/poke/etc.) is blocked and you're asked whether + to press charges - charges land them in jail. Re-filing renews + the hour.""" + bot = _bot(ctx) + if user.id == ctx.author.id: + await _say( + ctx, + "You can't file a restraining order against yourself.", + ok=False, + ) + return + if bot.user is not None and user.id == bot.user.id: + await _say( + ctx, + "Matrimony files the paperwork around here, not the other " + "way round.", + ok=False, + ) + return + order = bot.store.file_restraining_order( + ctx.guild.id, + ctx.channel.id, + ctx.author.id, + user.id, + ttl_seconds=bot.config.restraining_order_ttl_seconds, + ) + await _say( + ctx, + f"Restraining order filed: {user.mention} can't target you with " + f"proposals or actions until . If they " + "try, you'll be asked whether to press charges - charges mean " + "jail.", + ) + + # --------------------------------------------------------------------------- # Server configuration # --------------------------------------------------------------------------- @@ -1202,6 +1627,26 @@ async def matr_forceemancipate(ctx: commands.Context, child: discord.User) -> No await _say(ctx, f"Emancipated {child.mention}.") +@matr.command(name="clear") +@commands.guild_only() +@app_commands.guild_only() +@commands.has_permissions(manage_guild=True) +@app_commands.default_permissions(manage_guild=True) +async def matr_clear(ctx: commands.Context) -> None: + """Reset this server's Matrimony state (Manage Server only). + + Shows a warning first: it only runs after the invoking admin presses + Confirm within 60 seconds.""" + view = ClearConfirmView(ctx.author.id, ctx.guild.id) + view.message = await ctx.reply( + embed=_embed( + f"**Reset this server's Matrimony state?**\n\n{_CLEAR_SCOPE}", + RED, + ), + view=view, + ) + + # --------------------------------------------------------------------------- # Fun (simulation) commands # --------------------------------------------------------------------------- @@ -1233,6 +1678,10 @@ def _make_fun(name: str) -> None: verb, emoji, gif_type = _FUN_VERBS[name] async def _fun(ctx: commands.Context, user: discord.User) -> None: + if await _jail_block(ctx, user.id): + return + if await _restrain_block(ctx, user, name): + return gif = await _bot(ctx).gifs.fetch_url(gif_type) if user.id == ctx.author.id: text = f"You {verb} yourself. Okay then. {emoji}" @@ -1298,6 +1747,10 @@ async def matr_ship( async def matr_flirt(ctx: commands.Context, user: discord.User) -> None: """Drop a terrible pickup line on someone.""" bot = _bot(ctx) + if await _jail_block(ctx, user.id): + return + if await _restrain_block(ctx, user, "flirt"): + return line = random.choice(_PICKUP_LINES) gif = await bot.gifs.fetch_url("wink") text = f"{ctx.author.mention} to {user.mention}: *{line}*" @@ -1308,6 +1761,10 @@ async def matr_flirt(ctx: commands.Context, user: discord.User) -> None: async def matr_date(ctx: commands.Context, user: discord.User) -> None: """Invent a terrible date for you and someone.""" bot = _bot(ctx) + if await _jail_block(ctx, user.id): + return + if await _restrain_block(ctx, user, "date"): + return idea = random.choice(_DATE_IDEAS) gif = await bot.gifs.fetch_url("hug") text = f"{ctx.author.mention} takes {user.mention} {idea}." @@ -1434,7 +1891,9 @@ async def matr_stats(ctx: commands.Context) -> None: f"Marriages: **{s['marriages']}**\n" f"Parent-child links: **{s['parent_child_links']}**\n" f"Open proposals: **{s['open_proposals']}**\n" - f"Partner notices: **{s['partner_notices']}**" + f"Partner notices: **{s['partner_notices']}**\n" + f"Restraining orders: **{s['restraining_orders']}**\n" + f"Inmates: **{s['inmates']}**" ), ) ) @@ -1454,7 +1913,6 @@ async def matr_stats(ctx: commands.Context) -> None: "accept", "decline", "partners", - "anniversary", "adopt", "makeparent", "disown", @@ -1471,6 +1929,7 @@ async def matr_stats(ctx: commands.Context) -> None: "block", "unblock", "blocked", + "restrain", ) _APP_SUBGROUPS = ( @@ -1496,6 +1955,7 @@ async def matr_stats(ctx: commands.Context) -> None: "ship", "flirt", "date", + "anniversary", ), ), ( @@ -1508,6 +1968,7 @@ async def matr_stats(ctx: commands.Context) -> None: "forcedivorce", "forceadopt", "forceemancipate", + "clear", ), ), ( @@ -1527,6 +1988,61 @@ async def matr_stats(ctx: commands.Context) -> None: _matr_app_group.add_command(_sub) +async def _announce_expiry( + bot: MatrimonyBot, report: jail_mod.ExpiryReport +) -> None: + """Post the sweep's expiry/release notices to the channels on record. + + Order lapses announce in the channel the order was filed in; releases + (and the jail-relationship dissolutions they cause) announce where + the sentence was handed down. Missing/deleted channels are skipped.""" + for order in report.expired_orders: + channel = bot.get_channel(order.channel_id) + if channel is None: + continue + try: + await channel.send( + embed=_embed( + f"The restraining order protecting " + f"<@{order.protected}> against <@{order.restrained}> " + f"has expired." + ) + ) + except discord.HTTPException: + continue + for rel in report.released: + channel = bot.get_channel(rel.sentence.channel_id) + if channel is None: + continue + served = rel.sentence.release_at - rel.sentence.jailed_at + lines = [ + f"<@{rel.sentence.inmate}> has served their " + f"{_fmt_duration(served)} sentence and is released.\n" + f"-# {_quip('release')}" + ] + for partner in rel.divorced: + lines.append( + f"<@{rel.sentence.inmate}> and <@{partner}> were " + f"divorced on release. -# {_quip('dissolve')}" + ) + for child in rel.children_lost: + lines.append( + f"<@{child}> was abandoned - their jail parent " + f"<@{rel.sentence.inmate}> was released. " + f"-# {_quip('dissolve')}" + ) + if rel.parent_lost is not None: + lines.append( + f"<@{rel.sentence.inmate}>'s jail parent " + f"<@{rel.parent_lost}> is left inside - adoption " + f"dissolved. -# {_quip('dissolve')}" + ) + try: + await channel.send(embed=_embed("\n".join(lines))) + except discord.HTTPException: + continue + + class MatrCog(commands.Cog): def __init__(self, bot: MatrimonyBot) -> None: self.bot = bot @@ -1534,6 +2050,26 @@ def __init__(self, bot: MatrimonyBot) -> None: matr.app_command.name = bot.config.command_name bot.add_command(matr) + async def cog_load(self) -> None: + self.expiry_sweep.change_interval( + seconds=self.bot.config.jail_sweep_seconds + ) + self.expiry_sweep.start() + + async def cog_unload(self) -> None: + self.expiry_sweep.cancel() + + @tasks.loop(seconds=20) + async def expiry_sweep(self) -> None: + """Lapse restraining orders and release finished sentences.""" + report = jail_mod.sweep_expiry(self.bot.store, int(time.time())) + if report.expired_orders or report.released: + await _announce_expiry(self.bot, report) + + @expiry_sweep.before_loop + async def _before_sweep(self) -> None: + await self.bot.wait_until_ready() + @commands.Cog.listener() async def on_command_error( self, ctx: commands.Context, error: commands.CommandError diff --git a/matrimony/config.py b/matrimony/config.py index 51aa44a..096294d 100644 --- a/matrimony/config.py +++ b/matrimony/config.py @@ -91,6 +91,10 @@ class Config: max_children: Maximum children per user. 0 = unlimited. max_tree_depth: Depth limit applied when rendering family trees. proposal_ttl_seconds: How long a proposal stays open. + restraining_order_ttl_seconds: How long a restraining order lasts + (default 1 hour). + jail_sweep_seconds: How often the background sweep checks for + expired restraining orders and finished jail sentences. fluxpoint_api_key: Fluxpoint API token used to fetch action GIFs. Defaults to the provider's public-tier key; set ``FLUXPOINT_API_KEY`` to your own token, or to an empty string @@ -111,6 +115,8 @@ class Config: max_children: int = 0 max_tree_depth: int = 10 proposal_ttl_seconds: int = 300 + restraining_order_ttl_seconds: int = 3600 + jail_sweep_seconds: int = 20 fluxpoint_api_key: str = DEFAULT_FLUXPOINT_API_KEY allowed_channels: dict[int, frozenset[int]] = field(default_factory=dict) @@ -126,6 +132,10 @@ def from_env(cls, env: dict[str, str] | None = None) -> Config: max_children=int(env.get("MATRIMONY_MAX_CHILDREN", "0")), max_tree_depth=int(env.get("MATRIMONY_MAX_TREE_DEPTH", "10")), proposal_ttl_seconds=int(env.get("MATRIMONY_PROPOSAL_TTL", "300")), + restraining_order_ttl_seconds=int( + env.get("MATRIMONY_RESTRAINING_ORDER_TTL", "3600") + ), + jail_sweep_seconds=int(env.get("MATRIMONY_JAIL_SWEEP", "20")), fluxpoint_api_key=env.get( "FLUXPOINT_API_KEY", DEFAULT_FLUXPOINT_API_KEY ), diff --git a/matrimony/jail.py b/matrimony/jail.py new file mode 100644 index 0000000..9782dc1 --- /dev/null +++ b/matrimony/jail.py @@ -0,0 +1,145 @@ +"""Restraining-order and jail mechanics for Matrimony. + +Like ``family.py``, everything here is independent of discord.py: the +sentence-length table, the jail-relationship validation rules, and +``sweep_expiry`` - the periodic expiry pass - are all pure functions of +the store and an injected clock, so the whole module is unit-testable. +""" + +from __future__ import annotations + +import random +from dataclasses import dataclass, field + +from .store import ReleaseResult, RestrainingOrder, Store + +# --------------------------------------------------------------------------- +# Sentencing +# --------------------------------------------------------------------------- + +# Actions covered by restraining-order enforcement: everything a +# restrained user could aim at the protected user. (Read-only actions +# like `ship`, `tree` or `relationship` are not "targeted at" anyone and +# stay free.) +RESTRAINED_ACTIONS = frozenset({ + "marry", "adopt", "makeparent", + "hug", "cuddle", "kiss", "pat", "poke", "wave", "lick", "nom", + "tickle", "highfive", "handhold", "slap", "punch", "bite", "stab", + "flirt", "date", +}) + +# Jailed users are blocked from the restrained set plus the remaining +# family-mutating actions that target a (necessarily free) user on the +# outside: divorce, disown and (family) block. +JAILED_ACTIONS = RESTRAINED_ACTIONS | {"divorce", "disown", "block"} + +# Sentence lengths for violating a restraining order. +# +# Minor infractions - light social actions - draw a RANDOM sentence +# between 1 and 5 minutes. Major infractions draw a fixed sentence that +# scales with severity, up to the 30-minute cap for attempted forced +# marriage: +# +# slap 10 min +# adopt 15 min +# makeparent 15 min +# bite 20 min +# punch 20 min +# stab 25 min +# marry 30 min +# (unlisted) 15 min - safety default for future major actions +MINOR_SENTENCE_RANGE = (60, 300) # seconds: a random 1-5 minute stint +MAJOR_SENTENCE_MINUTES = { + "slap": 10, + "adopt": 15, + "makeparent": 15, + "bite": 20, + "punch": 20, + "stab": 25, + "marry": 30, +} +DEFAULT_MAJOR_MINUTES = 15 + + +def is_minor_action(action: str) -> bool: + """Whether ``action`` draws the random 1-5 minute minor sentence.""" + return action in RESTRAINED_ACTIONS and action not in MAJOR_SENTENCE_MINUTES + + +def sentence_seconds(action: str, rng: random.Random = random) -> int: + """How long ``action`` lands the offender in jail, in seconds. + + Minor actions roll a random sentence inside ``MINOR_SENTENCE_RANGE``; + major ones read ``MAJOR_SENTENCE_MINUTES`` (with a 15-minute default + for any targeted action not explicitly listed).""" + if is_minor_action(action): + return rng.randint(*MINOR_SENTENCE_RANGE) + return MAJOR_SENTENCE_MINUTES.get(action, DEFAULT_MAJOR_MINUTES) * 60 + + +# --------------------------------------------------------------------------- +# Jail relationships +# --------------------------------------------------------------------------- + + +def check_jail_marry(store: Store, proposer: int, target: int) -> str | None: + """Jail equivalent of ``FamilyView.check_marry`` - deliberately much + laxer (no blood/incest rules, no partner cap), since jail marriages + are ephemeral anyway.""" + if proposer == target: + return "You can't marry yourself, even in jail." + if target in store.jail_partners_of(proposer): + return "You're already jail-married to them!" + if store.is_blocked(target, proposer): + return "They've blocked you from sending them proposals." + return None + + +def check_jail_adopt(store: Store, parent: int, child: int) -> str | None: + """Jail equivalent of ``check_adopt``: one jail parent per child and + no direct two-cycle, but otherwise open.""" + if parent == child: + return "You can't adopt yourself, even in jail." + existing = store.jail_parent_of(child) + if existing is not None: + if existing == parent: + return "They're already your jail child." + return "They already have a jail parent." + if store.jail_parent_of(parent) == child: + return "They're already your jail parent." + if store.is_blocked(child, parent): + return "They've blocked you from sending them proposals." + return None + + +# --------------------------------------------------------------------------- +# Expiry sweep +# --------------------------------------------------------------------------- + + +@dataclass +class ExpiryReport: + """Everything a sweep changed, for the cog to announce. + + ``expired_orders`` are the restraining orders that lapsed; + ``released`` holds one ReleaseResult per freed inmate (including the + jail relationships dissolved by their release).""" + + expired_orders: list[RestrainingOrder] = field(default_factory=list) + released: list[ReleaseResult] = field(default_factory=list) + + +def sweep_expiry(store: Store, now: int) -> ExpiryReport: + """Apply expiry for ``now``: lapse restraining orders past their + expiry and release inmates whose sentence is up (dissolving their + jail relationships). + + Pure in its inputs - same store state + same ``now`` = same result - + so tests drive it with an injected clock.""" + expired = store.expire_restraining_orders(now) + released = [] + for sentence in store.sentences_due(now): + result = store.release_inmate(sentence.inmate) + if result is not None: + released.append(result) + return ExpiryReport(expired_orders=expired, released=released) diff --git a/matrimony/store.py b/matrimony/store.py index cb5890d..5ea1436 100644 --- a/matrimony/store.py +++ b/matrimony/store.py @@ -43,6 +43,7 @@ proposer INTEGER NOT NULL, target INTEGER NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('marry', 'adopt', 'makeparent')), + jail INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, UNIQUE (proposer, target, kind) @@ -63,6 +64,44 @@ other_id INTEGER NOT NULL, created_at INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS restraining_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + protected INTEGER NOT NULL, + restrained INTEGER NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + UNIQUE (protected, restrained) +); + +CREATE TABLE IF NOT EXISTS jail_sentences ( + inmate INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + action TEXT NOT NULL, + protected INTEGER NOT NULL, + jailed_at INTEGER NOT NULL, + release_at INTEGER NOT NULL +); + +-- Jail relationships are deliberately kept out of the permanent +-- partners/children tables: they are ephemeral and never survive a +-- release (see store.release_inmate). +CREATE TABLE IF NOT EXISTS jail_partners ( + user_a INTEGER NOT NULL, + user_b INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_a, user_b), + CHECK (user_a < user_b) +); + +CREATE TABLE IF NOT EXISTS jail_children ( + child_id INTEGER PRIMARY KEY, + parent_id INTEGER NOT NULL, + created_at INTEGER NOT NULL +); """ @@ -74,10 +113,58 @@ class Proposal: proposer: int target: int kind: str # 'marry' | 'adopt' | 'makeparent' + jail: bool # proposed between two inmates; enacts jail relationships + created_at: int + expires_at: int + + +@dataclass(frozen=True) +class RestrainingOrder: + """A one-hour order stopping ``restrained`` from targeting + ``protected`` with proposals and fun actions. ``channel_id`` is the + channel it was filed in, used to announce its expiry.""" + + id: int + guild_id: int + channel_id: int + protected: int + restrained: int created_at: int expires_at: int +@dataclass(frozen=True) +class JailSentence: + """A custodial sentence for violating a restraining order. + + ``action`` is the attempted command that triggered it (drives the + sentence length); ``protected`` is who pressed charges; + ``channel_id`` is where the violation happened, reused for the + release announcement.""" + + inmate: int + guild_id: int + channel_id: int + action: str + protected: int + jailed_at: int + release_at: int + + +@dataclass(frozen=True) +class ReleaseResult: + """Everything a release dissolved, for notifications. + + ``divorced`` are the inmate's jail partners; ``children_lost`` are + jail children whose jail parent was released; ``parent_lost`` is the + inmate's own jail parent, if any.""" + + sentence: JailSentence + divorced: list[int] + children_lost: list[int] + parent_lost: int | None + + @dataclass(frozen=True) class Notice: """A "your partner married someone new" notification. @@ -110,6 +197,21 @@ def __init__(self, path: str = ":memory:") -> None: self._conn.execute("PRAGMA journal_mode = WAL") with self._lock: self._conn.executescript(SCHEMA) + self._migrate() + + def _migrate(self) -> None: + """Add columns introduced after the first schema version. + + ``CREATE TABLE IF NOT EXISTS`` leaves existing tables alone, so + the ``proposals.jail`` flag (added for jail relationships) is + back-filled here for databases created before it existed.""" + cols = {r[1] for r in self._rows("PRAGMA table_info(proposals)")} + if "jail" not in cols: + self._conn.execute( + "ALTER TABLE proposals ADD COLUMN jail " + "INTEGER NOT NULL DEFAULT 0" + ) + self._conn.commit() def close(self) -> None: with self._lock: @@ -255,6 +357,8 @@ def create_proposal( target: int, kind: str, ttl_seconds: int, + *, + jail: bool = False, ) -> Proposal | None: """Create a proposal. Returns None if an identical open one exists.""" now = int(time.time()) @@ -262,28 +366,32 @@ def create_proposal( with self._lock: existing = self._row( "SELECT id FROM proposals WHERE proposer = ? AND target = ? " - "AND kind = ?", - (proposer, target, kind), + "AND kind = ? AND jail = ?", + (proposer, target, kind, int(jail)), ) if existing: return None cur = self._conn.execute( """INSERT INTO proposals - (guild_id, channel_id, proposer, target, kind, + (guild_id, channel_id, proposer, target, kind, jail, created_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - (guild_id, channel_id, proposer, target, kind, now, - now + ttl_seconds), + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (guild_id, channel_id, proposer, target, kind, int(jail), + now, now + ttl_seconds), ) self._conn.commit() pid = cur.lastrowid return Proposal(pid, guild_id, channel_id, proposer, target, kind, - now, now + ttl_seconds) + jail, now, now + ttl_seconds) + + _PROPOSAL_COLS = ( + "id, guild_id, channel_id, proposer, target, kind, jail, " + "created_at, expires_at" + ) def get_proposal(self, proposal_id: int) -> Proposal | None: row = self._row( - "SELECT id, guild_id, channel_id, proposer, target, kind, " - "created_at, expires_at FROM proposals WHERE id = ?", + f"SELECT {self._PROPOSAL_COLS} FROM proposals WHERE id = ?", (proposal_id,), ) return Proposal(*row) if row else None @@ -292,14 +400,12 @@ def proposals_for(self, target: int, kind: str | None = None) -> list[Proposal]: self.expire_proposals() if kind is None: rows = self._rows( - "SELECT id, guild_id, channel_id, proposer, target, kind, " - "created_at, expires_at FROM proposals WHERE target = ?", + f"SELECT {self._PROPOSAL_COLS} FROM proposals WHERE target = ?", (target,), ) else: rows = self._rows( - "SELECT id, guild_id, channel_id, proposer, target, kind, " - "created_at, expires_at FROM proposals WHERE target = ? " + f"SELECT {self._PROPOSAL_COLS} FROM proposals WHERE target = ? " "AND kind = ?", (target, kind), ) @@ -398,6 +504,277 @@ def blocked_users(self, blocker: int) -> list[int]: ) ] + # --------------------------------------------------- restraining orders + + _ORDER_COLS = ( + "id, guild_id, channel_id, protected, restrained, " + "created_at, expires_at" + ) + + def file_restraining_order( + self, + guild_id: int, + channel_id: int, + protected: int, + restrained: int, + *, + ttl_seconds: int, + now: int | None = None, + ) -> RestrainingOrder: + """File (or renew) a restraining order. Re-filing an active order + refreshes its expiry back to the full duration.""" + now = int(time.time()) if now is None else now + expires_at = now + ttl_seconds + with self._lock: + self._conn.execute( + """INSERT INTO restraining_orders + (guild_id, channel_id, protected, restrained, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(protected, restrained) DO UPDATE SET + guild_id = excluded.guild_id, + channel_id = excluded.channel_id, + expires_at = excluded.expires_at""", + (guild_id, channel_id, protected, restrained, + now, expires_at), + ) + self._conn.commit() + row = self._row( + f"SELECT {self._ORDER_COLS} FROM restraining_orders " + "WHERE protected = ? AND restrained = ?", + (protected, restrained), + ) + return RestrainingOrder(*row) + + def restraining_order( + self, protected: int, restrained: int, now: int | None = None + ) -> RestrainingOrder | None: + """The active order protecting ``protected`` from ``restrained``, + or None (expired orders are pruned on read).""" + now = int(time.time()) if now is None else now + self.expire_restraining_orders(now) + row = self._row( + f"SELECT {self._ORDER_COLS} FROM restraining_orders " + "WHERE protected = ? AND restrained = ?", + (protected, restrained), + ) + return RestrainingOrder(*row) if row else None + + def expire_restraining_orders( + self, now: int | None = None + ) -> list[RestrainingOrder]: + """Delete orders past their expiry and return them (for the + "order lapsed" announcements).""" + now = int(time.time()) if now is None else now + with self._lock: + rows = self._rows( + f"SELECT {self._ORDER_COLS} FROM restraining_orders " + "WHERE expires_at <= ?", + (now,), + ) + self._conn.execute( + "DELETE FROM restraining_orders WHERE expires_at <= ?", (now,) + ) + self._conn.commit() + return [RestrainingOrder(*r) for r in rows] + + # ----------------------------------------------------------------- jail + + def jail_inmate( + self, + inmate: int, + guild_id: int, + channel_id: int, + action: str, + protected: int, + sentence_seconds: int, + *, + now: int | None = None, + ) -> JailSentence: + """Sentence ``inmate``. Re-jailing someone already inside restarts + the clock from ``now`` for the new sentence.""" + now = int(time.time()) if now is None else now + release_at = now + sentence_seconds + self._exec( + """INSERT INTO jail_sentences + (inmate, guild_id, channel_id, action, protected, + jailed_at, release_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(inmate) DO UPDATE SET + guild_id = excluded.guild_id, + channel_id = excluded.channel_id, + action = excluded.action, + protected = excluded.protected, + jailed_at = excluded.jailed_at, + release_at = excluded.release_at""", + (inmate, guild_id, channel_id, action, protected, + now, release_at), + ) + return JailSentence( + inmate, guild_id, channel_id, action, protected, now, release_at + ) + + _SENTENCE_COLS = ( + "inmate, guild_id, channel_id, action, protected, " + "jailed_at, release_at" + ) + + def sentence_for( + self, user_id: int, now: int | None = None + ) -> JailSentence | None: + """The active sentence for ``user_id``, or None. Expired-but-unswept + sentences are treated as already served.""" + now = int(time.time()) if now is None else now + row = self._row( + f"SELECT {self._SENTENCE_COLS} FROM jail_sentences " + "WHERE inmate = ? AND release_at > ?", + (user_id, now), + ) + return JailSentence(*row) if row else None + + def is_jailed(self, user_id: int, now: int | None = None) -> bool: + return self.sentence_for(user_id, now) is not None + + def sentences_due(self, now: int | None = None) -> list[JailSentence]: + """Sentences whose release time has passed, still awaiting the + sweep that frees them.""" + now = int(time.time()) if now is None else now + return [ + JailSentence(*r) + for r in self._rows( + f"SELECT {self._SENTENCE_COLS} FROM jail_sentences " + "WHERE release_at <= ?", + (now,), + ) + ] + + # ----------------------------------------------------- jail relationships + + def add_jail_partners(self, user_x: int, user_y: int) -> None: + a, b = sorted((user_x, user_y)) + self._exec( + "INSERT OR IGNORE INTO jail_partners (user_a, user_b, created_at) " + "VALUES (?, ?, ?)", + (a, b, int(time.time())), + ) + + def jail_partners_of(self, user_id: int) -> list[int]: + rows = self._rows( + "SELECT user_a, user_b FROM jail_partners " + "WHERE user_a = ? OR user_b = ?", + (user_id, user_id), + ) + return [r[1] if r[0] == user_id else r[0] for r in rows] + + def add_jail_child(self, parent_id: int, child_id: int) -> bool: + with self._lock: + cur = self._conn.execute( + "INSERT OR IGNORE INTO jail_children " + "(child_id, parent_id, created_at) VALUES (?, ?, ?)", + (child_id, parent_id, int(time.time())), + ) + self._conn.commit() + return cur.rowcount > 0 + + def jail_parent_of(self, child_id: int) -> int | None: + row = self._row( + "SELECT parent_id FROM jail_children WHERE child_id = ?", + (child_id,), + ) + return row[0] if row else None + + def jail_children_of(self, parent_id: int) -> list[int]: + return [ + r[0] + for r in self._rows( + "SELECT child_id FROM jail_children WHERE parent_id = ?", + (parent_id,), + ) + ] + + def release_inmate(self, inmate: int) -> ReleaseResult | None: + """Free ``inmate``: delete the sentence and dissolve every jail + relationship they hold (jail marriages are divorced, jail + adoptions abandoned in both directions). Returns what was + dissolved so callers can notify, or None if they weren't inside.""" + with self._lock: + row = self._row( + f"SELECT {self._SENTENCE_COLS} FROM jail_sentences " + "WHERE inmate = ?", + (inmate,), + ) + if row is None: + return None + sentence = JailSentence(*row) + self._conn.execute( + "DELETE FROM jail_sentences WHERE inmate = ?", (inmate,) + ) + divorced = self.jail_partners_of(inmate) + for partner in divorced: + x, y = sorted((inmate, partner)) + self._conn.execute( + "DELETE FROM jail_partners WHERE user_a = ? AND user_b = ?", + (x, y), + ) + children_lost = self.jail_children_of(inmate) + self._conn.execute( + "DELETE FROM jail_children WHERE parent_id = ?", (inmate,) + ) + parent_lost = self.jail_parent_of(inmate) + self._conn.execute( + "DELETE FROM jail_children WHERE child_id = ?", (inmate,) + ) + self._conn.commit() + return ReleaseResult(sentence, divorced, children_lost, parent_lost) + + # ------------------------------------------------------------------ clear + + def clear_guild(self, guild_id: int) -> dict[str, int]: + """Reset this guild's state: its settings row, proposals and + notices filed here, restraining orders filed here, and jail + sentences handed down here (inmates are released and their jail + relationships dissolved). + + Deliberately NOT cleared: partners/children (the family tree is + global across guilds - it isn't guild state) and personal block + lists (also global). Returns per-table deletion counts.""" + counts: dict[str, int] = {} + + def _delete(table: str) -> int: + with self._lock: + cur = self._conn.execute( + f"DELETE FROM {table} WHERE guild_id = ?", (guild_id,) + ) + self._conn.commit() + return cur.rowcount + + counts["guild_settings"] = _delete("guild_settings") + counts["proposals"] = _delete("proposals") + counts["notices"] = _delete("notices") + counts["restraining_orders"] = _delete("restraining_orders") + sentences = [ + JailSentence(*r) + for r in self._rows( + f"SELECT {self._SENTENCE_COLS} FROM jail_sentences " + "WHERE guild_id = ?", + (guild_id,), + ) + ] + counts["jail_sentences"] = 0 + counts["jail_partners"] = 0 + counts["jail_children"] = 0 + for s in sentences: + result = self.release_inmate(s.inmate) + if result is None: + continue + counts["jail_sentences"] += 1 + counts["jail_partners"] += len(result.divorced) + counts["jail_children"] += ( + len(result.children_lost) + + (1 if result.parent_lost is not None else 0) + ) + return counts + # ------------------------------------------------------------------ misc def family_size(self, user_id: int) -> int: @@ -428,5 +805,7 @@ def count(table: str) -> int: "open_proposals": count("proposals"), "partner_notices": count("notices"), "blocks": count("blocks"), + "restraining_orders": count("restraining_orders"), + "inmates": count("jail_sentences"), "guild_settings": count("guild_settings"), } diff --git a/tests/test_jail.py b/tests/test_jail.py new file mode 100644 index 0000000..cebfbcb --- /dev/null +++ b/tests/test_jail.py @@ -0,0 +1,582 @@ +"""Tests for restraining orders, the jail system, the expiry sweep, and +the `matr clear` guild reset. + +Store and sweep tests inject ``now`` directly; command-level tests drive +the same ``ctx``/``interaction`` fakes as test_dispatch.""" + +import random +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from test_dispatch import ctx_for, make_bot, run, user + +from matrimony import jail as jail_mod +from matrimony.cogs import matr as matr_mod +from matrimony.cogs.matr import ( + ClearConfirmView, + PressChargesView, + _announce_expiry, + _resolve_charge, +) +from matrimony.store import Store + +GUILD = 42 + + +def order_filed(bot, protected: int, restrained: int, now: int | None = None): + return bot.store.file_restraining_order( + GUILD, 7, protected, restrained, ttl_seconds=3600, now=now + ) + + +def interaction(bot, uid: int): + return SimpleNamespace( + client=bot, + user=SimpleNamespace(id=uid), + response=SimpleNamespace( + send_message=AsyncMock(), is_done=lambda: False + ), + followup=SimpleNamespace(send=AsyncMock()), + reply=AsyncMock(), + ) + + +# --------------------------------------------------------------------------- +# Restraining orders: creation and expiry +# --------------------------------------------------------------------------- + + +def test_file_restraining_order() -> None: + bot = make_bot() + order = order_filed(bot, 1, 2) + assert order.protected == 1 and order.restrained == 2 + assert order.expires_at - order.created_at == 3600 + assert bot.store.restraining_order(1, 2) is not None + + +def test_restraining_order_expires() -> None: + bot = make_bot() + order_filed(bot, 1, 2, now=1000) + # 59 minutes in: still active. + assert bot.store.restraining_order(1, 2, now=1000 + 3540) is not None + # At the hour mark: gone. + assert bot.store.restraining_order(1, 2, now=1000 + 3600) is None + + +def test_refiling_renews_the_hour() -> None: + bot = make_bot() + order_filed(bot, 1, 2, now=1000) + order = order_filed(bot, 1, 2, now=4000) + assert order.expires_at == 4000 + 3600 + assert bot.store.restraining_order(1, 2, now=4000 + 3599) is not None + + +def test_expire_restraining_orders_returns_lapsed() -> None: + bot = make_bot() + order_filed(bot, 1, 2, now=1000) + order_filed(bot, 3, 4, now=1000) + expired = bot.store.expire_restraining_orders(now=1000 + 3600) + assert {(o.protected, o.restrained) for o in expired} == {(1, 2), (3, 4)} + assert bot.store.expire_restraining_orders(now=1000 + 3600) == [] + + +# --------------------------------------------------------------------------- +# Sentencing +# --------------------------------------------------------------------------- + + +def test_minor_infraction_rolls_1_to_5_minutes() -> None: + rng = random.Random(7) + for action in ("poke", "hug", "wave", "tickle", "flirt"): + for _ in range(20): + seconds = jail_mod.sentence_seconds(action, rng) + assert 60 <= seconds <= 300, (action, seconds) + + +def test_major_infraction_sentences() -> None: + expected = { + "slap": 10, + "adopt": 15, + "makeparent": 15, + "bite": 20, + "punch": 20, + "stab": 25, + "marry": 30, + } + for action, minutes in expected.items(): + assert jail_mod.sentence_seconds(action) == minutes * 60 + + +def test_unlisted_action_gets_default_major() -> None: + # Any targeted action missing from the table is a 15-minute major. + assert jail_mod.sentence_seconds("banish") == 15 * 60 + + +# --------------------------------------------------------------------------- +# Jail blocking and release +# --------------------------------------------------------------------------- + + +def test_jailing_and_release() -> None: + bot = make_bot() + bot.store.jail_inmate(2, GUILD, 7, "stab", 1, 600, now=1000) + assert bot.store.is_jailed(2, now=1000) + assert bot.store.is_jailed(2, now=1599) + assert not bot.store.is_jailed(2, now=1600) + s = bot.store.sentence_for(2, now=1000) + assert s.action == "stab" and s.protected == 1 + + +def test_release_inmate_dissolves_jail_relationships() -> None: + bot = make_bot() + bot.store.jail_inmate(2, GUILD, 7, "punch", 1, 600, now=1000) + bot.store.jail_inmate(3, GUILD, 7, "poke", 1, 600, now=1000) + bot.store.add_jail_partners(2, 3) + bot.store.add_jail_child(2, 3) # 3 is 2's jail child + bot.store.jail_inmate(4, GUILD, 7, "poke", 1, 600, now=1000) + bot.store.add_jail_child(4, 2) # 4 is 2's jail parent + + result = bot.store.release_inmate(2) + assert result.divorced == [3] + assert result.children_lost == [3] + assert result.parent_lost == 4 + assert not bot.store.is_jailed(2, now=1000) + assert bot.store.jail_partners_of(3) == [] + assert bot.store.jail_children_of(4) == [] + assert bot.store.jail_parent_of(3) is None + # Release is idempotent. + assert bot.store.release_inmate(2) is None + + +# --------------------------------------------------------------------------- +# Command-level: restrain and violation prompts +# --------------------------------------------------------------------------- + + +def test_restrain_command_files_order() -> None: + bot = make_bot() + protected, restrained = user(10), user(20) + ctx = ctx_for(bot, protected) + run(matr_mod.matr_restrain.callback(ctx, restrained)) + assert bot.store.restraining_order(10, 20) is not None + ctx.reply.assert_awaited_once() + + +def test_restrain_self_is_rejected() -> None: + bot = make_bot() + ctx = ctx_for(bot, user(10)) + run(matr_mod.matr_restrain.callback(ctx, user(10))) + assert bot.store.restraining_order(10, 10) is None + + +def _violation_prompt(ctx): + """The PressChargesView posted by a blocked action.""" + ctx.send.assert_awaited_once() + view = ctx.send.call_args.kwargs["view"] + assert isinstance(view, PressChargesView) + return view + + +def test_blocked_fun_action_prompts_charges() -> None: + bot = make_bot() + protected, restrained = user(10), user(20) + order_filed(bot, 10, 20) + ctx = ctx_for(bot, restrained) + run(matr_mod.matr.get_command("hug").callback(ctx, protected)) + view = _violation_prompt(ctx) + assert view.order.protected == 10 and view.order.restrained == 20 + assert view.action == "hug" + # The hug itself never went through. + ctx.reply.assert_not_awaited() + + +def test_blocked_proposal_prompts_charges() -> None: + bot = make_bot() + protected, restrained = user(10), user(20) + order_filed(bot, 10, 20) + ctx = ctx_for(bot, restrained) + run(matr_mod.matr_marry.callback(ctx, protected)) + view = _violation_prompt(ctx) + assert view.action == "marry" + assert bot.store.proposals_for(10) == [] + + +def test_expired_order_no_longer_blocks() -> None: + bot = make_bot() + protected, restrained = user(10), user(20) + # Filed over an hour ago: already lapsed. + order_filed(bot, 10, 20, now=int(time.time()) - 3700) + ctx = ctx_for(bot, restrained) + run(matr_mod.matr_marry.callback(ctx, protected)) + assert len(bot.store.proposals_for(10, "marry")) == 1 + + +def test_pressing_charges_jails_the_offender() -> None: + bot = make_bot() + order = order_filed(bot, 10, 20) + ctx = ctx_for(bot, user(10)) + run(_resolve_charge(bot, ctx, order, "slap", pressed=True)) + s = bot.store.sentence_for(20) + assert s is not None + assert s.action == "slap" and s.protected == 10 + # slap = 10 minutes. + assert s.release_at - s.jailed_at == 600 + + +def test_declining_charges_keeps_order_no_jail() -> None: + bot = make_bot() + order = order_filed(bot, 10, 20) + ctx = ctx_for(bot, user(10)) + run(_resolve_charge(bot, ctx, order, "poke", pressed=False)) + assert bot.store.sentence_for(20) is None + assert bot.store.restraining_order(10, 20) is not None + + +def test_pressing_after_order_lapsed_does_not_jail() -> None: + bot = make_bot() + order = order_filed(bot, 10, 20, now=1000) + bot.store.expire_restraining_orders(now=1000 + 4000) + ctx = ctx_for(bot, user(10)) + run(_resolve_charge(bot, ctx, order, "poke", pressed=True)) + assert bot.store.sentence_for(20) is None + + +def test_charge_prompt_only_answers_to_protected() -> None: + bot = make_bot() + order = order_filed(bot, 10, 20) + view = PressChargesView(order, "poke") + bystander = interaction(bot, 99) + assert run(view.interaction_check(bystander)) is False + bystander.response.send_message.assert_awaited_once() + assert run(view.interaction_check(interaction(bot, 10))) is True + + +# --------------------------------------------------------------------------- +# Jail blocking of non-jailed targets; jailed-to-jailed allowed +# --------------------------------------------------------------------------- + + +def test_jailed_user_cannot_target_free_user() -> None: + bot = make_bot() + inmate, free = user(20), user(30) + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 600) + ctx = ctx_for(bot, inmate) + run(matr_mod.matr.get_command("hug").callback(ctx, free)) + # No press-charges prompt, no hug - just the jail error reply. + ctx.send.assert_not_awaited() + ctx.reply.assert_awaited_once() + text = ctx.reply.call_args.kwargs["embed"].description + assert "jail" in text.lower() + + +def test_jailed_user_cannot_marry_free_user() -> None: + bot = make_bot() + inmate, free = user(20), user(30) + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 600) + ctx = ctx_for(bot, inmate) + run(matr_mod.matr_marry.callback(ctx, free)) + assert bot.store.proposals_for(30) == [] + ctx.reply.assert_awaited_once() + + +def test_free_user_cannot_target_inmate_with_proposals() -> None: + """The free side may not pull inmates into real relationships.""" + bot = make_bot() + free, inmate = user(30), user(20) + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 600) + ctx = ctx_for(bot, free) + run(matr_mod.matr_marry.callback(ctx, inmate)) + # A real proposal to a jailed user still goes through - the inmate + # answers from inside (and it enacts on the real tree). Not blocked. + assert len(bot.store.proposals_for(20, "marry")) == 1 + + +def test_jailed_pair_can_jail_marry() -> None: + bot = make_bot() + a, b = user(20), user(30) + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 600) + bot.store.jail_inmate(30, GUILD, 7, "poke", 10, 600) + 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.jail_partners_of(20) == [30] + assert bot.store.partners_of(20) == [] + + +def test_jailed_pair_can_jail_adopt() -> None: + bot = make_bot() + a, b = user(20), user(30) + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 600) + bot.store.jail_inmate(30, GUILD, 7, "poke", 10, 600) + run(matr_mod.matr_adopt.callback(ctx_for(bot, a), b)) + run(matr_mod.matr_accept.callback(ctx_for(bot, b), None)) + assert bot.store.jail_children_of(20) == [30] + assert bot.store.children_of(20) == [] + + +def test_jailed_pair_fun_action_passes() -> None: + bot = make_bot() + a, b = user(20), user(30) + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 600) + bot.store.jail_inmate(30, GUILD, 7, "poke", 10, 600) + ctx = ctx_for(bot, a) + run(matr_mod.matr.get_command("hug").callback(ctx, b)) + ctx.reply.assert_awaited_once() + assert "hugs" in ctx.reply.call_args.kwargs["embed"].description + + +def test_jail_proposal_lapses_after_release() -> None: + """A jail proposal accepted after someone's release enacts nothing.""" + bot = make_bot() + a, b = user(20), user(30) + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 600) + bot.store.jail_inmate(30, GUILD, 7, "poke", 10, 600) + run(matr_mod.matr_marry.callback(ctx_for(bot, a), b)) + bot.store.release_inmate(20) + run(matr_mod.matr_accept.callback(ctx_for(bot, b), None)) + assert bot.store.jail_partners_of(20) == [] + assert bot.store.partners_of(20) == [] + assert bot.store.partners_of(30) == [] + + +def test_jail_marriage_check_rejects_duplicate() -> None: + bot = make_bot() + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 600) + bot.store.jail_inmate(30, GUILD, 7, "poke", 10, 600) + bot.store.add_jail_partners(20, 30) + assert jail_mod.check_jail_marry(bot.store, 20, 30) is not None + assert jail_mod.check_jail_marry(bot.store, 20, 20) is not None + assert jail_mod.check_jail_marry(bot.store, 30, 40) is None + + +# --------------------------------------------------------------------------- +# Ephemerality: relationships die with the sentence +# --------------------------------------------------------------------------- + + +def test_sweep_releases_and_dissolves() -> None: + store = Store(":memory:") + store.jail_inmate(20, GUILD, 7, "poke", 10, 60, now=1000) + store.jail_inmate(30, GUILD, 7, "poke", 10, 60, now=1000) + store.add_jail_partners(20, 30) + + report = jail_mod.sweep_expiry(store, now=1000 + 61) + assert len(report.released) == 2 + assert store.jail_partners_of(20) == [] + assert not store.is_jailed(20, now=1061) + + # A second sweep at the same clock has nothing left to do. + assert jail_mod.sweep_expiry(store, now=1000 + 61).released == [] + + +def test_sweep_expires_orders() -> None: + store = Store(":memory:") + store.file_restraining_order(GUILD, 7, 10, 20, ttl_seconds=3600, now=1000) + report = jail_mod.sweep_expiry(store, now=1000 + 3601) + assert len(report.expired_orders) == 1 + assert report.expired_orders[0].protected == 10 + + +def test_jail_relationships_never_touch_real_tree() -> None: + """End-to-end: jail-marry inside, serve the sentence, and nothing + remains on either side.""" + bot = make_bot() + a, b = user(20), user(30) + s_a = bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 60) + bot.store.jail_inmate(30, GUILD, 7, "poke", 10, 60) + 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.jail_partners_of(20) == [30] + + report = jail_mod.sweep_expiry(bot.store, now=s_a.release_at + 1) + assert len(report.released) == 2 + assert bot.store.jail_partners_of(20) == [] + assert bot.store.partners_of(20) == [] + assert bot.store.partners_of(30) == [] + + +# --------------------------------------------------------------------------- +# Release announcements +# --------------------------------------------------------------------------- + + +def _bot_with_channel(bot, channel): + bot.get_channel = lambda cid: channel # type: ignore[method-assign] + return bot + + +def test_release_announcement_mentions_dissolutions() -> None: + bot = make_bot() + channel = SimpleNamespace(send=AsyncMock()) + _bot_with_channel(bot, channel) + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 60, now=1000) + bot.store.jail_inmate(30, GUILD, 7, "poke", 10, 60, now=1000) + bot.store.add_jail_partners(20, 30) + bot.store.add_jail_child(30, 20) + + report = jail_mod.sweep_expiry(bot.store, now=1000 + 61) + run(_announce_expiry(bot, report)) + assert channel.send.await_count == 2 + text = " ".join( + c.kwargs["embed"].description for c in channel.send.await_args_list + ) + assert "<@20>" in text and "<@30>" in text + assert "released" in text + assert "divorced" in text or "abandoned" in text + + +def test_expired_order_announcement() -> None: + bot = make_bot() + channel = SimpleNamespace(send=AsyncMock()) + _bot_with_channel(bot, channel) + bot.store.file_restraining_order( + GUILD, 7, 10, 20, ttl_seconds=3600, now=1000 + ) + report = jail_mod.sweep_expiry(bot.store, now=1000 + 3601) + run(_announce_expiry(bot, report)) + channel.send.assert_awaited_once() + assert "expired" in channel.send.call_args.kwargs["embed"].description + + +def test_announce_expiry_skips_missing_channels() -> None: + bot = make_bot() + bot.get_channel = lambda cid: None # type: ignore[method-assign] + bot.store.jail_inmate(20, GUILD, 7, "poke", 10, 60, now=1000) + report = jail_mod.sweep_expiry(bot.store, now=1000 + 61) + run(_announce_expiry(bot, report)) # no crash + + +# --------------------------------------------------------------------------- +# `matr clear` +# --------------------------------------------------------------------------- + + +def _seed_guild(bot, guild_id: int, base: int) -> None: + """One of every guild-scoped record, tagged by guild_id.""" + store = bot.store + store.set_prefix(guild_id, "?") + store.create_proposal(guild_id, 7, base, base + 1, "marry", 300) + store.create_notice(guild_id, 7, base, base + 2, base + 3) + store.file_restraining_order( + guild_id, 7, base, base + 4, ttl_seconds=3600 + ) + store.jail_inmate(base + 5, guild_id, 7, "poke", base, 600) + store.jail_inmate(base + 6, guild_id, 7, "poke", base, 600) + store.add_jail_partners(base + 5, base + 6) + + +def test_clear_requires_manage_guild() -> None: + bot = make_bot() + member = user(1) + ctx = ctx_for(bot, member) + ctx.interaction = None + ctx.permissions = SimpleNamespace(manage_guild=False) + with pytest.raises(matr_mod.commands.MissingPermissions): + run(matr_mod.matr_clear.can_run(ctx)) + + ctx.permissions = SimpleNamespace(manage_guild=True) + assert run(matr_mod.matr_clear.can_run(ctx)) is True + + +def test_clear_slash_requires_manage_guild() -> None: + bot = make_bot() + admin = bot.tree.get_command("matr").get_command("admin") + perms = admin.get_command("clear").default_permissions + assert perms is not None and perms.manage_guild + + +def test_clear_shows_warning_first() -> None: + bot = make_bot() + _seed_guild(bot, GUILD, 500) + admin = user(1) + admin.guild_permissions = SimpleNamespace(manage_guild=True) + ctx = ctx_for(bot, admin) + run(matr_mod.matr_clear.callback(ctx)) + view = ctx.reply.call_args.kwargs["view"] + assert isinstance(view, ClearConfirmView) + # Nothing cleared yet. + assert bot.store.get_prefix(GUILD) == "?" + assert bot.store.is_jailed(505) + + +def test_clear_confirm_resets_only_that_guild() -> None: + bot = make_bot() + _seed_guild(bot, GUILD, 500) + _seed_guild(bot, 43, 600) + bot.store.add_partners(600, 601) # global: must survive + + counts = bot.store.clear_guild(GUILD) + assert counts["guild_settings"] == 1 + assert counts["proposals"] == 1 + assert counts["notices"] == 1 + assert counts["restraining_orders"] == 1 + assert counts["jail_sentences"] == 2 + assert counts["jail_partners"] == 1 + + # Guild 42 is gone... + assert bot.store.get_prefix(GUILD) is None + assert bot.store.proposals_for(501) == [] + assert bot.store.restraining_order(500, 504) is None + assert not bot.store.is_jailed(505) + assert bot.store.jail_partners_of(505) == [] + + # ...guild 43 is untouched... + assert bot.store.get_prefix(43) == "?" + assert len(bot.store.proposals_for(601)) == 1 + assert bot.store.restraining_order(600, 604) is not None + assert bot.store.is_jailed(605) + assert bot.store.jail_partners_of(605) == [606] + + # ...and global relationships survive. + assert bot.store.partners_of(600) == [601] + + +def test_clear_confirm_via_view() -> None: + bot = make_bot() + _seed_guild(bot, GUILD, 500) + view = ClearConfirmView(invoker_id=1, guild_id=GUILD) + confirm = [i for i in view.children if i.label == "Confirm reset"][0] + run(confirm.callback(interaction(bot, 1))) + assert bot.store.get_prefix(GUILD) is None + + +def test_clear_cancel_clears_nothing() -> None: + bot = make_bot() + _seed_guild(bot, GUILD, 500) + view = ClearConfirmView(invoker_id=1, guild_id=GUILD) + cancel = [i for i in view.children if i.label == "Cancel"][0] + run(cancel.callback(interaction(bot, 1))) + assert bot.store.get_prefix(GUILD) == "?" + assert bot.store.is_jailed(505) + + +def test_clear_timeout_clears_nothing_and_disables() -> None: + bot = make_bot() + _seed_guild(bot, GUILD, 500) + view = ClearConfirmView(invoker_id=1, guild_id=GUILD) + view.message = SimpleNamespace(edit=AsyncMock()) + run(view.on_timeout()) + assert all(item.disabled for item in view.children) + view.message.edit.assert_awaited_once() + assert bot.store.get_prefix(GUILD) == "?" + + +def test_clear_view_only_answers_to_invoker() -> None: + view = ClearConfirmView(invoker_id=1, guild_id=GUILD) + bot = make_bot() + other = interaction(bot, 2) + assert run(view.interaction_check(other)) is False + other.response.send_message.assert_awaited_once() + assert run(view.interaction_check(interaction(bot, 1))) is True + + +def test_slash_clear_registered() -> None: + bot = make_bot() + admin = bot.tree.get_command("matr").get_command("admin") + assert admin.get_command("clear") is not None + + +def test_slash_restrain_registered() -> None: + bot = make_bot() + assert bot.tree.get_command("matr").get_command("restrain") is not None diff --git a/tests/test_quips_slash.py b/tests/test_quips_slash.py index 9e3de5b..369c916 100644 --- a/tests/test_quips_slash.py +++ b/tests/test_quips_slash.py @@ -150,18 +150,21 @@ def test_slash_tree_layout() -> None: "accept", "decline", "partners", - "anniversary", "adopt", "block", + "restrain", ): 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["fun"].commands) == 19 + assert len(subs["admin"].commands) == 7 assert len(subs["meta"].commands) == 4 + # `anniversary` lives under fun so the root group stays within + # Discord's 25-child limit. + assert "anniversary" in {c.name for c in subs["fun"].commands} # Slash command names must be lowercase and within 32 chars. for command in group.walk_commands():