Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ MATRIMONY_MAX_CHILDREN=0
MATRIMONY_MAX_TREE_DEPTH=10
# Seconds a proposal stays open (buttons + accept/decline commands)
MATRIMONY_PROPOSAL_TTL=300

# Fluxpoint API token for action GIFs (hug/kiss/slap/...).
# Defaults to the provider's shared public-tier key; set your own token here,
# or leave empty to disable GIFs.
# FLUXPOINT_API_KEY=
53 changes: 49 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ All commands live under one group, `matr`, invoked with a per-guild prefix
- **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`.
- **Fun commands** — `hug`, `cuddle`, `kiss`, `pat`, `poke`, `wave`,
`lick`, `nom`, `tickle`, `highfive`, `handhold`, `slap`, `punch`,
`bite`, `stab`, `ship`,
each with a random SFW anime GIF via the
[Fluxpoint API](https://docs.fluxpoint.dev/api/endpoints/sfw-anime-gifs)
(like the original MarriageBot).
- **Meta** — `info`, `invite`, `stats`, `help`.

## Bot accounts are first-class
Expand All @@ -40,9 +45,15 @@ 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.
command messages are dispatched exactly like human ones. (This requires
overriding `process_commands`: discord.py drops all bot-authored
messages in its default dispatch.)
- The only exclusion is Matrimony's *own* messages — enough to prevent
self-command loops while leaving every other bot free to participate.
- Command parsing is lenient: leading/trailing whitespace and extra text
after the arguments are tolerated, `@Matrimony matr ...` works as a
mention prefix, and whitespace after the prefix (`! matr help`) is
accepted.
- Covered by tests: proposal/accept flows driven by bot-authored commands
(`tests/test_dispatch.py`).

Expand Down Expand Up @@ -71,8 +82,8 @@ explicitly supports bot-authored commands and bot targets:
| `!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 hug`/`cuddle`/`kiss`/`pat`/`poke`/`wave`/`lick`/`nom`/`tickle`/`highfive`/`handhold`/`slap`/`punch`/`bite`/`stab @user` | Flavour actions (+ GIF) |
| `!matr ship @a [@b]` | Deterministic ship percentage (+ GIF) |
| `!matr info` / `invite` / `stats` | Bot information |

`@Matrimony matr ...` (mention prefix) also always works.
Expand Down Expand Up @@ -115,6 +126,37 @@ docker run -e MATRIMONY_TOKEN=... -v matrimony-data:/data matrimony
| `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 |
| `FLUXPOINT_API_KEY` | Fluxpoint's shared public key | Fluxpoint API token for action GIFs; set to empty to disable |

## Action GIFs

Action replies embed a random SFW anime GIF fetched from the Fluxpoint API
(`GET https://api.fluxpoint.dev/sfw/gif/<type>`, token in the `Authorization`
header). Mappings:

| Command | Fluxpoint endpoint |
| --- | --- |
| `marry` (marriage completed) | `kiss` |
| `divorce` | `cry` |
| `hug` | `hug` |
| `cuddle` | `hug` *(no `cuddle` endpoint exists; closest fit)* |
| `kiss` | `kiss` |
| `pat` | `pat` |
| `poke` | `poke` |
| `wave` | `wave` |
| `lick` | `lick` |
| `nom` | `feed` *(no `nom` endpoint exists; closest fit)* |
| `tickle` | `tickle` |
| `highfive` | `highfive` |
| `handhold` | `handhold` |
| `slap` | `slap` |
| `punch` | `punch` |
| `bite` | `bite` |
| `stab` | `punch` *(no `stab` endpoint exists; closest fit)* |
| `ship` | `handhold` |

GIF fetching never blocks the action: a missing/empty key, HTTP error,
timeout, or malformed response just sends the reply without an image.

## Fidelity to the original MarriageBot

Expand Down Expand Up @@ -146,6 +188,9 @@ Matrimony replicates the original's mechanics as closely as is practical
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.
- Upstream sources GIFs from its own store; Matrimony uses Fluxpoint's SFW
anime GIF API (provider's public-tier key by default, override via
`FLUXPOINT_API_KEY`).

**Sources:**

Expand Down
22 changes: 22 additions & 0 deletions matrimony/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from .config import Config
from .family import FamilyView
from .gifs import FluxpointClient
from .store import Store

log = logging.getLogger("matrimony")
Expand All @@ -31,11 +32,13 @@ def __init__(self, config: Config, store: Store | None = None) -> None:
command_prefix=self._get_prefix,
intents=intents,
case_insensitive=True,
strip_after_prefix=True,
help_command=None,
)
self.config = config
self.store = store or Store(config.database_path)
self.family = FamilyView(self.store)
self.gifs = FluxpointClient(config.fluxpoint_api_key)

# ------------------------------------------------------------- prefixes

Expand All @@ -59,13 +62,32 @@ def should_process(self, message: discord.Message) -> bool:
accept, and adopt like human users."""
return self.user is None or message.author.id != self.user.id

async def process_commands(self, message: discord.Message) -> None:
"""Dispatch ``message`` as a command.

discord.py's built-in version silently drops every message whose
author is a bot; bots are first-class users here, so the parser only
skips this bot's own messages (``should_process``). Stray whitespace
around the command text is tolerated."""
if not self.should_process(message):
return
content = message.content.strip()
if content != message.content:
message.content = content
ctx = await self.get_context(message)
await self.invoke(ctx)

async def on_message(self, message: discord.Message) -> None:
if not self.should_process(message):
return
await self.process_commands(message)

# --------------------------------------------------------------- setup

async def close(self) -> None:
await self.gifs.close()
await super().close()

async def setup_hook(self) -> None:
from .cogs import ALL_COGS

Expand Down
72 changes: 58 additions & 14 deletions matrimony/cogs/matr.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,16 @@ 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))
async def _say(
ctx: commands.Context,
text: str,
ok: bool = True,
image: str | None = None,
) -> None:
emb = _embed(text, GREEN if ok else RED)
if image:
emb.set_image(url=image)
await ctx.reply(embed=emb)


def _display(user: discord.User | discord.Member | None, author_id: int) -> str:
Expand All @@ -54,8 +62,15 @@ def name_of(uid: int) -> str:
return name_of


async def _respond(ctx_or_interaction, text: str, ok: bool) -> None:
async def _respond(
ctx_or_interaction,
text: str,
ok: bool,
image: str | None = None,
) -> None:
emb = _embed(text, GREEN if ok else RED)
if image:
emb.set_image(url=image)
if isinstance(ctx_or_interaction, discord.Interaction):
if ctx_or_interaction.response.is_done():
await ctx_or_interaction.followup.send(embed=emb)
Expand Down Expand Up @@ -132,10 +147,12 @@ async def _enact(bot: MatrimonyBot, proposal: Proposal, ctx_or_int) -> None:
target = _name_of(bot, None)(proposal.target)
if proposal.kind == "marry":
bot.store.add_partners(proposal.proposer, proposal.target)
gif = await bot.gifs.fetch_url("kiss")
await _respond(
ctx_or_int,
f"{proposer} and {target} are now married! :tada:",
True,
image=gif,
)
elif proposal.kind == "adopt":
# proposer adopts target as their child
Expand Down Expand Up @@ -235,7 +252,9 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed:
"`forcedivorce @a [@b]` - `forceadopt @parent @child` - "
"`forceemancipate @user`\n"
"**Fun**\n"
"`hug`/`kiss`/`slap`/`punch`/`bite`/`stab @user` - `ship @a @b`\n"
"`hug`/`cuddle`/`kiss`/`pat`/`poke`/`wave`/`lick`/`nom`\n"
"`tickle`/`highfive`/`handhold`/`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 "
Expand Down Expand Up @@ -317,10 +336,12 @@ async def matr_divorce(
await _say(ctx, "You're not married to them.", ok=False)
return
bot.store.remove_partners(ctx.author.id, target)
gif = await bot.gifs.fetch_url("cry")
await _say(
ctx,
f"{ctx.author.mention} and <@{target}> are now divorced. "
":broken_heart:",
image=gif,
)


Expand Down Expand Up @@ -750,25 +771,45 @@ async def matr_forceemancipate(ctx: commands.Context, child: discord.User) -> No
# Fun (simulation) commands
# ---------------------------------------------------------------------------

# name -> (past-tense verb, emoji, Fluxpoint gif type)
_FUN_VERBS = {
"hug": ("hugs", ":hugging:"),
"kiss": ("kisses", ":kissing_heart:"),
"slap": ("slaps", ":raised_back_of_hand:"),
"punch": ("punches", ":punch:"),
"bite": ("bites", ":tooth:"),
"stab": ("stabs", ":dagger:"),
"hug": ("hugs", ":hugging:", "hug"),
# Fluxpoint has no "cuddle" endpoint; hug is the same gesture.
"cuddle": ("cuddles", ":people_hugging:", "hug"),
"kiss": ("kisses", ":kissing_heart:", "kiss"),
"pat": ("pats", ":hand_splayed:", "pat"),
"poke": ("pokes", ":point_right:", "poke"),
"wave": ("waves at", ":wave:", "wave"),
"lick": ("licks", ":tongue:", "lick"),
# Fluxpoint has no "nom" endpoint; feed is the same gesture.
"nom": ("noms", ":yum:", "feed"),
"tickle": ("tickles", ":raised_hand:", "tickle"),
"highfive": ("high-fives", ":raised_hands:", "highfive"),
"handhold": ("holds hands with", ":handshake:", "handhold"),
"slap": ("slaps", ":raised_back_of_hand:", "slap"),
"punch": ("punches", ":punch:", "punch"),
"bite": ("bites", ":tooth:", "bite"),
# Fluxpoint has no "stab" endpoint; punch is the closest violent action.
"stab": ("stabs", ":dagger:", "punch"),
}


def _make_fun(name: str) -> None:
verb, emoji = _FUN_VERBS[name]
verb, emoji, gif_type = _FUN_VERBS[name]

@matr.command(name=name)
async def _fun(ctx: commands.Context, user: discord.User) -> None:
gif = await _bot(ctx).gifs.fetch_url(gif_type)
if user.id == ctx.author.id:
await _say(ctx, f"You {verb} yourself. Okay then. {emoji}")
await _say(
ctx, f"You {verb} yourself. Okay then. {emoji}", image=gif
)
else:
await _say(ctx, f"{ctx.author.mention} {verb} {user.mention}! {emoji}")
await _say(
ctx,
f"{ctx.author.mention} {verb} {user.mention}! {emoji}",
image=gif,
)

_fun.__doc__ = f"{name.capitalize()} another user."

Expand All @@ -785,7 +826,10 @@ async def matr_ship(
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:")
gif = await _bot(ctx).gifs.fetch_url("handhold")
await _say(
ctx, f"{a.mention} x {b.mention}: **{pct}%** :heart:", image=gif
)


# ---------------------------------------------------------------------------
Expand Down
12 changes: 12 additions & 0 deletions matrimony/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
import os
from dataclasses import dataclass

# Fluxpoint's shared public-tier key: the API provider ships it for bots to
# use as a default (see https://fluxpoint.dev). Override with FLUXPOINT_API_KEY.
DEFAULT_FLUXPOINT_API_KEY = "FP-Public-naEjca70OhKMtq67WpzaN8Gs"


@dataclass(frozen=True)
class Config:
Expand All @@ -22,6 +26,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.
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
to disable GIF fetching entirely.
"""

token: str = ""
Expand All @@ -32,6 +40,7 @@ class Config:
max_children: int = 0
max_tree_depth: int = 10
proposal_ttl_seconds: int = 300
fluxpoint_api_key: str = DEFAULT_FLUXPOINT_API_KEY

@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> Config:
Expand All @@ -45,4 +54,7 @@ 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")),
fluxpoint_api_key=env.get(
"FLUXPOINT_API_KEY", DEFAULT_FLUXPOINT_API_KEY
),
)
Loading
Loading