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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,21 @@ All commands live under one group, `matr`, invoked with a per-guild prefix
each with a random SFW anime GIF via the
[Fluxpoint API](https://docs.fluxpoint.dev/api/endpoints/sfw-anime-gifs)
(like the original MarriageBot).
- **Drifting ship percentages** — `ship @a @b` is deterministic for a
pair at a given moment, but wanders smoothly over ~24h instead of being
pinned forever, and busy channels nudge it: the score includes the
rolling count of channel messages over the last 10 minutes (a small,
bounded in-memory window fed by every observed message, bots included).
- **Bot-directed quips** — aiming an action at Matrimony itself appends a
rotating one-liner as Discord subtext (a `-# ` line) under the normal
reply, and proposals at the bot are declined outright ("sorry, taken by
discord.py").
- **Unknown-action quips** — `!matr blorp` (or any nonexistent action)
gets a rotating funny quip plus the help embed, never silence. The
bot's own messages never trigger it.
- **Marital wisdom** — `!matr help` (and bare `!matr`) ends with a long
list of quips about memes, Discord culture, programming, and
relationships, below the full command listing.
- **Slash commands** — every command is a discord.py hybrid, so `/matr`
mirrors `!matr` exactly: `/matr marry`, `/matr fun hug`,
`/matr admin forcemarry`, `/matr meta stats`.
Expand Down Expand Up @@ -97,7 +108,7 @@ explicitly supports bot-authored commands and bot targets:
| `!matr forceadopt @parent @child` | Force adoption (Manage Server) |
| `!matr forceemancipate @user` | Force-remove a parent (Manage Server) |
| `!matr hug`/`cuddle`/`kiss`/`pat`/`poke`/`wave`/`lick`/`nom`/`tickle`/`highfive`/`handhold`/`slap`/`punch`/`bite`/`stab @user` | Flavour actions (+ GIF) |
| `!matr ship @a [@b]` | Deterministic ship percentage (+ GIF) |
| `!matr ship @a [@b]` | Drifting ship percentage (pair + time + channel chatter; + GIF) |
| `!matr flirt @user` (alias `rizz`) | Terrible pickup line (+ GIF) |
| `!matr date @user` (alias `dateidea`) | Terrible date idea (+ GIF) |
| `!matr anniversary [@partner]` (alias `anniv`) | Time married (+ GIF) |
Expand Down
9 changes: 9 additions & 0 deletions matrimony/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
from __future__ import annotations

import logging
import time

import discord
from discord.ext import commands

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

log = logging.getLogger("matrimony")
Expand Down Expand Up @@ -39,6 +41,8 @@ def __init__(self, config: Config, store: Store | None = None) -> None:
self.store = store or Store(config.database_path)
self.family = FamilyView(self.store)
self.gifs = FluxpointClient(config.fluxpoint_api_key)
# Named channel_activity: ``Client.activity`` is the presence field.
self.channel_activity = ChannelActivity()

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

Expand Down Expand Up @@ -88,6 +92,11 @@ async def process_commands(self, message: discord.Message) -> None:
await self.invoke(ctx)

async def on_message(self, message: discord.Message) -> None:
# Every observed message feeds the per-channel activity window
# (human and bot authors alike, including our own replies) — it
# powers the channel-busyness nudge in `matr ship`. Dispatching is
# still governed by ``should_process``.
self.channel_activity.note(message.channel.id, time.time())
if not self.should_process(message):
return
await self.process_commands(message)
Expand Down
97 changes: 87 additions & 10 deletions matrimony/cogs/matr.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from __future__ import annotations

import hashlib
import itertools
import random
import time
Expand All @@ -18,6 +17,7 @@

from .. import __version__
from ..bot import MatrimonyBot
from ..ship import ship_score
from ..store import Notice, Proposal

GREEN = discord.Color(0x2ECC71)
Expand Down Expand Up @@ -207,13 +207,29 @@ async def _post(
],
}

# Replies for `!matr <something that isn't a real action>`.
_UNKNOWN_QUIPS = [
"unknown action - the council of spouses will review it",
"that's not a real command, but points for confidence",
"close, but no matrimony",
"that command is in another castle",
"404: action not found (your marriage is still valid)",
"unknown action; the family tree remains unbothered",
"the action you typed does not spark joy",
"try `help` - it loves you unconditionally",
"that isn't a thing, and honestly? respect for trying",
"you misspelled it so confidently we're letting it slide",
]

_QUIP_SETS: dict[str, list[str]] = {**_BOT_QUIPS, "unknown": _UNKNOWN_QUIPS}

_QUIP_CYCLES: dict[str, itertools.cycle] = {}


def _quip(kind: str) -> str:
"""Next quip in the rotation for ``kind``."""
if kind not in _QUIP_CYCLES:
_QUIP_CYCLES[kind] = itertools.cycle(_BOT_QUIPS[kind])
_QUIP_CYCLES[kind] = itertools.cycle(_QUIP_SETS[kind])
return next(_QUIP_CYCLES[kind])


Expand Down Expand Up @@ -531,7 +547,43 @@ async def _pick_answerable(
return items[0]


# Slightly unhinged wisdom appended to the bottom of the help embed.
_HELP_QUIPS = [
"it's not a bug, it's an unmarried feature",
"your family tree has merge conflicts",
"touch grass? the tree command *is* grass",
"polygamy is just concurrency with worse consistency guarantees",
"till deadlock do us part",
"my love language is ACID compliance",
"a ship percentage is a metaphor, not a measurement",
"the real family was the friends we adopted along the way",
"marriage: a distributed system with no rollback",
"works on my machine, married on yours",
"have you tried divorcing it and marrying it again?",
"you had me at `if __name__ == '__main__'`",
"single? that's just a null pointer waiting to be dereferenced",
"skill issue? more like ship issue",
"discord kitten not included",
"ratio'd by your own family tree",
"the family tree is technically a DAG - do not ask",
"segfault in my heart, core dumped in yours",
"10/10 would propose again - sponsored by SQLite",
"emotional damage mitigated: spouse buff applied",
"shipping yourself with the bot voids the warranty",
"loading family tree... 99% (the last 1% is a git rebase)",
"you can't spell 'discord' without 'cord' - they're married now",
"in this economy `!matr marry` is still free",
"the matchmaking algorithm is vibes-based and union-approved",
"seven marriages in, zero conflicts - impressively rebased",
"our lawyers recommend `!matr help` twice daily",
"here be dragons (they're in a committed polycule)",
"marriage is just pair programming without the programming",
"pov: you typed `!matr blorp` expecting wisdom - close enough",
]


def _help_embed(prefix: str, cmd: str) -> discord.Embed:
quips = "\n".join(f"\u00b7 {q}" for q in _HELP_QUIPS)
return discord.Embed(
title="Matrimony",
description=(
Expand Down Expand Up @@ -565,7 +617,8 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed:
"Family trees are global across servers, and bot accounts can "
"use commands and be married or adopted. Users may have multiple "
"partners; existing partners are notified of each new marriage "
"and may acknowledge it or divorce on the spot."
"and may acknowledge it or divorce on the spot.\n\n"
f"**Marital wisdom**\n{quips}"
),
colour=GOLD,
)
Expand All @@ -583,8 +636,23 @@ def _help_embed(prefix: str, cmd: str) -> discord.Embed:
description="Matrimony family-tree commands.",
)
async def matr(ctx: commands.Context) -> None:
"""Bare ``!matr`` shows help."""
"""Bare ``!matr`` shows help; ``!matr <unknown>`` earns a quip.

With ``invoke_without_command=True`` the group's callback runs even
when the first word isn't a real subcommand — the parser rewinds the
view and invokes us as a plain command, so any leftover text means the
user typed an action that doesn't exist (`!matr blorp`)."""
bot = _bot(ctx)
view = getattr(ctx, "view", None)
leftover = (
view.buffer[view.index:].strip() if view is not None else ""
)
if leftover:
await ctx.reply(
content=_quip("unknown"),
embed=_help_embed(ctx.prefix or "!", bot.config.command_name),
)
return
await ctx.reply(
embed=_help_embed(ctx.prefix or "!", bot.config.command_name)
)
Expand Down Expand Up @@ -1186,11 +1254,17 @@ async def _fun(ctx: commands.Context, user: discord.User) -> None:
async def matr_ship(
ctx: commands.Context, a: discord.User, b: discord.User | None = None
) -> None:
"""Show a (deterministic) ship percentage between two users."""
"""Show the drifting ship percentage between two users."""
bot = _bot(ctx)
b = b or ctx.author
lo, hi = sorted((a.id, b.id))
pct = int(hashlib.md5(f"{lo}:{hi}".encode()).hexdigest()[:6], 16) % 101
gif = await _bot(ctx).gifs.fetch_url("handhold")
now = time.time()
pct = ship_score(
a.id,
b.id,
now=now,
message_count=bot.channel_activity.count(ctx.channel.id, now),
)
gif = await bot.gifs.fetch_url("handhold")
text = f"{a.mention} x {b.mention}: **{pct}%** :heart:"
if _is_matrimony(ctx, a) or _is_matrimony(ctx, b):
text += f"\n-# {_quip('ship')}"
Expand Down Expand Up @@ -1466,11 +1540,14 @@ async def on_command_error(
) -> None:
error = getattr(error, "original", error)
if isinstance(error, commands.CommandNotFound):
# Unknown `!matr <action>` -> show help instead of silence.
# Unknown `!matr <action>` -> a quip plus the help embed.
# (Usually the group callback answers this first via
# ``subcommand_passed``; this path catches any other
# CommandNotFound raised against the matr group.)
content = ctx.message.content.split()
if len(content) >= 2 and content[0].endswith(self.bot.config.command_name):
await ctx.reply(
content="Unknown action.",
content=_quip("unknown"),
embed=_help_embed(ctx.prefix or "!", self.bot.config.command_name),
)
return
Expand Down
106 changes: 106 additions & 0 deletions matrimony/ship.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Drifting ship-percentage scoring and per-channel message activity.

``matr ship`` used to be a fixed hash of the two user ids, so it never
changed. The score is now a pure function of three inputs:

- the sorted pair of user ids (stable anchor, per-pair drift shape),
- a slowly changing time component (a per-pair sinusoid over ~24h, so the
value drifts smoothly between 5-minute buckets instead of jumping),
- the rolling count of messages in the channel over the last 10 minutes
(busier channels nudge the value a little, direction depends on the pair).

Everything is deterministic: the same inputs in the same time bucket always
give the same answer, and the function is pure so tests can inject the
clock and the message count. The result is clamped to 0-100.
"""

from __future__ import annotations

import hashlib
import math
from collections import deque

SHIP_BUCKET_SECONDS = 300.0
"""Length of a time bucket. The score is constant inside one bucket."""

SHIP_DRIFT_PERIOD_SECONDS = 24.0 * 3600.0
"""Period of the sinusoidal drift. Slower = gentler wandering."""

SHIP_ACTIVITY_WINDOW_SECONDS = 600.0
"""How long a message stays in a channel's rolling activity window."""

SHIP_ACTIVITY_NUDGE = 10
"""Maximum absolute nudge applied by channel activity."""

SHIP_ACTIVITY_MAX_PER_CHANNEL = 256
"""Bound on stored timestamps per channel (oldest are dropped)."""

_TAU = 2.0 * math.pi


def ship_score(
user_a: int, user_b: int, *, now: float, message_count: int
) -> int:
"""Deterministic, slowly drifting ship percentage in ``[0, 100]``.

``now`` is seconds since the epoch (any monotonic epoch works as long
as callers are consistent); ``message_count`` is the number of recent
messages in the channel. Same pair + same bucket + same count always
yields the same score; adjacent buckets differ by at most a point or
two so the percentage wanders rather than jumps.
"""
lo, hi = sorted((int(user_a), int(user_b)))
digest = hashlib.sha256(f"matrimony-ship:{lo}:{hi}".encode()).digest()
base = 15 + digest[0] * 70 // 256 # stable anchor in [15, 84]
amplitude = 5 + digest[1] * 15 // 256 # drift amplitude in [5, 19]
phase = (digest[2] | digest[3] << 8) / 65536.0 * _TAU
bucket = int(now // SHIP_BUCKET_SECONDS)
drift = amplitude * math.sin(
phase + _TAU * bucket * SHIP_BUCKET_SECONDS / SHIP_DRIFT_PERIOD_SECONDS
)
direction = -1.0 if digest[4] & 1 else 1.0
nudge = direction * min(max(int(message_count), 0), SHIP_ACTIVITY_NUDGE)
return max(0, min(100, round(base + drift + nudge)))


class ChannelActivity:
"""In-memory rolling window of per-channel message timestamps.

One small deque per channel, pruned to the last ``window_seconds``
(and hard-bounded per channel) — recording and counting are both a
handful of pointer operations, so it stays cheap on busy channels.
"""

def __init__(
self,
window_seconds: float = SHIP_ACTIVITY_WINDOW_SECONDS,
max_per_channel: int = SHIP_ACTIVITY_MAX_PER_CHANNEL,
) -> None:
self._window = window_seconds
self._channels: dict[int, deque[float]] = {}
self._max = max_per_channel

def _prune(self, dq: deque[float], now: float) -> None:
cutoff = now - self._window
while dq and dq[0] <= cutoff:
dq.popleft()

def note(self, channel_id: int, when: float) -> None:
"""Record that a message was sent in ``channel_id`` at ``when``."""
dq = self._channels.get(channel_id)
if dq is None:
dq = self._channels[channel_id] = deque(maxlen=self._max)
dq.append(when)
self._prune(dq, when)

def count(self, channel_id: int, now: float) -> int:
"""Messages seen in ``channel_id`` within the window ending ``now``."""
dq = self._channels.get(channel_id)
if not dq:
return 0
self._prune(dq, now)
if not dq:
# Empty channels are dropped so the map can't grow forever.
del self._channels[channel_id]
return 0
return len(dq)
Loading
Loading