diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..752ce8f --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Required: your Discord bot token (https://discord.com/developers/applications) +MATRIMONY_TOKEN= + +# Optional configuration (defaults shown) +MATRIMONY_DEFAULT_PREFIX=! +MATRIMONY_COMMAND_NAME=matr +MATRIMONY_DB_PATH=matrimony.db +# 0 = unlimited (matches the original MarriageBot, which has no partner cap) +MATRIMONY_MAX_PARTNERS=0 +MATRIMONY_MAX_CHILDREN=0 +MATRIMONY_MAX_TREE_DEPTH=10 +# Seconds a proposal stays open (buttons + accept/decline commands) +MATRIMONY_PROPOSAL_TTL=300 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..576af2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -r requirements-dev.txt + - name: Lint + run: ruff check . + - name: Test + run: python -m pytest tests -q diff --git a/.gitignore b/.gitignore index 83972fa..1b1d522 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,9 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Local bot database +matrimony.db +*.db-journal +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbbd380 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY matrimony ./matrimony + +VOLUME /data +ENV MATRIMONY_DB_PATH=/data/matrimony.db + +CMD ["python", "-m", "matrimony"] diff --git a/README.md b/README.md index 0ba7802..bdfbb1e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,174 @@ -# matrimony-bot -Matrimony — a MarriageBot-style Discord bot: marriages, divorces, adoptions, family trees, and custom prefixes (!matr <action> <params>). +# Matrimony + +A MarriageBot-style family-tree bot for Discord, written in Python with +[discord.py](https://github.com/Rapptz/discord.py). Users can marry, divorce, +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 `!`): + +``` +!matr +``` + +## Features + +- **Marriages** — propose, accept/decline (buttons *or* text commands), + divorce, multiple simultaneous partners. +- **Parentage** — `adopt` (they become your child) and `makeparent` (they + become your parent), `disown`, `disownall`, `emancipate`/`runaway`, + `abandon` (wipe your whole family). +- **Family trees** — `tree` (blood relatives) and `fulltree` (partners + included), rendered as an inline text tree, plus `parent`, `children`, + `siblings`, `familysize`, `relationship`. +- **Global trees** — like the original MarriageBot, family relationships are + stored globally and follow users across every server the bot is in. +- **Custom prefixes** — per-guild prefix (`!matr prefix ?` → `?matr marry`). +- **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. +- **Admin force commands** — `forcemarry`, `forcedivorce`, `forceadopt`, + `forceemancipate` (Manage Server). +- **Fun commands** — `hug`, `kiss`, `slap`, `punch`, `bite`, `stab`, `ship`. +- **Meta** — `info`, `invite`, `stats`, `help`. + +## Bot accounts are first-class + +Unlike upstream MarriageBot (which refuses to let bots take part), Matrimony +explicitly supports bot-authored commands and bot targets: + +- Humans can marry/adopt bots, bots can marry/adopt humans, and bots can + marry/adopt each other. +- The message listener does **not** ignore `message.author.bot`; other bots' + command messages are dispatched exactly like human ones. +- The only exclusion is Matrimony's *own* messages — enough to prevent + self-command loops while leaving every other bot free to participate. +- Covered by tests: proposal/accept flows driven by bot-authored commands + (`tests/test_dispatch.py`). + +## Command reference + +| Command | Description | +| --- | --- | +| `!matr help` | Show the command list | +| `!matr marry @user` | Propose to a user or bot | +| `!matr divorce [@user]` | Divorce a partner (required if you have several) | +| `!matr accept [@proposer]` / `decline` | Answer a pending proposal | +| `!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 | +| `!matr disown @child` / `disownall` | Remove one/all children | +| `!matr emancipate` (alias `runaway`) | Remove your parent | +| `!matr abandon` | Remove all your family ties | +| `!matr tree [@user]` / `fulltree [@user]` | Blood / full family tree | +| `!matr parent` / `children` / `siblings` [@user] | Relatives at a glance | +| `!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 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 hug`/`kiss`/`slap`/`punch`/`bite`/`stab @user` | Flavour actions | +| `!matr ship @a [@b]` | Deterministic ship percentage | +| `!matr info` / `invite` / `stats` | Bot information | + +`@Matrimony matr ...` (mention prefix) also always works. + +## Running + +### Requirements + +- Python 3.10+ +- A Discord application + bot token () +- Privileged **Message Content** and **Server Members** intents enabled in the + developer portal (required for prefix commands and member lookups) + +### Local + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # fill in MATRIMONY_TOKEN +export $(grep -v '^#' .env | xargs) +python -m matrimony +``` + +### Docker + +```bash +docker build -t matrimony . +docker run -e MATRIMONY_TOKEN=... -v matrimony-data:/data matrimony +``` + +### Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `MATRIMONY_TOKEN` | *(required)* | Discord bot token | +| `MATRIMONY_DEFAULT_PREFIX` | `!` | Fallback prefix for guilds/DMs | +| `MATRIMONY_COMMAND_NAME` | `matr` | The command group name (`!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) | +| `MATRIMONY_MAX_TREE_DEPTH` | `10` | Depth limit when rendering trees | +| `MATRIMONY_PROPOSAL_TTL` | `300` | Seconds before a proposal expires | + +## Fidelity to the original MarriageBot + +Matrimony replicates the original's mechanics as closely as is practical +(sources below): + +- Same command surface: marry/divorce/partners, adopt/makeparent/disown/ + disownall/emancipate/runaway/abandon, tree/fulltree/parent/children/ + siblings/familysize/relationship, block/unblock, incest toggle, force + commands, fun commands, info/invite/stats. +- **Cross-server global family trees.** +- **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). +- `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 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 + it deliberately. +- Upstream now primarily uses slash commands; Matrimony uses the requested + `!matr ` prefix-command surface (plus mention-prefix support). +- 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`, + `runaway` alias kept as `emancipate` alias) are out of scope. + +**Sources:** + +- [Voxel-Fox-Ltd/MarriageBot](https://github.com/Voxel-Fox-Ltd/MarriageBot) — + original bot source (Python, "cross-server family trees", command list). +- [top.gg command listing](https://top.gg/bot/468281173072805889/commands) + and [alternative.me command listing](https://alternative.me/discord/bots/marriagebot/commands) — + upstream command descriptions including `incest`, `disownall`, `abandon`, + `makeparent`, and force commands. +- [docs.marriagebot.xyz](https://docs.marriagebot.xyz/) — upstream docs. + +## Development + +```bash +pip install -r requirements-dev.txt +python -m pytest tests # unit tests (no Discord connection needed) +ruff check . # lint +``` + +Storage is SQLite (`matrimony/store.py`); family-graph logic +(`matrimony/family.py`) is Discord-free and fully unit-tested. CI runs lint + +tests on Python 3.11/3.12. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/matrimony/__init__.py b/matrimony/__init__.py new file mode 100644 index 0000000..2c48871 --- /dev/null +++ b/matrimony/__init__.py @@ -0,0 +1,3 @@ +"""Matrimony - a MarriageBot-style family-tree Discord bot.""" + +__version__ = "0.1.0" diff --git a/matrimony/__main__.py b/matrimony/__main__.py new file mode 100644 index 0000000..f3f51ef --- /dev/null +++ b/matrimony/__main__.py @@ -0,0 +1,6 @@ +"""Entrypoint: ``python -m matrimony``.""" + +from .bot import run + +if __name__ == "__main__": + run() diff --git a/matrimony/bot.py b/matrimony/bot.py new file mode 100644 index 0000000..fee55ee --- /dev/null +++ b/matrimony/bot.py @@ -0,0 +1,88 @@ +"""The Matrimony bot client.""" + +from __future__ import annotations + +import logging + +import discord +from discord.ext import commands + +from .config import Config +from .family import FamilyView +from .store import Store + +log = logging.getLogger("matrimony") + + +class MatrimonyBot(commands.Bot): + """commands.Bot subclass wiring together config, store and cogs. + + Deliberately does **not** ignore messages from other bot accounts: + marrying/adopting bots is a supported feature, so bot-authored command + messages are dispatched like any other. Only messages authored by this + bot itself are skipped, which is enough to prevent self-command loops. + """ + + def __init__(self, config: Config, store: Store | None = None) -> None: + intents = discord.Intents.default() + intents.members = True + intents.message_content = True + super().__init__( + command_prefix=self._get_prefix, + intents=intents, + case_insensitive=True, + help_command=None, + ) + self.config = config + self.store = store or Store(config.database_path) + self.family = FamilyView(self.store) + + # ------------------------------------------------------------- prefixes + + async def _get_prefix(self, bot: MatrimonyBot, message: discord.Message): + """Per-guild prefix; falls back to the configured default. + + ``commands.when_mentioned`` support is kept so ``@Matrimony matr ...`` + always works regardless of prefix.""" + prefix = self.config.default_prefix + if message.guild is not None: + prefix = self.store.get_prefix(message.guild.id) or prefix + return commands.when_mentioned_or(prefix)(bot, message) + + # ----------------------------------------------------------- dispatching + + def should_process(self, message: discord.Message) -> bool: + """Whether ``message`` should be run through the command parser. + + Returns False only for this bot's own messages (self-loop guard). + Other bots' messages ARE processed so that bot accounts can propose, + accept, and adopt like human users.""" + return self.user is None or message.author.id != self.user.id + + async def on_message(self, message: discord.Message) -> None: + if not self.should_process(message): + return + await self.process_commands(message) + + # --------------------------------------------------------------- setup + + async def setup_hook(self) -> None: + from .cogs import ALL_COGS + + for cog in ALL_COGS: + await self.add_cog(cog(self)) + log.info("Loaded %d cogs", len(ALL_COGS)) + + +def run() -> None: + """Console entrypoint: ``python -m matrimony``.""" + logging.basicConfig(level=logging.INFO) + config = Config.from_env() + if not config.token: + raise SystemExit( + "MATRIMONY_TOKEN is not set. Copy .env.example to .env, fill in " + "your Discord bot token, and export it (or use a process manager " + "that loads .env)." + ) + bot = MatrimonyBot(config) + bot.run(config.token) diff --git a/matrimony/cogs/__init__.py b/matrimony/cogs/__init__.py new file mode 100644 index 0000000..be69b11 --- /dev/null +++ b/matrimony/cogs/__init__.py @@ -0,0 +1,5 @@ +"""Cog registration for Matrimony.""" + +from .matr import MatrCog + +ALL_COGS = [MatrCog] diff --git a/matrimony/cogs/matr.py b/matrimony/cogs/matr.py new file mode 100644 index 0000000..cd24fa9 --- /dev/null +++ b/matrimony/cogs/matr.py @@ -0,0 +1,892 @@ +"""The ``matr`` command group: all of Matrimony's commands. + +Commands are module-level functions attached to a shared ``matr`` Group so +the file stays easy to navigate; ``MatrCog`` registers the group (under the +configured command name) and owns the error handler. +""" + +from __future__ import annotations + +import hashlib + +import discord +from discord.ext import commands + +from .. import __version__ +from ..bot import MatrimonyBot +from ..store import Proposal + +GREEN = discord.Color(0x2ECC71) +RED = discord.Color(0xE74C3C) +GOLD = discord.Color(0xF1C40F) + + +def _bot(ctx: commands.Context) -> MatrimonyBot: + return ctx.bot # type: ignore[return-value] + + +def _embed(text: str, colour: discord.Color = GREEN) -> discord.Embed: + return discord.Embed(description=text, colour=colour) + + +async def _say(ctx: commands.Context, text: str, ok: bool = True) -> None: + await ctx.reply(embed=_embed(text, GREEN if ok else RED)) + + +def _display(user: discord.User | discord.Member | None, author_id: int) -> str: + """Friendly name for command output.""" + if user is None: + return "you" + return user.display_name if isinstance(user, discord.Member) else user.name + + +def _name_of(bot: MatrimonyBot, guild: discord.Guild | None): + """Resolve a user id to a display name for tree rendering.""" + + def name_of(uid: int) -> str: + if guild is not None: + member = guild.get_member(uid) + if member is not None: + return member.display_name + user = bot.get_user(uid) + return user.display_name if user is not None else f"<@{uid}>" + + return name_of + + +async def _respond(ctx_or_interaction, text: str, ok: bool) -> None: + emb = _embed(text, GREEN if ok else RED) + if isinstance(ctx_or_interaction, discord.Interaction): + if ctx_or_interaction.response.is_done(): + await ctx_or_interaction.followup.send(embed=emb) + else: + await ctx_or_interaction.response.send_message(embed=emb) + else: + await ctx_or_interaction.reply(embed=emb) + + +# --------------------------------------------------------------------------- +# Proposal flow +# --------------------------------------------------------------------------- + +PROPOSAL_VERBS = { + "marry": "marry", + "adopt": "adopt", + "makeparent": "become the parent of", +} + + +class ProposalView(discord.ui.View): + """Accept/decline buttons attached to a proposal message.""" + + def __init__(self, proposal: Proposal, timeout: float = 300) -> None: + super().__init__(timeout=timeout) + self.proposal = proposal + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.proposal.target: + await interaction.response.send_message( + "This proposal 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_proposal( + interaction.client, interaction, self.proposal.id, accepted=True + ) + self.stop() + + @discord.ui.button(label="Decline", style=discord.ButtonStyle.danger) + async def decline( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await _resolve_proposal( + interaction.client, interaction, self.proposal.id, accepted=False + ) + self.stop() + + +async def _resolve_proposal( + bot: MatrimonyBot, ctx_or_interaction, proposal_id: int, *, accepted: bool +) -> bool: + """Resolve a proposal by id. Returns False if it no longer exists.""" + proposal = bot.store.get_proposal(proposal_id) + if proposal is None: + await _respond(ctx_or_interaction, "That proposal no longer exists.", False) + return False + bot.store.delete_proposal(proposal_id) + if not accepted: + await _respond(ctx_or_interaction, "Proposal declined.", False) + return True + await _enact(bot, proposal, ctx_or_interaction) + return True + + +async def _enact(bot: MatrimonyBot, proposal: Proposal, ctx_or_int) -> None: + """Apply a proposal's effect to the family tree.""" + proposer = _name_of(bot, None)(proposal.proposer) + target = _name_of(bot, None)(proposal.target) + if proposal.kind == "marry": + bot.store.add_partners(proposal.proposer, proposal.target) + await _respond( + ctx_or_int, + f"{proposer} and {target} are now married! :tada:", + True, + ) + elif proposal.kind == "adopt": + # proposer adopts target as their child + if bot.store.add_child(proposal.proposer, proposal.target): + await _respond( + ctx_or_int, f"{target} is now the child of {proposer}.", True + ) + else: + await _respond(ctx_or_int, "They already have a parent.", False) + elif proposal.kind == "makeparent": + # proposer asked target to become THEIR parent + if bot.store.add_child(proposal.target, proposal.proposer): + await _respond( + ctx_or_int, f"{target} is now the parent of {proposer}.", True + ) + else: + await _respond(ctx_or_int, "They already have a parent.", False) + + +async def _send_proposal( + ctx: commands.Context, + target: discord.User | discord.Member, + kind: str, + error: str | None, +) -> None: + """Shared entry for marry/adopt/makeparent.""" + 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, + ctx.author.id, + target.id, + kind, + bot.config.proposal_ttl_seconds, + ) + if proposal is None: + await _say(ctx, "You already have a pending proposal for them.", ok=False) + return + verb = PROPOSAL_VERBS[kind] + cmd = bot.config.command_name + 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.", + GOLD, + ), + view=ProposalView(proposal, timeout=bot.config.proposal_ttl_seconds), + ) + + +async def _pick_proposal( + 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) + 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) + return None + if len(pending) > 1: + await _say( + ctx, + "You have multiple pending proposals - mention the proposer to " + "pick one.", + ok=False, + ) + return None + return pending[0] + + +def _help_embed(prefix: str, cmd: str) -> discord.Embed: + return discord.Embed( + title="Matrimony", + description=( + f"MarriageBot-style family trees. Commands: `{prefix}{cmd} " + "`.\n\n" + "**Love**\n" + "`marry @user` propose - `divorce [@user]` - " + "`accept`/`decline [@user]` - `partners [@user]`\n" + "**Parentage**\n" + "`adopt @user` - `makeparent @user` - `disown @child` - " + "`disownall` - `emancipate` - `abandon`\n" + "**Trees**\n" + "`tree [@user]` - `fulltree [@user]` - `parent [@user]` - " + "`children [@user]` - `siblings [@user]` - `familysize [@user]` - " + "`relationship @a @b`\n" + "**Blocks**\n" + "`block @user` - `unblock @user` - `blocked`\n" + "**Server** *(Manage Server)*\n" + "`prefix ` - `incest` - `forcemarry @a @b` - " + "`forcedivorce @a [@b]` - `forceadopt @parent @child` - " + "`forceemancipate @user`\n" + "**Fun**\n" + "`hug`/`kiss`/`slap`/`punch`/`bite`/`stab @user` - `ship @a @b`\n" + "**Meta**\n" + "`info` - `invite` - `stats`\n\n" + "Family trees are global across servers, and bot accounts can " + "use commands and be married or adopted." + ), + colour=GOLD, + ) + + +# --------------------------------------------------------------------------- +# The group +# --------------------------------------------------------------------------- + + +@commands.group( + name="matr", + invoke_without_command=True, + help="Matrimony family-tree commands.", +) +async def matr(ctx: commands.Context) -> None: + """Bare ``!matr`` shows help.""" + bot = _bot(ctx) + await ctx.reply( + embed=_help_embed(ctx.prefix or "!", bot.config.command_name) + ) + + +@matr.command(name="help") +async def matr_help(ctx: commands.Context) -> None: + bot = _bot(ctx) + await ctx.reply( + embed=_help_embed(ctx.prefix or "!", bot.config.command_name) + ) + + +# --------------------------------------------------------------------------- +# Marriage +# --------------------------------------------------------------------------- + + +@matr.command(name="marry", aliases=["propose"]) +@commands.guild_only() +async def matr_marry(ctx: commands.Context, user: discord.User) -> None: + """Propose marriage to another user - or bot.""" + bot = _bot(ctx) + error = bot.family.check_marry( + ctx.author.id, + user.id, + allow_incest=bot.store.get_allow_incest(ctx.guild.id), + max_partners=bot.config.max_partners, + ) + await _send_proposal(ctx, user, "marry", error) + + +@matr.command(name="divorce") +@commands.guild_only() +async def matr_divorce( + ctx: commands.Context, user: discord.User | None = None +) -> None: + """Divorce a partner. Required if you have several.""" + 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 to divorce.", + ok=False, + ) + return + else: + target = user.id + if target not in partners: + await _say(ctx, "You're not married to them.", ok=False) + return + bot.store.remove_partners(ctx.author.id, target) + await _say( + ctx, + f"{ctx.author.mention} and <@{target}> are now divorced. " + ":broken_heart:", + ) + + +@matr.command(name="accept") +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) + + +@matr.command(name="decline", aliases=["deny"]) +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) + + +@matr.command(name="partners", aliases=["spouse", "spouses", "partner"]) +async def matr_partners( + ctx: commands.Context, user: discord.User | None = None +) -> None: + """Show a user's partners (defaults to you).""" + bot = _bot(ctx) + uid = user.id if user else ctx.author.id + partners = bot.store.partners_of(uid) + if not partners: + who = "You have" if user is None else f"{_display(user, ctx.author.id)} has" + await _say(ctx, f"{who} no partners.", ok=False) + return + listing = ", ".join(f"<@{p}>" for p in partners) + whose = "Your" if user is None else f"{_display(user, ctx.author.id)}'s" + await _say(ctx, f"{whose} partners: {listing}") + + +# --------------------------------------------------------------------------- +# Parentage +# --------------------------------------------------------------------------- + + +@matr.command(name="adopt") +@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) + error = bot.family.check_adopt( + ctx.author.id, + user.id, + allow_incest=bot.store.get_allow_incest(ctx.guild.id), + max_children=bot.config.max_children, + ) + await _send_proposal(ctx, user, "adopt", error) + + +@matr.command(name="makeparent") +@commands.guild_only() +async def matr_makeparent(ctx: commands.Context, user: discord.User) -> None: + """Ask a user to become YOUR parent.""" + bot = _bot(ctx) + error = bot.family.check_adopt( + user.id, # target becomes the parent + ctx.author.id, # you become the child + allow_incest=bot.store.get_allow_incest(ctx.guild.id), + max_children=bot.config.max_children, + ) + await _send_proposal(ctx, user, "makeparent", error) + + +@matr.command(name="disown") +@commands.guild_only() +async def matr_disown(ctx: commands.Context, child: discord.User) -> None: + """Disown one of your children.""" + bot = _bot(ctx) + if bot.store.parent_of(child.id) != ctx.author.id: + await _say(ctx, "They're not your child.", ok=False) + return + bot.store.remove_child(child.id) + await _say(ctx, f"You've disowned {child.mention}.") + + +@matr.command(name="disownall") +@commands.guild_only() +async def matr_disownall(ctx: commands.Context) -> None: + """Disown all of your children at once.""" + bot = _bot(ctx) + children = bot.store.children_of(ctx.author.id) + if not children: + await _say(ctx, "You have no children.", ok=False) + return + for c in children: + bot.store.remove_child(c) + await _say(ctx, f"Disowned {len(children)} children. Cold.") + + +@matr.command(name="emancipate", aliases=["runaway"]) +@commands.guild_only() +async def matr_emancipate(ctx: commands.Context) -> None: + """Remove your parent (run away from home).""" + bot = _bot(ctx) + parent = bot.store.parent_of(ctx.author.id) + if parent is None: + await _say(ctx, "You don't have a parent.", ok=False) + return + bot.store.remove_child(ctx.author.id) + await _say(ctx, f"You're now free of <@{parent}>. Go forth.") + + +@matr.command(name="abandon") +@commands.guild_only() +async def matr_abandon(ctx: commands.Context) -> None: + """Remove ALL your family ties: partners, parent and children.""" + bot = _bot(ctx) + uid = ctx.author.id + partners = bot.store.partners_of(uid) + children = bot.store.children_of(uid) + parent = bot.store.parent_of(uid) + if not partners and not children and parent is None: + await _say(ctx, "You have no family to abandon.", ok=False) + return + for p in partners: + bot.store.remove_partners(uid, p) + for c in children: + bot.store.remove_child(c) + if parent is not None: + bot.store.remove_child(uid) + await _say( + ctx, + f"{ctx.author.mention} abandoned their whole family " + f"({len(partners)} partner(s), {len(children)} child(ren), " + f"{1 if parent is not None else 0} parent).", + ) + + +# --------------------------------------------------------------------------- +# Trees and info +# --------------------------------------------------------------------------- + + +@matr.command(name="tree") +async def matr_tree(ctx: commands.Context, user: discord.User | None = None) -> None: + """Show a user's blood family tree.""" + bot = _bot(ctx) + uid = user.id if user else ctx.author.id + text = bot.family.render_tree( + uid, + _name_of(bot, ctx.guild), + include_spouses=False, + max_depth=bot.config.max_tree_depth, + ) + await ctx.reply(embed=_embed(text)) + + +@matr.command(name="fulltree") +async def matr_fulltree( + ctx: commands.Context, user: discord.User | None = None +) -> None: + """Show a user's full family tree including partners.""" + bot = _bot(ctx) + uid = user.id if user else ctx.author.id + text = bot.family.render_tree( + uid, + _name_of(bot, ctx.guild), + include_spouses=True, + max_depth=bot.config.max_tree_depth, + ) + await ctx.reply(embed=_embed(text)) + + +def _solo_line( + ctx: commands.Context, user: discord.User | None, label: str, ids: list[int] +) -> str: + who = "You have" if user is None else f"{_display(user, ctx.author.id)} has" + if not ids: + return f"{who} no {label}." + listing = ", ".join(f"<@{i}>" for i in ids) + whose = "Your" if user is None else f"{_display(user, ctx.author.id)}'s" + return f"{whose} {label}: {listing}" + + +@matr.command(name="parent") +async def matr_parent(ctx: commands.Context, user: discord.User | None = None) -> None: + """Show a user's parent.""" + bot = _bot(ctx) + uid = user.id if user else ctx.author.id + parent = bot.store.parent_of(uid) + await _say( + ctx, + _solo_line(ctx, user, "parent", [parent] if parent is not None else []), + ok=parent is not None, + ) + + +@matr.command(name="children") +async def matr_children( + ctx: commands.Context, user: discord.User | None = None +) -> None: + """Show a user's children.""" + bot = _bot(ctx) + uid = user.id if user else ctx.author.id + children = bot.store.children_of(uid) + await _say(ctx, _solo_line(ctx, user, "children", children), ok=bool(children)) + + +@matr.command(name="siblings") +async def matr_siblings( + ctx: commands.Context, user: discord.User | None = None +) -> None: + """Show a user's siblings (other children of their parent).""" + bot = _bot(ctx) + uid = user.id if user else ctx.author.id + sibs = bot.family.siblings(uid) + await _say(ctx, _solo_line(ctx, user, "siblings", sibs), ok=bool(sibs)) + + +@matr.command(name="familysize") +async def matr_familysize( + ctx: commands.Context, user: discord.User | None = None +) -> None: + """Show the number of people in a user's family tree.""" + bot = _bot(ctx) + uid = user.id if user else ctx.author.id + size = bot.store.family_size(uid) + who = "You have" if user is None else f"{_display(user, ctx.author.id)} has" + await _say(ctx, f"{who} {size} family member(s).") + + +@matr.command(name="relationship") +async def matr_relationship( + ctx: commands.Context, a: discord.User, b: discord.User +) -> None: + """Show how user B is related to user A.""" + bot = _bot(ctx) + rel = bot.family.relationship(a.id, b.id) + if rel is None: + await _say(ctx, f"{a.mention} and {b.mention} aren't related.") + elif rel == "self": + await _say(ctx, "That's... the same person.") + else: + await _say(ctx, f"{b.mention} is {a.mention}'s **{rel}**.") + + +# --------------------------------------------------------------------------- +# Blocks +# --------------------------------------------------------------------------- + + +@matr.command(name="block") +async def matr_block(ctx: commands.Context, user: discord.User) -> None: + """Block a user from sending you proposals.""" + bot = _bot(ctx) + if user.id == ctx.author.id: + await _say(ctx, "You can't block yourself.", ok=False) + return + bot.store.add_block(ctx.author.id, user.id) + await _say(ctx, f"Blocked {user.mention} from proposing to you.") + + +@matr.command(name="unblock") +async def matr_unblock(ctx: commands.Context, user: discord.User) -> None: + """Unblock a user.""" + bot = _bot(ctx) + if not bot.store.remove_block(ctx.author.id, user.id): + await _say(ctx, "They weren't blocked.", ok=False) + return + await _say(ctx, f"Unblocked {user.mention}.") + + +@matr.command(name="blocked") +async def matr_blocked(ctx: commands.Context) -> None: + """List everyone you've blocked.""" + bot = _bot(ctx) + users = bot.store.blocked_users(ctx.author.id) + if not users: + await _say(ctx, "You haven't blocked anyone.", ok=False) + return + await _say(ctx, "Blocked: " + ", ".join(f"<@{u}>" for u in users)) + + +# --------------------------------------------------------------------------- +# Server configuration +# --------------------------------------------------------------------------- + + +@matr.command(name="prefix") +@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 ``!``). + + Changing it requires the Manage Server permission.""" + bot = _bot(ctx) + if new_prefix is None: + current = ( + bot.store.get_prefix(ctx.guild.id) or bot.config.default_prefix + ) + await _say( + ctx, + f"This server's prefix is `{current}` " + f"(e.g. `{current}{bot.config.command_name} marry @user`).", + ) + return + if not ctx.author.guild_permissions.manage_guild: + await _say( + ctx, + "You need the Manage Server permission to change the prefix.", + ok=False, + ) + return + if len(new_prefix) > 10 or any(c.isspace() for c in new_prefix): + await _say(ctx, "Prefixes must be <=10 chars with no spaces.", ok=False) + return + bot.store.set_prefix(ctx.guild.id, new_prefix) + await _say( + ctx, + f"Prefix set to `{new_prefix}`. " + f"Try `{new_prefix}{bot.config.command_name} help`.", + ) + + +@matr.command(name="incest") +@commands.guild_only() +@commands.has_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) + current = bot.store.get_allow_incest(ctx.guild.id) + bot.store.set_allow_incest(ctx.guild.id, not current) + await _say( + ctx, + "Incest proposals are now **" + + ("allowed" if not current else "disallowed") + + "** in this server.", + ) + + +# --------------------------------------------------------------------------- +# Admin force commands +# --------------------------------------------------------------------------- + + +@matr.command(name="forcemarry") +@commands.guild_only() +@commands.has_permissions(manage_guild=True) +async def matr_forcemarry( + ctx: commands.Context, a: discord.User, b: discord.User +) -> None: + """Force-marry two users (no proposal needed).""" + bot = _bot(ctx) + if a.id == b.id: + await _say(ctx, "A user can't marry themselves.", ok=False) + return + bot.store.add_partners(a.id, b.id) + await _say(ctx, f"{a.mention} and {b.mention} are now married. :tada:") + + +@matr.command(name="forcedivorce") +@commands.guild_only() +@commands.has_permissions(manage_guild=True) +async def matr_forcedivorce( + ctx: commands.Context, a: discord.User, b: discord.User | None = None +) -> None: + """Force-divorce a pair (or all of A's partners if B omitted).""" + bot = _bot(ctx) + if b is None: + partners = bot.store.partners_of(a.id) + if not partners: + await _say(ctx, "They have no partners.", ok=False) + return + for p in partners: + bot.store.remove_partners(a.id, p) + await _say(ctx, f"Divorced {a.mention} from {len(partners)} partner(s).") + return + if not bot.store.remove_partners(a.id, b.id): + await _say(ctx, "They aren't married.", ok=False) + return + await _say(ctx, f"Divorced {a.mention} and {b.mention}.") + + +@matr.command(name="forceadopt") +@commands.guild_only() +@commands.has_permissions(manage_guild=True) +async def matr_forceadopt( + ctx: commands.Context, parent: discord.User, child: discord.User +) -> None: + """Force ``child`` to become ``parent``'s child. + + Skips consent/blocks/relation checks but never the structural rules + (no self-adoption, no cycles, one parent per child).""" + bot = _bot(ctx) + if parent.id == child.id: + await _say(ctx, "A user can't adopt themselves.", ok=False) + return + existing = bot.store.parent_of(child.id) + if existing is not None: + await _say(ctx, "They already have a parent.", ok=False) + return + if ( + child.id in bot.family.ancestors(parent.id) + or parent.id in bot.family.ancestors(child.id) + ): + await _say(ctx, "That would create a family loop.", ok=False) + return + if bot.store.add_child(parent.id, child.id): + await _say(ctx, f"{child.mention} is now {parent.mention}'s child.") + else: + await _say(ctx, "They already have a parent.", ok=False) + + +@matr.command(name="forceemancipate") +@commands.guild_only() +@commands.has_permissions(manage_guild=True) +async def matr_forceemancipate(ctx: commands.Context, child: discord.User) -> None: + """Force-remove a user's parent.""" + bot = _bot(ctx) + if bot.store.parent_of(child.id) is None: + await _say(ctx, "They have no parent.", ok=False) + return + bot.store.remove_child(child.id) + await _say(ctx, f"Emancipated {child.mention}.") + + +# --------------------------------------------------------------------------- +# Fun (simulation) commands +# --------------------------------------------------------------------------- + +_FUN_VERBS = { + "hug": ("hugs", ":hugging:"), + "kiss": ("kisses", ":kissing_heart:"), + "slap": ("slaps", ":raised_back_of_hand:"), + "punch": ("punches", ":punch:"), + "bite": ("bites", ":tooth:"), + "stab": ("stabs", ":dagger:"), +} + + +def _make_fun(name: str) -> None: + verb, emoji = _FUN_VERBS[name] + + @matr.command(name=name) + async def _fun(ctx: commands.Context, user: discord.User) -> None: + if user.id == ctx.author.id: + await _say(ctx, f"You {verb} yourself. Okay then. {emoji}") + else: + await _say(ctx, f"{ctx.author.mention} {verb} {user.mention}! {emoji}") + + _fun.__doc__ = f"{name.capitalize()} another user." + + +for _verb_name in _FUN_VERBS: + _make_fun(_verb_name) + + +@matr.command(name="ship") +async def matr_ship( + ctx: commands.Context, a: discord.User, b: discord.User | None = None +) -> None: + """Show a (deterministic) ship percentage between two users.""" + b = b or ctx.author + lo, hi = sorted((a.id, b.id)) + pct = int(hashlib.md5(f"{lo}:{hi}".encode()).hexdigest()[:6], 16) % 101 + await _say(ctx, f"{a.mention} x {b.mention}: **{pct}%** :heart:") + + +# --------------------------------------------------------------------------- +# Meta +# --------------------------------------------------------------------------- + + +@matr.command(name="info") +async def matr_info(ctx: commands.Context) -> None: + """Bot information and links.""" + bot = _bot(ctx) + await ctx.reply( + embed=discord.Embed( + title="Matrimony", + description=( + "A MarriageBot-style family-tree bot: marriages, divorces, " + "adoptions and global family trees. Works for humans and " + "bots alike.\n\n" + f"Commands: `{ctx.prefix}{bot.config.command_name} help`\n" + "Source: https://github.com/CommunityPokeOrg/matrimony" + ), + colour=GOLD, + ).set_footer(text=f"Matrimony v{__version__} - discord.py v{discord.__version__}") + ) + + +@matr.command(name="invite") +async def matr_invite(ctx: commands.Context) -> None: + """Get the bot's invite link.""" + bot = _bot(ctx) + if bot.user is None: + await _say(ctx, "Not connected yet.", ok=False) + return + url = discord.utils.oauth_url( + bot.user.id, + permissions=discord.Permissions( + send_messages=True, + embed_links=True, + read_message_history=True, + add_reactions=True, + ), + ) + await _say(ctx, f"[Invite Matrimony]({url})") + + +@matr.command(name="stats") +async def matr_stats(ctx: commands.Context) -> None: + """Bot statistics.""" + bot = _bot(ctx) + s = bot.store.stats() + await ctx.reply( + embed=discord.Embed( + title="Matrimony stats", + colour=GOLD, + description=( + 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']}**" + ), + ) + ) + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + + +class MatrCog(commands.Cog): + def __init__(self, bot: MatrimonyBot) -> None: + self.bot = bot + matr.name = bot.config.command_name + bot.add_command(matr) + + @commands.Cog.listener() + async def on_command_error( + self, ctx: commands.Context, error: commands.CommandError + ) -> None: + error = getattr(error, "original", error) + if isinstance(error, commands.CommandNotFound): + # Unknown `!matr ` -> show help instead of silence. + content = ctx.message.content.split() + if len(content) >= 2 and content[0].endswith(self.bot.config.command_name): + await ctx.reply( + content="Unknown action.", + embed=_help_embed(ctx.prefix or "!", self.bot.config.command_name), + ) + return + if isinstance(error, commands.MissingRequiredArgument): + await _say(ctx, f"Missing argument: `{error.param.name}`.", False) + elif isinstance(error, commands.BadArgument): + await _say(ctx, str(error), False) + elif isinstance(error, commands.MissingPermissions): + await _say( + ctx, "You need the Manage Server permission for that.", False + ) + elif isinstance(error, commands.NoPrivateMessage): + await _say(ctx, "That command only works in a server.", False) + elif isinstance(error, commands.CheckFailure): + await _say(ctx, "You can't use that command.", False) + else: + await _say(ctx, "Something went wrong running that command.", False) + raise error diff --git a/matrimony/config.py b/matrimony/config.py new file mode 100644 index 0000000..bc5859c --- /dev/null +++ b/matrimony/config.py @@ -0,0 +1,48 @@ +"""Environment-driven configuration for Matrimony.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Config: + """Runtime configuration, read from environment variables. + + Attributes: + token: Discord bot token (required). + default_prefix: Fallback command prefix for guilds/DMs without a custom + prefix. The command *name* stays fixed (``matr`` by default); only + the leading sigil is customised. + command_name: Name of the command group, e.g. ``!matr ...``. + database_path: SQLite database file location. + max_partners: Maximum simultaneous partners per user. 0 = unlimited + (MarriageBot itself has no partner cap). + 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. + """ + + token: str = "" + default_prefix: str = "!" + command_name: str = "matr" + database_path: str = "matrimony.db" + max_partners: int = 0 + max_children: int = 0 + max_tree_depth: int = 10 + proposal_ttl_seconds: int = 300 + + @classmethod + def from_env(cls, env: dict[str, str] | None = None) -> Config: + env = os.environ if env is None else env + return cls( + token=env.get("MATRIMONY_TOKEN", ""), + default_prefix=env.get("MATRIMONY_DEFAULT_PREFIX", "!"), + command_name=env.get("MATRIMONY_COMMAND_NAME", "matr"), + database_path=env.get("MATRIMONY_DB_PATH", "matrimony.db"), + max_partners=int(env.get("MATRIMONY_MAX_PARTNERS", "0")), + 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")), + ) diff --git a/matrimony/family.py b/matrimony/family.py new file mode 100644 index 0000000..7205778 --- /dev/null +++ b/matrimony/family.py @@ -0,0 +1,268 @@ +"""Pure family-graph logic for Matrimony. + +Everything in this module is independent of discord.py: a ``FamilyView`` +wraps a ``Store`` and exposes ancestors/descendants, validation rules for +proposals, relationship naming, and ASCII tree rendering. Keeping this free +of Discord types makes it fully unit-testable. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from .store import Store + +NameFn = Callable[[int], str] + +# --------------------------------------------------------------------------- +# Relationship labels +# --------------------------------------------------------------------------- + +GRANDPARENT_PREFIX = {2: "grand", 3: "great-grand"} + + +def _ancestor_label(depth: int) -> str: + if depth == 1: + return "parent" + if depth == 2: + return "grandparent" + if depth == 3: + return "great-grandparent" + return f"great-x{depth - 2} grandparent" + + +def _descendant_label(depth: int) -> str: + if depth == 1: + return "child" + if depth == 2: + return "grandchild" + if depth == 3: + return "great-grandchild" + return f"great-x{depth - 2} grandchild" + + +class FamilyView: + def __init__(self, store: Store) -> None: + self.store = store + + # ------------------------------------------------------------ traversal + + def ancestors(self, user_id: int, max_depth: int = 100) -> dict[int, int]: + """All ancestors of ``user_id`` as ``{ancestor_id: depth}``. + + Depth 1 = parent, 2 = grandparent, etc. Cycle-safe.""" + found: dict[int, int] = {} + frontier = [user_id] + depth = 0 + while frontier and depth < max_depth: + depth += 1 + next_frontier: list[int] = [] + for node in frontier: + parent = self.store.parent_of(node) + if parent is not None and parent not in found and parent != user_id: + found[parent] = depth + next_frontier.append(parent) + frontier = next_frontier + return found + + def descendants(self, user_id: int, max_depth: int = 100) -> dict[int, int]: + """All descendants of ``user_id`` as ``{descendant_id: depth}``.""" + found: dict[int, int] = {} + frontier = [user_id] + depth = 0 + while frontier and depth < max_depth: + depth += 1 + next_frontier = [] + for node in frontier: + for child in self.store.children_of(node): + if child not in found and child != user_id: + found[child] = depth + next_frontier.append(child) + frontier = next_frontier + return found + + def siblings(self, user_id: int) -> list[int]: + parent = self.store.parent_of(user_id) + if parent is None: + return [] + return [c for c in self.store.children_of(parent) if c != user_id] + + def blood_related(self, a: int, b: int) -> bool: + """True if ``a`` and ``b`` share any blood relation: one is an + ancestor/descendant of the other, or they share a common ancestor + (siblings, cousins, ...).""" + if a == b: + return True + if b in self.ancestors(a) or a in self.ancestors(b): + return True + return bool(set(self.ancestors(a)) & set(self.ancestors(b))) + + def relationship(self, a: int, b: int) -> str | None: + """Human-readable label of how ``b`` relates to ``a``, or None.""" + if a == b: + return "self" + if b in self.store.partners_of(a): + return "partner" + if self.store.parent_of(a) == b: + return "parent" + if self.store.parent_of(b) == a: + return "child" + if b in self.siblings(a): + return "sibling" + anc = self.ancestors(a) + if b in anc: + return _ancestor_label(anc[b]) + desc = self.descendants(a) + if b in desc: + return _descendant_label(desc[b]) + parent = self.store.parent_of(a) + if parent is not None and b in self.siblings(parent): + return "aunt/uncle" + their_parent = self.store.parent_of(b) + if their_parent is not None and a in self.siblings(their_parent): + return "niece/nephew" + if set(anc) & set(self.ancestors(b)): + return "cousin" + return None + + # ----------------------------------------------------------- validation + + def check_marry( + self, proposer: int, target: int, *, allow_incest: bool, + max_partners: int = 0, + ) -> str | None: + """Returns an error string if ``proposer`` may not marry ``target``, + else None. Mirrors MarriageBot's proposal checks.""" + if proposer == target: + return "You can't marry yourself, as much as you may want to." + if target in self.store.partners_of(proposer): + return "You're already married to them!" + if self.store.is_blocked(target, proposer): + return "They've blocked you from sending them proposals." + if not allow_incest and self.blood_related(proposer, target): + return ( + "You can't marry a family member. A server admin can allow " + "incest proposals with the `incest` command." + ) + if max_partners and len(self.store.partners_of(proposer)) >= max_partners: + return f"You already have the maximum of {max_partners} partners." + if max_partners and len(self.store.partners_of(target)) >= max_partners: + return f"They already have the maximum of {max_partners} partners." + return None + + def check_adopt( + self, parent: int, child: int, *, allow_incest: bool, + max_children: int = 0, + ) -> str | None: + """Returns an error string if ``parent`` may not adopt ``child``.""" + if parent == child: + return "You can't adopt yourself." + if self.store.parent_of(child) is not None: + if self.store.parent_of(child) == parent: + return "They're already your child." + return "They already have a parent. They'd need to emancipate first." + if self.store.is_blocked(child, parent): + return "They've blocked you from sending them proposals." + if child in self.store.partners_of(parent): + return "You can't adopt your own partner - divorce them first." + # Structural checks that apply even when incest is allowed: adopting an + # ancestor would create a cycle, and a descendant is already family. + anc = self.ancestors(parent) + if child in anc: + return "You can't adopt your own ancestor - that would loop the tree." + if parent in self.ancestors(child) or child in self.descendants(parent): + return "They're already related to you." + if not allow_incest and self.blood_related(parent, child): + return ( + "You can't adopt a family member. A server admin can allow " + "incest proposals with the `incest` command." + ) + if max_children and len(self.store.children_of(parent)) >= max_children: + return f"You already have the maximum of {max_children} children." + return None + + # ------------------------------------------------------------ rendering + + def render_tree( + self, + root: int, + name_of: NameFn, + *, + include_spouses: bool = False, + max_depth: int = 10, + max_nodes: int = 500, + ) -> str: + """Render ``root``'s family as an indented text tree. + + ``include_spouses=False`` renders blood relatives only (like + MarriageBot's ``tree``); ``True`` also shows each node's partners + inline (like ``fulltree``). + """ + budget = [max_nodes] + lines: list[str] = [f"**{name_of(root)}**'s family tree", ""] + + # Ancestor chain (single-parent model -> linear chain). + chain: list[int] = [] + node = root + seen = {root} + while len(chain) < max_depth: + parent = self.store.parent_of(node) + if parent is None or parent in seen: + break + chain.append(parent) + seen.add(parent) + node = parent + if chain: + lines.append("Ancestors:") + for depth, anc in enumerate(reversed(chain), start=1): + label = _ancestor_label(len(chain) - depth + 1) + lines.append(f" {' ' * (depth - 1)}- {name_of(anc)} ({label})") + lines.append("") + + sibs = self.siblings(root) + if sibs: + lines.append("Siblings: " + ", ".join(name_of(s) for s in sibs)) + + partners = self.store.partners_of(root) + if partners: + lines.append("Partners: " + ", ".join(name_of(p) for p in partners)) + + children = self.store.children_of(root) + if children: + lines.append("Children:") + for i, child in enumerate(children): + last = i == len(children) - 1 + self._render_node( + child, name_of, include_spouses, max_depth, 1, + "" if last else "|", last, lines, budget, {root}, + ) + if not partners and not children and not chain and not sibs: + lines.append("_This user has no family yet._") + if budget[0] <= 0: + lines.append("_...tree truncated (too many members)_") + return "\n".join(lines) + + def _render_node( + self, uid: int, name_of: NameFn, include_spouses: bool, + depth_left: int, indent: int, prefix: str, last: bool, + lines: list[str], budget: list[int], seen: set[int], + ) -> None: + if budget[0] <= 0 or uid in seen or depth_left <= 0: + return + budget[0] -= 1 + seen.add(uid) + branch = "`-- " if last else "|-- " + pad = " " if last else "| " + partner_txt = "" + if include_spouses: + partners = [p for p in self.store.partners_of(uid) if p not in seen] + if partners: + partner_txt = " (+ " + ", ".join(name_of(p) for p in partners) + ")" + lines.append(f"{prefix}{branch}{name_of(uid)}{partner_txt}") + children = [c for c in self.store.children_of(uid) if c not in seen] + for i, child in enumerate(children): + self._render_node( + child, name_of, include_spouses, depth_left - 1, + indent + 1, prefix + pad, i == len(children) - 1, + lines, budget, seen, + ) diff --git a/matrimony/store.py b/matrimony/store.py new file mode 100644 index 0000000..4c78e3a --- /dev/null +++ b/matrimony/store.py @@ -0,0 +1,336 @@ +"""SQLite storage layer for Matrimony. + +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. +""" + +from __future__ import annotations + +import sqlite3 +import threading +import time +from dataclasses import dataclass + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS guild_settings ( + guild_id INTEGER PRIMARY KEY, + prefix TEXT, + allow_incest INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS 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 children ( + child_id INTEGER PRIMARY KEY, + parent_id INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS proposals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + proposer INTEGER NOT NULL, + target INTEGER NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('marry', 'adopt', 'makeparent')), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + UNIQUE (proposer, target, kind) +); + +CREATE TABLE IF NOT EXISTS blocks ( + blocker INTEGER NOT NULL, + blocked INTEGER NOT NULL, + PRIMARY KEY (blocker, blocked) +); +""" + + +@dataclass(frozen=True) +class Proposal: + id: int + guild_id: int + channel_id: int + proposer: int + target: int + kind: str # 'marry' | 'adopt' | 'makeparent' + created_at: int + expires_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, + but for a bot of this scale direct calls are fine.""" + + def __init__(self, path: str = ":memory:") -> None: + self._lock = threading.RLock() + self._conn = sqlite3.connect(path, check_same_thread=False) + self._conn.execute("PRAGMA foreign_keys = ON") + self._conn.execute("PRAGMA journal_mode = WAL") + with self._lock: + self._conn.executescript(SCHEMA) + + def close(self) -> None: + with self._lock: + self._conn.close() + + # ------------------------------------------------------------------ util + + def _row(self, sql: str, params: tuple = ()) -> sqlite3.Row | None: + with self._lock: + cur = self._conn.execute(sql, params) + return cur.fetchone() + + def _rows(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]: + with self._lock: + cur = self._conn.execute(sql, params) + return cur.fetchall() + + def _exec(self, sql: str, params: tuple = ()) -> None: + with self._lock: + self._conn.execute(sql, params) + self._conn.commit() + + # --------------------------------------------------------- guild settings + + def get_prefix(self, guild_id: int) -> str | None: + row = self._row( + "SELECT prefix FROM guild_settings WHERE guild_id = ?", (guild_id,) + ) + return row[0] if row and row[0] else None + + def set_prefix(self, guild_id: int, prefix: str | None) -> None: + self._exec( + """INSERT INTO guild_settings (guild_id, prefix, allow_incest) + VALUES (?, ?, 0) + ON CONFLICT(guild_id) DO UPDATE SET prefix = excluded.prefix""", + (guild_id, prefix), + ) + + def get_allow_incest(self, guild_id: int) -> bool: + row = self._row( + "SELECT allow_incest FROM guild_settings WHERE guild_id = ?", + (guild_id,), + ) + return bool(row[0]) if row else False + + def set_allow_incest(self, guild_id: int, allow: bool) -> None: + self._exec( + """INSERT INTO guild_settings (guild_id, prefix, allow_incest) + VALUES (?, NULL, ?) + ON CONFLICT(guild_id) DO UPDATE SET + allow_incest = excluded.allow_incest""", + (guild_id, int(allow)), + ) + + # ------------------------------------------------------------- partners + + def add_partners(self, user_x: int, user_y: int) -> None: + a, b = sorted((user_x, user_y)) + self._exec( + "INSERT OR IGNORE INTO partners (user_a, user_b, created_at) " + "VALUES (?, ?, ?)", + (a, b, int(time.time())), + ) + + def remove_partners(self, user_x: int, user_y: int) -> bool: + a, b = sorted((user_x, user_y)) + with self._lock: + cur = self._conn.execute( + "DELETE FROM partners WHERE user_a = ? AND user_b = ?", (a, b) + ) + self._conn.commit() + return cur.rowcount > 0 + + 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 = ?", + (user_id, user_id), + ) + return [r[1] if r[0] == user_id else r[0] for r in rows] + + def all_partner_edges(self) -> list[tuple[int, int]]: + return [tuple(r) for r in self._rows("SELECT user_a, user_b FROM partners")] + + # ------------------------------------------------------------- children + + def add_child(self, parent_id: int, child_id: int) -> bool: + """Attach ``child_id`` under ``parent_id``. One parent per child + (as in the original MarriageBot). Returns False if the child already + has a parent.""" + with self._lock: + cur = self._conn.execute( + "INSERT OR IGNORE INTO children (child_id, parent_id, created_at) " + "VALUES (?, ?, ?)", + (child_id, parent_id, int(time.time())), + ) + self._conn.commit() + return cur.rowcount > 0 + + def remove_child(self, child_id: int) -> bool: + with self._lock: + cur = self._conn.execute( + "DELETE FROM children WHERE child_id = ?", (child_id,) + ) + self._conn.commit() + return cur.rowcount > 0 + + def parent_of(self, child_id: int) -> int | None: + row = self._row( + "SELECT parent_id FROM children WHERE child_id = ?", (child_id,) + ) + return row[0] if row else None + + def children_of(self, parent_id: int) -> list[int]: + return [ + r[0] + for r in self._rows( + "SELECT child_id FROM children WHERE parent_id = ?", (parent_id,) + ) + ] + + # ------------------------------------------------------------- proposals + + def create_proposal( + self, + guild_id: int, + channel_id: int, + proposer: int, + target: int, + kind: str, + ttl_seconds: int, + ) -> Proposal | None: + """Create a proposal. Returns None if an identical open one exists.""" + now = int(time.time()) + self.expire_proposals(now) + with self._lock: + existing = self._row( + "SELECT id FROM proposals WHERE proposer = ? AND target = ? " + "AND kind = ?", + (proposer, target, kind), + ) + if existing: + return None + cur = self._conn.execute( + """INSERT INTO proposals + (guild_id, channel_id, proposer, target, kind, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (guild_id, channel_id, proposer, target, kind, now, + now + ttl_seconds), + ) + self._conn.commit() + pid = cur.lastrowid + return Proposal(pid, guild_id, channel_id, proposer, target, kind, + now, now + ttl_seconds) + + 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 = ?", + (proposal_id,), + ) + return Proposal(*row) if row else None + + 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 = ?", + (target,), + ) + else: + rows = self._rows( + "SELECT id, guild_id, channel_id, proposer, target, kind, " + "created_at, expires_at FROM proposals WHERE target = ? " + "AND kind = ?", + (target, kind), + ) + return [Proposal(*r) for r in rows] + + def delete_proposal(self, proposal_id: int) -> None: + self._exec("DELETE FROM proposals WHERE id = ?", (proposal_id,)) + + def expire_proposals(self, now: int | None = None) -> int: + now = int(time.time()) if now is None else now + with self._lock: + cur = self._conn.execute( + "DELETE FROM proposals WHERE expires_at <= ?", (now,) + ) + self._conn.commit() + return cur.rowcount + + # ----------------------------------------------------------------- blocks + + def add_block(self, blocker: int, blocked: int) -> None: + self._exec( + "INSERT OR IGNORE INTO blocks (blocker, blocked) VALUES (?, ?)", + (blocker, blocked), + ) + + def remove_block(self, blocker: int, blocked: int) -> bool: + with self._lock: + cur = self._conn.execute( + "DELETE FROM blocks WHERE blocker = ? AND blocked = ?", + (blocker, blocked), + ) + self._conn.commit() + return cur.rowcount > 0 + + def is_blocked(self, blocker: int, blocked: int) -> bool: + return bool( + self._row( + "SELECT 1 FROM blocks WHERE blocker = ? AND blocked = ?", + (blocker, blocked), + ) + ) + + def blocked_users(self, blocker: int) -> list[int]: + return [ + r[0] + for r in self._rows( + "SELECT blocked FROM blocks WHERE blocker = ?", (blocker,) + ) + ] + + # ------------------------------------------------------------------ misc + + def family_size(self, user_id: int) -> int: + """Total number of distinct users reachable through partner/parent + edges (i.e. everyone in the user's tree, excluding themselves).""" + seen = {user_id} + frontier = [user_id] + while frontier: + current = frontier.pop() + neighbours = ( + self.partners_of(current) + + self.children_of(current) + + ([self.parent_of(current)] if self.parent_of(current) else []) + ) + for n in neighbours: + if n not in seen: + seen.add(n) + frontier.append(n) + return len(seen) - 1 + + def stats(self) -> dict[str, int]: + def count(table: str) -> int: + return self._row(f"SELECT COUNT(*) FROM {table}")[0] + + return { + "marriages": count("partners"), + "parent_child_links": count("children"), + "open_proposals": count("proposals"), + "blocks": count("blocks"), + "guild_settings": count("guild_settings"), + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..144d77c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "matrimony-bot" +version = "0.1.0" +description = "Matrimony - a MarriageBot-style family-tree Discord bot" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +dependencies = ["discord.py>=2.4.0,<3"] + +[project.optional-dependencies] +dev = ["pytest>=8.0,<9", "ruff>=0.5,<1"] + +[tool.setuptools.packages.find] +include = ["matrimony*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "SIM", "B", "UP"] +ignore = ["E501"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..5b83c03 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0,<9 +ruff>=0.5,<1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..36d0657 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +discord.py>=2.4.0,<3 diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py new file mode 100644 index 0000000..472eab3 --- /dev/null +++ b/tests/test_dispatch.py @@ -0,0 +1,189 @@ +"""Tests for command dispatch: bot-authored messages are processed (the +bot-marriage feature), only this bot's own messages are skipped, plus +end-to-end proposal flows driven through the command callbacks.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from matrimony.bot import MatrimonyBot +from matrimony.cogs import matr as matr_mod +from matrimony.cogs.matr import MatrCog +from matrimony.config import Config + +BOT_USER_ID = 999999 + + +def make_bot() -> MatrimonyBot: + bot = MatrimonyBot(Config(database_path=":memory:")) + MatrCog(bot) + # Pretend we're logged in as this user id + bot._connection.user = SimpleNamespace(id=BOT_USER_ID) # type: ignore[attr-defined] + return bot + + +def user(uid: int, *, bot: bool = False, name: str | None = None): + return SimpleNamespace( + id=uid, + bot=bot, + name=name or f"user{uid}", + display_name=name or f"user{uid}", + mention=f"<@{uid}>", + guild_permissions=SimpleNamespace(manage_guild=False), + ) + + +def message(author, content: str = "!matr help", guild=None): + return SimpleNamespace( + author=author, content=content, guild=guild, channel=SimpleNamespace(id=1) + ) + + +def ctx_for(bot: MatrimonyBot, author, guild_id: int = 42): + guild = SimpleNamespace(id=guild_id, get_member=lambda uid: None) + return SimpleNamespace( + bot=bot, + author=author, + guild=guild, + channel=SimpleNamespace(id=7), + prefix="!", + reply=AsyncMock(), + command=None, + ) + + +def run(coro): + return asyncio.get_event_loop_policy().new_event_loop().run_until_complete(coro) + + +# --------------------------------------------------------------------------- +# Dispatch policy +# --------------------------------------------------------------------------- + + +def test_should_process_human() -> None: + bot = make_bot() + assert bot.should_process(message(user(1, bot=False))) is True + + +def test_should_process_other_bot() -> None: + """Bot-authored command messages must be dispatched - this is the + feature that lets other bots marry/adopt/be adopted.""" + bot = make_bot() + assert bot.should_process(message(user(2, bot=True))) is True + + +def test_should_process_ignores_self() -> None: + """Only THIS bot's own messages are skipped - enough to prevent + self-command loops without a blanket author.bot exclusion.""" + bot = make_bot() + assert bot.should_process(message(user(BOT_USER_ID, bot=True))) is False + + +def test_on_message_dispatches_bot_authors() -> None: + bot = make_bot() + bot.process_commands = AsyncMock() # type: ignore[method-assign] + run(bot.on_message(message(user(2, bot=True)))) + bot.process_commands.assert_awaited_once() + + +def test_on_message_skips_own() -> None: + bot = make_bot() + bot.process_commands = AsyncMock() # type: ignore[method-assign] + run(bot.on_message(message(user(BOT_USER_ID, bot=True)))) + bot.process_commands.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Proposal flows through command callbacks (bot + human authors) +# --------------------------------------------------------------------------- + + +def test_bot_proposes_to_bot_and_accepts() -> None: + """Full bot-marries-bot flow driven through the marry/accept commands.""" + bot = make_bot() + proposer = user(100, bot=True) + target = user(200, bot=True) + + run(matr_mod.matr_marry.callback(ctx_for(bot, proposer), target)) + pending = bot.store.proposals_for(target.id, "marry") + assert len(pending) == 1 and pending[0].proposer == proposer.id + + run(matr_mod.matr_accept.callback(ctx_for(bot, target), None)) + assert bot.store.partners_of(proposer.id) == [target.id] + assert bot.store.proposals_for(target.id) == [] + + +def test_human_proposes_to_bot_and_bot_accepts() -> None: + bot = make_bot() + human = user(300, bot=False) + robot = user(400, bot=True) + + run(matr_mod.matr_marry.callback(ctx_for(bot, human), robot)) + run(matr_mod.matr_accept.callback(ctx_for(bot, robot), None)) + assert bot.store.partners_of(human.id) == [robot.id] + + +def test_bot_adopts_human() -> None: + bot = make_bot() + robot = user(500, bot=True) + human = user(600, bot=False) + + run(matr_mod.matr_adopt.callback(ctx_for(bot, robot), human)) + run(matr_mod.matr_accept.callback(ctx_for(bot, human), None)) + assert bot.store.parent_of(human.id) == robot.id + assert bot.store.children_of(robot.id) == [human.id] + + +def test_human_adopts_bot() -> None: + bot = make_bot() + human = user(700, bot=False) + robot = user(800, bot=True) + + run(matr_mod.matr_adopt.callback(ctx_for(bot, human), robot)) + run(matr_mod.matr_accept.callback(ctx_for(bot, robot), None)) + assert bot.store.parent_of(robot.id) == human.id + + +def test_bot_makeparent_and_divorce() -> None: + bot = make_bot() + child_bot = user(900, bot=True) + parent_human = user(901, bot=False) + + run(matr_mod.matr_makeparent.callback(ctx_for(bot, child_bot), parent_human)) + run(matr_mod.matr_accept.callback(ctx_for(bot, parent_human), None)) + assert bot.store.parent_of(child_bot.id) == parent_human.id + + # and a divorce path + other = user(902, bot=True) + run(matr_mod.matr_marry.callback(ctx_for(bot, child_bot), other)) + run(matr_mod.matr_accept.callback(ctx_for(bot, other), None)) + run(matr_mod.matr_divorce.callback(ctx_for(bot, child_bot), other)) + assert bot.store.partners_of(child_bot.id) == [] + + +def test_proposal_declined_by_bot() -> None: + bot = make_bot() + proposer = user(111, bot=True) + target = user(222, bot=True) + run(matr_mod.matr_marry.callback(ctx_for(bot, proposer), target)) + run(matr_mod.matr_decline.callback(ctx_for(bot, target), None)) + assert bot.store.partners_of(proposer.id) == [] + assert bot.store.proposals_for(target.id) == [] + + +# --------------------------------------------------------------------------- +# Prefix resolution +# --------------------------------------------------------------------------- + + +def test_custom_guild_prefix() -> None: + bot = make_bot() + bot.store.set_prefix(42, "?") + guild = SimpleNamespace(id=42) + msg = message(user(1), guild=guild) + prefixes = run(bot._get_prefix(bot, msg)) + assert "?" in prefixes + # DM/other guild falls back to default + other = message(user(1), guild=SimpleNamespace(id=99)) + assert "!" in run(bot._get_prefix(bot, other)) diff --git a/tests/test_family.py b/tests/test_family.py new file mode 100644 index 0000000..2ae11a5 --- /dev/null +++ b/tests/test_family.py @@ -0,0 +1,173 @@ +"""Tests for family-graph logic (ancestors, validation, relationship, +rendering) - all Discord-free.""" + +import pytest + +from matrimony.family import FamilyView +from matrimony.store import Store + + +@pytest.fixture() +def store() -> Store: + s = Store(":memory:") + yield s + s.close() + + +@pytest.fixture() +def fam(store: Store) -> FamilyView: + return FamilyView(store) + + +def _name(uid: int) -> str: + return f"U{uid}" + + +def build_tree(store: Store) -> None: + """ 1+2 + | + 3 ---+--- 4 + | + 5+6 (5 partners 6) + """ + store.add_partners(1, 2) + store.add_child(1, 3) + store.add_child(1, 4) + store.add_child(3, 5) + store.add_partners(5, 6) + + +def test_ancestors_and_descendants(store: Store, fam: FamilyView) -> None: + build_tree(store) + assert fam.ancestors(5) == {3: 1, 1: 2} + assert fam.descendants(1) == {3: 1, 4: 1, 5: 2} + assert fam.ancestors(1) == {} + assert fam.descendants(4) == {} + + +def test_siblings(store: Store, fam: FamilyView) -> None: + build_tree(store) + assert sorted(fam.siblings(3)) == [4] + assert fam.siblings(1) == [] + assert fam.siblings(5) == [] + + +def test_blood_related(store: Store, fam: FamilyView) -> None: + build_tree(store) + assert fam.blood_related(1, 5) # ancestor/descendant + assert fam.blood_related(5, 1) + assert fam.blood_related(3, 4) # siblings (shared ancestor) + assert fam.blood_related(4, 5) # uncle/nephew-ish via 1 + assert not fam.blood_related(1, 2) # partners aren't blood + assert not fam.blood_related(1, 6) # partner of grandchild + assert not fam.blood_related(7, 8) # strangers + + +def test_relationship(store: Store, fam: FamilyView) -> None: + build_tree(store) + assert fam.relationship(1, 2) == "partner" + assert fam.relationship(3, 1) == "parent" + assert fam.relationship(1, 3) == "child" + assert fam.relationship(3, 4) == "sibling" + assert fam.relationship(5, 1) == "grandparent" + assert fam.relationship(1, 5) == "grandchild" + assert fam.relationship(4, 5) == "niece/nephew" + assert fam.relationship(5, 4) == "aunt/uncle" + assert fam.relationship(1, 6) is None + assert fam.relationship(7, 8) is None + assert fam.relationship(3, 3) == "self" + + +def test_check_marry(store: Store, fam: FamilyView) -> None: + build_tree(store) + # self + assert fam.check_marry(7, 7, allow_incest=False) + # already partners + assert fam.check_marry(1, 2, allow_incest=False) + # blood relation blocked + assert "family" in (fam.check_marry(3, 4, allow_incest=False) or "") + # ... but allowed when incest is on + assert fam.check_marry(3, 4, allow_incest=True) is None + # strangers fine + assert fam.check_marry(7, 8, allow_incest=False) is None + # blocked user + store.add_block(8, 7) + assert fam.check_marry(7, 8, allow_incest=False) + + +def test_check_marry_partner_cap(store: Store, fam: FamilyView) -> None: + store.add_partners(1, 2) + store.add_partners(1, 3) + assert "maximum" in (fam.check_marry(1, 4, allow_incest=False, max_partners=2) or "") + assert fam.check_marry(1, 4, allow_incest=False, max_partners=0) is None + + +def test_check_adopt(store: Store, fam: FamilyView) -> None: + build_tree(store) + # self + assert fam.check_adopt(7, 7, allow_incest=False) + # already has a parent + assert "already have a parent" in (fam.check_adopt(9, 3, allow_incest=False) or "") + assert "already your child" in (fam.check_adopt(1, 3, allow_incest=False) or "") + # can't adopt ancestor (cycle) + assert fam.check_adopt(5, 1, allow_incest=True) + # can't adopt descendant (already related) + assert fam.check_adopt(1, 5, allow_incest=True) + # can't adopt partner + assert fam.check_adopt(1, 2, allow_incest=True) + # unrelated adoption is fine + assert fam.check_adopt(1, 7, allow_incest=False) is None + # blocked + store.add_block(8, 1) + assert fam.check_adopt(1, 8, allow_incest=False) + + +def test_check_adopt_children_cap(store: Store, fam: FamilyView) -> None: + store.add_child(1, 10) + store.add_child(1, 11) + assert "maximum" in (fam.check_adopt(1, 20, allow_incest=False, max_children=2) or "") + assert fam.check_adopt(1, 20, allow_incest=False, max_children=0) is None + + +def test_render_tree_blood(store: Store, fam: FamilyView) -> None: + build_tree(store) + text = fam.render_tree(1, _name) + assert "U1" in text + assert "U3" in text and "U4" in text and "U5" in text + # blood tree shows partners of root in Partners line, not recursively + assert "Partners: U2" in text + assert "U6" not in text # grandchild's partner not shown in blood tree + + +def test_render_tree_full(store: Store, fam: FamilyView) -> None: + build_tree(store) + text = fam.render_tree(1, _name, include_spouses=True) + assert "U6" in text # partner of child U5 shown inline + + +def test_render_tree_ancestors(store: Store, fam: FamilyView) -> None: + build_tree(store) + text = fam.render_tree(5, _name) + assert "Ancestors" in text + assert "U3" in text and "U1" in text + + +def test_render_tree_empty(store: Store, fam: FamilyView) -> None: + text = fam.render_tree(42, _name) + assert "no family" in text + + +def test_render_tree_depth_limit(store: Store, fam: FamilyView) -> None: + # chain 1 -> 2 -> 3 -> 4 -> 5 + for i in range(1, 5): + store.add_child(i, i + 1) + text = fam.render_tree(1, _name, max_depth=2) + assert "U2" in text and "U3" in text + assert "U4" not in text + + +def test_render_tree_cyclesafe(store: Store, fam: FamilyView) -> None: + store.add_child(1, 2) + store.add_partners(1, 2) # odd data, but rendering must not loop + text = fam.render_tree(1, _name, include_spouses=True) + assert "U1" in text diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..126a1e7 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,133 @@ +"""Tests for the SQLite store layer.""" + + +import pytest + +from matrimony.store import Store + + +@pytest.fixture() +def store() -> Store: + s = Store(":memory:") + yield s + s.close() + + +def test_partners_roundtrip(store: Store) -> None: + store.add_partners(10, 20) + store.add_partners(30, 10) + assert sorted(store.partners_of(10)) == [20, 30] + assert store.partners_of(20) == [10] + assert store.partners_of(99) == [] + + assert store.remove_partners(10, 20) is True + assert store.remove_partners(10, 20) is False + assert store.partners_of(10) == [30] + + +def test_partners_idempotent(store: Store) -> None: + store.add_partners(1, 2) + store.add_partners(1, 2) # duplicate insert is a no-op + assert store.partners_of(1) == [2] + + +def test_one_parent_per_child(store: Store) -> None: + assert store.add_child(100, 200) is True + assert store.parent_of(200) == 100 + # Second parent rejected + assert store.add_child(300, 200) is False + assert store.parent_of(200) == 100 + assert store.children_of(100) == [200] + assert store.children_of(300) == [] + + assert store.remove_child(200) is True + assert store.remove_child(200) is False + + +def test_proposal_lifecycle(store: Store) -> None: + p = store.create_proposal(1, 2, 10, 20, "marry", 300) + assert p is not None + assert store.get_proposal(p.id) == p + + # Duplicate (same proposer/target/kind) rejected while open + assert store.create_proposal(1, 2, 10, 20, "marry", 300) is None + # Different kind allowed + assert store.create_proposal(1, 2, 10, 20, "adopt", 300) is not None + # Reverse direction allowed + assert store.create_proposal(1, 2, 20, 10, "marry", 300) is not None + + store.delete_proposal(p.id) + assert store.get_proposal(p.id) is None + assert store.create_proposal(1, 2, 10, 20, "marry", 300) is not None + + +def test_proposal_expiry(store: Store) -> None: + store.create_proposal(1, 2, 10, 20, "marry", ttl_seconds=-1) + assert store.expire_proposals() == 1 + assert store.proposals_for(20) == [] + + +def test_proposals_for_filters(store: Store) -> None: + store.create_proposal(1, 2, 10, 20, "marry", 300) + store.create_proposal(1, 2, 30, 20, "adopt", 300) + assert len(store.proposals_for(20)) == 2 + assert len(store.proposals_for(20, "marry")) == 1 + assert store.proposals_for(21) == [] + + +def test_blocks(store: Store) -> None: + store.add_block(1, 2) + assert store.is_blocked(1, 2) is True + assert store.is_blocked(2, 1) is False + assert store.blocked_users(1) == [2] + assert store.remove_block(1, 2) is True + assert store.is_blocked(1, 2) is False + + +def test_guild_settings(store: Store) -> None: + assert store.get_prefix(1) is None + store.set_prefix(1, "?") + assert store.get_prefix(1) == "?" + store.set_prefix(1, "$") + assert store.get_prefix(1) == "$" + + assert store.get_allow_incest(1) is False + store.set_allow_incest(1, True) + assert store.get_allow_incest(1) is True + # prefix survives incest toggle + assert store.get_prefix(1) == "$" + # other guild unaffected + assert store.get_allow_incest(2) is False + + +def test_family_size(store: Store) -> None: + # 1 - 2 partners; 1 -> 3 child; 3 -> 4 grandchild; 4 partners 5 + store.add_partners(1, 2) + store.add_child(1, 3) + store.add_child(3, 4) + store.add_partners(4, 5) + assert store.family_size(1) == 4 + assert store.family_size(2) == 4 + assert store.family_size(99) == 0 + + +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.add_block(7, 8) + s = store.stats() + assert s["marriages"] == 1 + assert s["parent_child_links"] == 1 + assert s["open_proposals"] == 1 + assert s["blocks"] == 1 + + +def test_persistence(tmp_path) -> None: + db = tmp_path / "m.db" + s = Store(str(db)) + s.add_partners(1, 2) + s.close() + s2 = Store(str(db)) + assert s2.partners_of(1) == [2] + s2.close()