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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ MATRIMONY_MAX_TREE_DEPTH=10
# Seconds a proposal stays open (buttons + accept/decline commands)
MATRIMONY_PROPOSAL_TTL=300

# Optional per-guild channel allow-list: JSON object mapping guild ids to
# channel id lists. Guilds not listed are unrestricted; a guild mapped to
# [] accepts commands nowhere. Commands in other channels are ignored.
# MATRIMONY_ALLOWED_CHANNELS={"123456789012345678": ["111222333444555666"]}
# Or point to a JSON file with the same structure (inline var wins):
# MATRIMONY_ALLOWED_CHANNELS_FILE=/etc/matrimony/channels.json

# 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.
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ All commands live under one group, `matr`, invoked with a per-guild prefix
- **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`).
- **Channel restrictions** — optional per-guild allow-lists confine
commands to specific channels (`MATRIMONY_ALLOWED_CHANNELS`).
- **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.
Expand Down Expand Up @@ -126,8 +128,31 @@ 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 |
| `MATRIMONY_ALLOWED_CHANNELS` | *(unset)* | JSON object mapping guild ids to lists of allowed channel ids (see below) |
| `MATRIMONY_ALLOWED_CHANNELS_FILE` | *(unset)* | Path to a JSON file with the same mapping; used when `MATRIMONY_ALLOWED_CHANNELS` is unset |
| `FLUXPOINT_API_KEY` | Fluxpoint's shared public key | Fluxpoint API token for action GIFs; set to empty to disable |

### Channel restrictions

`MATRIMONY_ALLOWED_CHANNELS` takes a JSON object that maps a guild id to
the list of channel ids where commands are accepted:

```bash
MATRIMONY_ALLOWED_CHANNELS='{"123456789012345678": ["111222333444555666", "777888999000111222"]}'
```

A guild absent from the mapping is unrestricted (existing deployments are
unaffected). A guild mapped to an empty list (`"123": []`) accepts commands
in no channel. Commands used outside the allowed channels are **silently
ignored** — the message is never dispatched, matching how the bot already
ignores its own messages. DMs are never restricted. Threads are their own
channels: add the thread's id if commands should work there.

For larger setups, put the same JSON object in a file and point
`MATRIMONY_ALLOWED_CHANNELS_FILE` at it (the inline variable wins when
both are set). A malformed value logs a warning and is treated as no
restriction, so a typo can't lock every server out of the bot.

## Action GIFs

Action replies embed a random SFW anime GIF fetched from the Fluxpoint API
Expand Down
18 changes: 14 additions & 4 deletions matrimony/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,20 @@ async def _get_prefix(self, bot: MatrimonyBot, message: discord.Message):
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
Returns False for this bot's own messages (self-loop guard) and for
messages in channels outside the guild's allow-list
(``MATRIMONY_ALLOWED_CHANNELS``) — those commands are silently
ignored. Guilds with no entry in the mapping are unrestricted, and
DMs are never restricted. Other bots' messages ARE processed so
that bot accounts can propose, accept, and adopt like human
users."""
if self.user is not None and message.author.id == self.user.id:
return False
guild = message.guild
if guild is None:
return True
allowed = self.config.allowed_channels.get(guild.id)
return allowed is None or message.channel.id in allowed

async def process_commands(self, message: discord.Message) -> None:
"""Dispatch ``message`` as a command.
Expand Down
75 changes: 74 additions & 1 deletion matrimony/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,79 @@

from __future__ import annotations

import json
import logging
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path

log = logging.getLogger("matrimony")

# 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"


def _parse_allowed_channels(raw: str, source: str) -> dict[int, frozenset[int]]:
"""Parse a JSON object mapping guild ids to lists of channel ids.

Any malformed input (bad JSON, non-object top level, non-list values,
non-numeric ids) logs a warning and yields no restrictions, so a typo
can never lock every guild out of the bot."""
try:
data = json.loads(raw)
except ValueError:
log.warning("%s is not valid JSON; ignoring it", source)
return {}
if not isinstance(data, dict):
log.warning(
"%s must be a JSON object mapping guild ids to channel id "
"lists; ignoring it",
source,
)
return {}
try:
out: dict[int, frozenset[int]] = {}
for guild_id, channel_ids in data.items():
if not isinstance(channel_ids, list):
raise TypeError(f"{guild_id!r} is not a channel id list")
out[int(guild_id)] = frozenset(int(c) for c in channel_ids)
except (TypeError, ValueError):
log.warning(
"%s must map guild ids to lists of channel ids; ignoring it",
source,
)
return {}
return out


def _allowed_channels_from_env(env: dict[str, str]) -> dict[int, frozenset[int]]:
"""Resolve the per-guild channel allow-list.

``MATRIMONY_ALLOWED_CHANNELS`` holds the JSON mapping inline; when it
is unset, ``MATRIMONY_ALLOWED_CHANNELS_FILE`` points to a JSON file
with the same structure."""
raw = env.get("MATRIMONY_ALLOWED_CHANNELS", "").strip()
if raw:
return _parse_allowed_channels(raw, "MATRIMONY_ALLOWED_CHANNELS")
path = env.get("MATRIMONY_ALLOWED_CHANNELS_FILE", "").strip()
if not path:
return {}
try:
raw = Path(path).read_text(encoding="utf-8")
except OSError as exc:
log.warning(
"Could not read MATRIMONY_ALLOWED_CHANNELS_FILE %r: %s; "
"ignoring it",
path,
exc,
)
return {}
return _parse_allowed_channels(
raw, f"MATRIMONY_ALLOWED_CHANNELS_FILE ({path})"
)


@dataclass(frozen=True)
class Config:
"""Runtime configuration, read from environment variables.
Expand All @@ -30,6 +95,12 @@ class Config:
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.
allowed_channels: Per-guild command allow-list: guild id -> the
channel ids where commands are accepted. Guilds absent from
the mapping are unrestricted; a guild mapped to an empty list
blocks every channel. DMs are never restricted. Set via
``MATRIMONY_ALLOWED_CHANNELS`` or
``MATRIMONY_ALLOWED_CHANNELS_FILE``.
"""

token: str = ""
Expand All @@ -41,6 +112,7 @@ class Config:
max_tree_depth: int = 10
proposal_ttl_seconds: int = 300
fluxpoint_api_key: str = DEFAULT_FLUXPOINT_API_KEY
allowed_channels: dict[int, frozenset[int]] = field(default_factory=dict)

@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> Config:
Expand All @@ -57,4 +129,5 @@ def from_env(cls, env: dict[str, str] | None = None) -> Config:
fluxpoint_api_key=env.get(
"FLUXPOINT_API_KEY", DEFAULT_FLUXPOINT_API_KEY
),
allowed_channels=_allowed_channels_from_env(env),
)
Loading
Loading