From c894cc30849f37558c242a8c57bff24db9b3a268 Mon Sep 17 00:00:00 2001 From: commdevin Date: Thu, 8 Oct 2026 20:23:37 +0000 Subject: [PATCH] Add per-guild channel restrictions (MATRIMONY_ALLOWED_CHANNELS) Guild ids map to lists of allowed channel ids via the MATRIMONY_ALLOWED_CHANNELS env var (inline JSON) or MATRIMONY_ALLOWED_CHANNELS_FILE (path to a JSON file; the inline var wins when both are set). - Check runs in should_process(), before command dispatch: commands in non-allowed channels are silently ignored, matching how the bot already ignores its own messages. - Guilds absent from the mapping are unrestricted, DMs are never restricted, and a guild mapped to [] accepts commands nowhere. - Malformed JSON / wrong shapes log a warning and are treated as no restriction (fail-open, so a typo can't lock every server out). Docs: README config table + new 'Channel restrictions' section and .env.example entries. Tests: tests/test_allowed_channels.py covers parsing (inline/file/precedence/bad input) and the dispatch gate. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 7 ++ README.md | 25 ++++ matrimony/bot.py | 18 ++- matrimony/config.py | 75 +++++++++++- tests/test_allowed_channels.py | 215 +++++++++++++++++++++++++++++++++ 5 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 tests/test_allowed_channels.py diff --git a/.env.example b/.env.example index ffc1bb9..9c18656 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 66079a4..a9466d4 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/matrimony/bot.py b/matrimony/bot.py index ac8db96..053dad6 100644 --- a/matrimony/bot.py +++ b/matrimony/bot.py @@ -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. diff --git a/matrimony/config.py b/matrimony/config.py index bb62151..51aa44a 100644 --- a/matrimony/config.py +++ b/matrimony/config.py @@ -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. @@ -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 = "" @@ -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: @@ -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), ) diff --git a/tests/test_allowed_channels.py b/tests/test_allowed_channels.py new file mode 100644 index 0000000..fe8bcd5 --- /dev/null +++ b/tests/test_allowed_channels.py @@ -0,0 +1,215 @@ +"""Per-guild channel restrictions: MATRIMONY_ALLOWED_CHANNELS / +MATRIMONY_ALLOWED_CHANNELS_FILE parsing, and the dispatch gate that +silently ignores commands outside a guild's allowed channels.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from matrimony.bot import MatrimonyBot +from matrimony.cogs.matr import MatrCog +from matrimony.config import Config + +BOT_USER_ID = 999999 + + +def make_bot(**config_kwargs) -> MatrimonyBot: + bot = MatrimonyBot(Config(database_path=":memory:", **config_kwargs)) + 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 wire_message( + bot, + author, + content: str = "!matr help", + guild_id: int | None = 42, + channel_id: int = 7, +): + """A message fake enough to run through the real command parser.""" + guild = ( + None + if guild_id is None + else SimpleNamespace(id=guild_id, get_member=lambda uid: None) + ) + return SimpleNamespace( + id=1, + author=author, + content=content, + guild=guild, + channel=SimpleNamespace(id=channel_id), + to_message_reference_dict=lambda: {"message_id": 1}, + attachments=[], + _state=bot._connection, + ) + + +def stub_send(bot) -> AsyncMock: + """Intercept the HTTP layer so replies don't hit the network.""" + bot._connection.http.send_message = AsyncMock(return_value={"id": "2"}) + # Skip real discord.Message construction for the sent reply. + bot._connection.create_message = ( + lambda channel, data: SimpleNamespace(id=int(data.get("id", 0))) + ) + return bot._connection.http.send_message + + +def user(uid: int): + return SimpleNamespace(id=uid, bot=False, name=f"user{uid}") + + +def run(coro): + return asyncio.get_event_loop_policy().new_event_loop().run_until_complete(coro) + + +# --------------------------------------------------------------------------- +# Configuration parsing +# --------------------------------------------------------------------------- + + +def test_no_env_no_restrictions() -> None: + assert Config.from_env({}).allowed_channels == {} + + +def test_inline_json() -> None: + config = Config.from_env( + { + "MATRIMONY_ALLOWED_CHANNELS": '{"42": ["7", "8"], "99": []}', + } + ) + assert config.allowed_channels == {42: frozenset({7, 8}), 99: frozenset()} + + +def test_inline_json_numeric_ids() -> None: + config = Config.from_env( + {"MATRIMONY_ALLOWED_CHANNELS": '{"42": [7, 8]}'} + ) + assert config.allowed_channels == {42: frozenset({7, 8})} + + +def test_invalid_json_warns_and_unrestricts(caplog) -> None: + config = Config.from_env( + {"MATRIMONY_ALLOWED_CHANNELS": "not json"} + ) + assert config.allowed_channels == {} + assert "MATRIMONY_ALLOWED_CHANNELS" in caplog.text + + +def test_non_object_json_warns(caplog) -> None: + config = Config.from_env({"MATRIMONY_ALLOWED_CHANNELS": '["7"]'}) + assert config.allowed_channels == {} + assert "MATRIMONY_ALLOWED_CHANNELS" in caplog.text + + +def test_non_list_values_warn(caplog) -> None: + config = Config.from_env( + {"MATRIMONY_ALLOWED_CHANNELS": '{"42": "7"}'} + ) + assert config.allowed_channels == {} + assert "MATRIMONY_ALLOWED_CHANNELS" in caplog.text + + +def test_non_numeric_ids_warn(caplog) -> None: + config = Config.from_env( + {"MATRIMONY_ALLOWED_CHANNELS": '{"42": ["seven"]}'} + ) + assert config.allowed_channels == {} + assert "MATRIMONY_ALLOWED_CHANNELS" in caplog.text + + +def test_file_config(tmp_path) -> None: + path = tmp_path / "channels.json" + path.write_text('{"42": ["7"]}') + config = Config.from_env( + {"MATRIMONY_ALLOWED_CHANNELS_FILE": str(path)} + ) + assert config.allowed_channels == {42: frozenset({7})} + + +def test_file_missing_warns_and_unrestricts(caplog, tmp_path) -> None: + config = Config.from_env( + { + "MATRIMONY_ALLOWED_CHANNELS_FILE": str( + tmp_path / "does-not-exist.json" + ) + } + ) + assert config.allowed_channels == {} + assert "MATRIMONY_ALLOWED_CHANNELS_FILE" in caplog.text + + +def test_inline_beats_file(tmp_path) -> None: + path = tmp_path / "channels.json" + path.write_text('{"42": ["7"]}') + config = Config.from_env( + { + "MATRIMONY_ALLOWED_CHANNELS": '{"1": ["2"]}', + "MATRIMONY_ALLOWED_CHANNELS_FILE": str(path), + } + ) + assert config.allowed_channels == {1: frozenset({2})} + + +# --------------------------------------------------------------------------- +# Dispatch gate +# --------------------------------------------------------------------------- + + +def test_command_in_allowed_channel_dispatches() -> None: + bot = make_bot(allowed_channels={42: frozenset({7})}) + send = stub_send(bot) + msg = wire_message(bot, user(1), channel_id=7) + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_command_in_disallowed_channel_ignored() -> None: + bot = make_bot(allowed_channels={42: frozenset({7})}) + send = stub_send(bot) + msg = wire_message(bot, user(1), channel_id=8) + run(bot.process_commands(msg)) + send.assert_not_awaited() + + +def test_unlisted_guild_unrestricted() -> None: + bot = make_bot(allowed_channels={99: frozenset({7})}) + send = stub_send(bot) + msg = wire_message(bot, user(1), guild_id=42, channel_id=1) + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_empty_allow_list_blocks_guild() -> None: + bot = make_bot(allowed_channels={42: frozenset()}) + send = stub_send(bot) + msg = wire_message(bot, user(1), channel_id=7) + run(bot.process_commands(msg)) + send.assert_not_awaited() + + +def test_dms_never_restricted() -> None: + bot = make_bot(allowed_channels={42: frozenset({7})}) + send = stub_send(bot) + msg = wire_message(bot, user(1), guild_id=None) + run(bot.process_commands(msg)) + send.assert_awaited_once() + + +def test_restriction_via_env_end_to_end() -> None: + config = Config.from_env( + { + "MATRIMONY_DB_PATH": ":memory:", + "MATRIMONY_ALLOWED_CHANNELS": '{"42": ["7"]}', + } + ) + bot = MatrimonyBot(config) + MatrCog(bot) + bot._connection.user = SimpleNamespace(id=BOT_USER_ID) # type: ignore[attr-defined] + send = stub_send(bot) + run(bot.process_commands(wire_message(bot, user(1), channel_id=7))) + send.assert_awaited_once() + send.reset_mock() + run(bot.process_commands(wire_message(bot, user(1), channel_id=8))) + send.assert_not_awaited()