From d2723995466e382d8d5fea70f7d1fc625cc2bbdd Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Thu, 20 Aug 2026 10:36:52 -0500 Subject: [PATCH 1/9] feat(discord): add scalar guild-settings REST builder Rebuild A5 on current main as the issue-scoped pure request-builder contract. Remove the premature model-callable mutator and its arbitrary guild target; preserve only approved-scalar validation and focused tests. Fixes #86431 Part of #79564 --- tests/tools/test_discord_guild_settings.py | 166 +++++++++++++++++++ tools/discord_api/guild_settings.py | 184 +++++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 tests/tools/test_discord_guild_settings.py create mode 100644 tools/discord_api/guild_settings.py diff --git a/tests/tools/test_discord_guild_settings.py b/tests/tools/test_discord_guild_settings.py new file mode 100644 index 000000000000..9b06cd64b5a0 --- /dev/null +++ b/tests/tools/test_discord_guild_settings.py @@ -0,0 +1,166 @@ +"""Tests for the Discord guild-settings request contract.""" + +import pytest + +from tools.discord_api.guild_settings import GuildSettingsError, edit_guild_request + +GUILD_ID = "123456789012345678" +CHANNEL_ID = "987654321098765432" + + +def test_allowed_scalar_edits_full_payload(): + request = edit_guild_request( + GUILD_ID, + name="Hermes HQ", + description="A fine place", + verification_level=4, + default_message_notifications=1, + explicit_content_filter=2, + nsfw_level=3, + premium_progress_bar_enabled=True, + system_channel_id=CHANNEL_ID, + rules_channel_id=CHANNEL_ID, + public_updates_channel_id=CHANNEL_ID, + afk_timeout=3600, + ) + + assert request == { + "method": "PATCH", + "path": f"/guilds/{GUILD_ID}", + "json": { + "name": "Hermes HQ", + "description": "A fine place", + "verification_level": 4, + "default_message_notifications": 1, + "explicit_content_filter": 2, + "nsfw_level": 3, + "premium_progress_bar_enabled": True, + "system_channel_id": CHANNEL_ID, + "rules_channel_id": CHANNEL_ID, + "public_updates_channel_id": CHANNEL_ID, + "afk_timeout": 3600, + }, + } + + +def test_minimum_allowed_values_and_nullable_fields(): + request = edit_guild_request( + GUILD_ID, + verification_level=0, + default_message_notifications=0, + explicit_content_filter=0, + nsfw_level=0, + premium_progress_bar_enabled=False, + afk_timeout=60, + description=None, + system_channel_id=None, + ) + + assert request["json"] == { + "verification_level": 0, + "default_message_notifications": 0, + "explicit_content_filter": 0, + "nsfw_level": 0, + "premium_progress_bar_enabled": False, + "afk_timeout": 60, + "description": None, + "system_channel_id": None, + } + + +@pytest.mark.parametrize( + "bad_key", + ["widget_enabled", "system_channel_flags", "bogus_field"], +) +def test_disallowed_key_rejected(bad_key): + with pytest.raises(GuildSettingsError, match="unsupported guild setting"): + edit_guild_request(GUILD_ID, **{bad_key: True}) + + +def test_name_max_length_ok(): + assert edit_guild_request(GUILD_ID, name="x" * 100)["json"]["name"] == "x" * 100 + + +def test_name_too_long_rejected(): + with pytest.raises(GuildSettingsError, match="exceeds 100"): + edit_guild_request(GUILD_ID, name="x" * 101) + + +def test_name_must_be_string(): + with pytest.raises(GuildSettingsError, match="must be a string"): + edit_guild_request(GUILD_ID, name=123) + + +def test_description_max_ok(): + assert len(edit_guild_request(GUILD_ID, description="x" * 1024)["json"]["description"]) == 1024 + + +def test_description_too_long_rejected(): + with pytest.raises(GuildSettingsError, match="exceeds 1024"): + edit_guild_request(GUILD_ID, description="x" * 1025) + + +@pytest.mark.parametrize("level", [-1, 5, 100]) +def test_verification_level_out_of_range(level): + with pytest.raises(GuildSettingsError, match="between 0 and 4"): + edit_guild_request(GUILD_ID, verification_level=level) + + +@pytest.mark.parametrize("bad", [True, "3", 3.5]) +def test_verification_level_wrong_type(bad): + with pytest.raises(GuildSettingsError, match="must be an integer"): + edit_guild_request(GUILD_ID, verification_level=bad) + + +@pytest.mark.parametrize( + "field", + ["system_channel_id", "rules_channel_id", "public_updates_channel_id"], +) +@pytest.mark.parametrize( + "bad", + ["not-a-snowflake", "123abc", -5, 0, 2**64, 1.5, True], +) +def test_channel_id_invalid_rejected(field, bad): + with pytest.raises(GuildSettingsError): + edit_guild_request(GUILD_ID, **{field: bad}) + + +def test_channel_id_none_allowed(): + assert edit_guild_request(GUILD_ID, system_channel_id=None)["json"] == { + "system_channel_id": None + } + + +def test_snowflakes_are_canonical_decimal_strings(): + request = edit_guild_request( + 123456789012345678, + rules_channel_id=987654321098765432, + system_channel_id="000987654321098765432", + ) + + assert request["path"] == "/guilds/123456789012345678" + assert request["json"]["rules_channel_id"] == "987654321098765432" + assert request["json"]["system_channel_id"] == "987654321098765432" + + +@pytest.mark.parametrize("guild_id", ["guild-abc", "", "0000", 0, -1, 2**64, True]) +def test_invalid_guild_id_rejected(guild_id): + with pytest.raises(GuildSettingsError): + edit_guild_request(guild_id, name="Hermes") + + +@pytest.mark.parametrize("timeout", [59, 3601, 0, -1]) +def test_afk_timeout_out_of_range(timeout): + with pytest.raises(GuildSettingsError, match="between 60 and 3600"): + edit_guild_request(GUILD_ID, afk_timeout=timeout) + + +def test_only_provided_fields_in_payload(): + assert edit_guild_request(GUILD_ID, name="Renamed")["json"] == { + "name": "Renamed" + } + + +def test_empty_patch_rejected(): + with pytest.raises(GuildSettingsError, match="no guild settings provided"): + edit_guild_request(GUILD_ID) diff --git a/tools/discord_api/guild_settings.py b/tools/discord_api/guild_settings.py new file mode 100644 index 000000000000..b93d109abbec --- /dev/null +++ b/tools/discord_api/guild_settings.py @@ -0,0 +1,184 @@ +"""Discord REST v10 guild-settings request builder (approved scalars only). + +This module is deliberately transport-free. It validates the scalar guild +settings Hermes exposes and returns a request descriptor for the model-callable +consumer in :mod:`tools.discord_guild_settings_tool`. +""" + +from __future__ import annotations + +import re +from typing import Any, Callable, Dict, Final + +__all__ = [ + "AFK_TIMEOUT_MAX", + "AFK_TIMEOUT_MIN", + "DESCRIPTION_MAX", + "EDITABLE_GUILD_SETTINGS", + "GuildSettingsError", + "NAME_MAX", + "edit_guild_request", +] + +NAME_MAX: Final = 100 +DESCRIPTION_MAX: Final = 1024 +VERIFICATION_LEVEL_MIN: Final = 0 +VERIFICATION_LEVEL_MAX: Final = 4 +DEFAULT_MESSAGE_NOTIFICATIONS_MIN: Final = 0 +DEFAULT_MESSAGE_NOTIFICATIONS_MAX: Final = 1 +EXPLICIT_CONTENT_FILTER_MIN: Final = 0 +EXPLICIT_CONTENT_FILTER_MAX: Final = 2 +NSFW_LEVEL_MIN: Final = 0 +NSFW_LEVEL_MAX: Final = 3 +AFK_TIMEOUT_MIN: Final = 60 +AFK_TIMEOUT_MAX: Final = 3600 + +_SNOWFLAKE_MAX: Final = (1 << 64) - 1 +_SNOWFLAKE_RE: Final = re.compile(r"^[0-9]{1,64}$") + + +class GuildSettingsError(ValueError): + """Raised when a guild-settings request violates the exposed contract.""" + + +def _validate_snowflake(value: Any, field: str) -> str: + """Return a positive Discord snowflake in canonical decimal-string form.""" + if isinstance(value, bool): + raise GuildSettingsError(f"{field!r} must be a snowflake, not a bool") + + if isinstance(value, int): + parsed = value + elif isinstance(value, str) and _SNOWFLAKE_RE.fullmatch(value): + parsed = int(value, 10) + else: + raise GuildSettingsError( + f"{field!r} must be a snowflake (int or decimal str), " + f"got {type(value).__name__}" + ) + + if not 1 <= parsed <= _SNOWFLAKE_MAX: + raise GuildSettingsError(f"{field!r} snowflake out of range: {value!r}") + + # Discord's wire contract represents snowflakes as decimal strings. This + # also removes ambiguous zero padding from otherwise equivalent identities. + return str(parsed) + + +def _validate_str( + value: Any, + field: str, + max_len: int, + *, + allow_none: bool = False, +) -> str | None: + if value is None and allow_none: + return None + if not isinstance(value, str): + raise GuildSettingsError(f"{field!r} must be a string") + if len(value) > max_len: + raise GuildSettingsError(f"{field!r} exceeds {max_len} characters") + return value + + +def _validate_int_range(value: Any, field: str, lo: int, hi: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise GuildSettingsError(f"{field!r} must be an integer") + if not lo <= value <= hi: + raise GuildSettingsError(f"{field!r} must be between {lo} and {hi}") + return value + + +def _validate_bool(value: Any, field: str) -> bool: + if not isinstance(value, bool): + raise GuildSettingsError(f"{field!r} must be a boolean") + return value + + +def _validate_optional_snowflake(value: Any, field: str) -> str | None: + if value is None: + return None + return _validate_snowflake(value, field) + + +_FIELD_VALIDATORS: Dict[str, Callable[[Any], Any]] = { + "name": lambda value: _validate_str(value, "name", NAME_MAX), + "description": lambda value: _validate_str( + value, + "description", + DESCRIPTION_MAX, + allow_none=True, + ), + "verification_level": lambda value: _validate_int_range( + value, + "verification_level", + VERIFICATION_LEVEL_MIN, + VERIFICATION_LEVEL_MAX, + ), + "default_message_notifications": lambda value: _validate_int_range( + value, + "default_message_notifications", + DEFAULT_MESSAGE_NOTIFICATIONS_MIN, + DEFAULT_MESSAGE_NOTIFICATIONS_MAX, + ), + "explicit_content_filter": lambda value: _validate_int_range( + value, + "explicit_content_filter", + EXPLICIT_CONTENT_FILTER_MIN, + EXPLICIT_CONTENT_FILTER_MAX, + ), + "nsfw_level": lambda value: _validate_int_range( + value, + "nsfw_level", + NSFW_LEVEL_MIN, + NSFW_LEVEL_MAX, + ), + "premium_progress_bar_enabled": lambda value: _validate_bool( + value, + "premium_progress_bar_enabled", + ), + "system_channel_id": lambda value: _validate_optional_snowflake( + value, + "system_channel_id", + ), + "rules_channel_id": lambda value: _validate_optional_snowflake( + value, + "rules_channel_id", + ), + "public_updates_channel_id": lambda value: _validate_optional_snowflake( + value, + "public_updates_channel_id", + ), + "afk_timeout": lambda value: _validate_int_range( + value, + "afk_timeout", + AFK_TIMEOUT_MIN, + AFK_TIMEOUT_MAX, + ), +} + +EDITABLE_GUILD_SETTINGS: Final = frozenset(_FIELD_VALIDATORS) + + +def edit_guild_request(guild_id: Any, **fields: Any) -> Dict[str, Any]: + """Build a validated ``PATCH /guilds/{guild_id}`` request descriptor. + + Snowflakes are normalized to canonical decimal strings. At least one + editable field is required; an empty PATCH is never emitted. + """ + canonical_guild_id = _validate_snowflake(guild_id, "guild_id") + + payload: Dict[str, Any] = {} + for key, value in fields.items(): + validator = _FIELD_VALIDATORS.get(key) + if validator is None: + raise GuildSettingsError(f"unsupported guild setting: {key!r}") + payload[key] = validator(value) + + if not payload: + raise GuildSettingsError("no guild settings provided") + + return { + "method": "PATCH", + "path": f"/guilds/{canonical_guild_id}", + "json": payload, + } From 6406809494b92570da10010f53cc835c6aff74f4 Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Thu, 20 Aug 2026 11:28:33 -0500 Subject: [PATCH 2/9] fix(discord): enforce current guild-settings wire contract --- tools/discord_api/guild_settings.py | 88 +++++++++++++++++------------ 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/tools/discord_api/guild_settings.py b/tools/discord_api/guild_settings.py index b93d109abbec..ae5216b76d84 100644 --- a/tools/discord_api/guild_settings.py +++ b/tools/discord_api/guild_settings.py @@ -1,8 +1,8 @@ """Discord REST v10 guild-settings request builder (approved scalars only). This module is deliberately transport-free. It validates the scalar guild -settings Hermes exposes and returns a request descriptor for the model-callable -consumer in :mod:`tools.discord_guild_settings_tool`. +settings Hermes exposes and returns a request descriptor for the existing +``discord_admin`` consumer. """ from __future__ import annotations @@ -11,8 +11,7 @@ from typing import Any, Callable, Dict, Final __all__ = [ - "AFK_TIMEOUT_MAX", - "AFK_TIMEOUT_MIN", + "AFK_TIMEOUT_VALUES", "DESCRIPTION_MAX", "EDITABLE_GUILD_SETTINGS", "GuildSettingsError", @@ -20,18 +19,13 @@ "edit_guild_request", ] +NAME_MIN: Final = 2 NAME_MAX: Final = 100 DESCRIPTION_MAX: Final = 1024 -VERIFICATION_LEVEL_MIN: Final = 0 -VERIFICATION_LEVEL_MAX: Final = 4 -DEFAULT_MESSAGE_NOTIFICATIONS_MIN: Final = 0 -DEFAULT_MESSAGE_NOTIFICATIONS_MAX: Final = 1 -EXPLICIT_CONTENT_FILTER_MIN: Final = 0 -EXPLICIT_CONTENT_FILTER_MAX: Final = 2 -NSFW_LEVEL_MIN: Final = 0 -NSFW_LEVEL_MAX: Final = 3 -AFK_TIMEOUT_MIN: Final = 60 -AFK_TIMEOUT_MAX: Final = 3600 +VERIFICATION_LEVEL_VALUES: Final = frozenset(range(5)) +DEFAULT_MESSAGE_NOTIFICATION_VALUES: Final = frozenset(range(2)) +EXPLICIT_CONTENT_FILTER_VALUES: Final = frozenset(range(3)) +AFK_TIMEOUT_VALUES: Final = frozenset({60, 300, 900, 1800, 3600}) _SNOWFLAKE_MAX: Final = (1 << 64) - 1 _SNOWFLAKE_RE: Final = re.compile(r"^[0-9]{1,64}$") @@ -64,6 +58,18 @@ def _validate_snowflake(value: Any, field: str) -> str: return str(parsed) +def _validate_name(value: Any) -> str: + if not isinstance(value, str): + raise GuildSettingsError("'name' must be a string") + if value != value.strip(): + raise GuildSettingsError("'name' cannot have leading or trailing whitespace") + if not NAME_MIN <= len(value) <= NAME_MAX: + raise GuildSettingsError( + f"'name' must be between {NAME_MIN} and {NAME_MAX} characters" + ) + return value + + def _validate_str( value: Any, field: str, @@ -80,11 +86,20 @@ def _validate_str( return value -def _validate_int_range(value: Any, field: str, lo: int, hi: int) -> int: +def _validate_int_enum( + value: Any, + field: str, + allowed: frozenset[int], + *, + allow_none: bool = False, +) -> int | None: + if value is None and allow_none: + return None if isinstance(value, bool) or not isinstance(value, int): raise GuildSettingsError(f"{field!r} must be an integer") - if not lo <= value <= hi: - raise GuildSettingsError(f"{field!r} must be between {lo} and {hi}") + if value not in allowed: + allowed_text = ", ".join(str(item) for item in sorted(allowed)) + raise GuildSettingsError(f"{field!r} must be one of: {allowed_text}") return value @@ -101,41 +116,39 @@ def _validate_optional_snowflake(value: Any, field: str) -> str | None: _FIELD_VALIDATORS: Dict[str, Callable[[Any], Any]] = { - "name": lambda value: _validate_str(value, "name", NAME_MAX), + "name": _validate_name, "description": lambda value: _validate_str( value, "description", DESCRIPTION_MAX, allow_none=True, ), - "verification_level": lambda value: _validate_int_range( + "verification_level": lambda value: _validate_int_enum( value, "verification_level", - VERIFICATION_LEVEL_MIN, - VERIFICATION_LEVEL_MAX, + VERIFICATION_LEVEL_VALUES, + allow_none=True, ), - "default_message_notifications": lambda value: _validate_int_range( + "default_message_notifications": lambda value: _validate_int_enum( value, "default_message_notifications", - DEFAULT_MESSAGE_NOTIFICATIONS_MIN, - DEFAULT_MESSAGE_NOTIFICATIONS_MAX, + DEFAULT_MESSAGE_NOTIFICATION_VALUES, + allow_none=True, ), - "explicit_content_filter": lambda value: _validate_int_range( + "explicit_content_filter": lambda value: _validate_int_enum( value, "explicit_content_filter", - EXPLICIT_CONTENT_FILTER_MIN, - EXPLICIT_CONTENT_FILTER_MAX, - ), - "nsfw_level": lambda value: _validate_int_range( - value, - "nsfw_level", - NSFW_LEVEL_MIN, - NSFW_LEVEL_MAX, + EXPLICIT_CONTENT_FILTER_VALUES, + allow_none=True, ), "premium_progress_bar_enabled": lambda value: _validate_bool( value, "premium_progress_bar_enabled", ), + "afk_channel_id": lambda value: _validate_optional_snowflake( + value, + "afk_channel_id", + ), "system_channel_id": lambda value: _validate_optional_snowflake( value, "system_channel_id", @@ -148,11 +161,14 @@ def _validate_optional_snowflake(value: Any, field: str) -> str | None: value, "public_updates_channel_id", ), - "afk_timeout": lambda value: _validate_int_range( + "safety_alerts_channel_id": lambda value: _validate_optional_snowflake( + value, + "safety_alerts_channel_id", + ), + "afk_timeout": lambda value: _validate_int_enum( value, "afk_timeout", - AFK_TIMEOUT_MIN, - AFK_TIMEOUT_MAX, + AFK_TIMEOUT_VALUES, ), } From 1c27ee90591a9943376af6737a2917dc871c9a05 Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Thu, 20 Aug 2026 11:29:10 -0500 Subject: [PATCH 3/9] test(discord): pin guild-settings REST contract --- tests/tools/test_discord_guild_settings.py | 77 +++++++++++----------- 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/tests/tools/test_discord_guild_settings.py b/tests/tools/test_discord_guild_settings.py index 9b06cd64b5a0..f90e9419bf64 100644 --- a/tests/tools/test_discord_guild_settings.py +++ b/tests/tools/test_discord_guild_settings.py @@ -16,11 +16,12 @@ def test_allowed_scalar_edits_full_payload(): verification_level=4, default_message_notifications=1, explicit_content_filter=2, - nsfw_level=3, premium_progress_bar_enabled=True, + afk_channel_id=CHANNEL_ID, system_channel_id=CHANNEL_ID, rules_channel_id=CHANNEL_ID, public_updates_channel_id=CHANNEL_ID, + safety_alerts_channel_id=CHANNEL_ID, afk_timeout=3600, ) @@ -33,76 +34,65 @@ def test_allowed_scalar_edits_full_payload(): "verification_level": 4, "default_message_notifications": 1, "explicit_content_filter": 2, - "nsfw_level": 3, "premium_progress_bar_enabled": True, + "afk_channel_id": CHANNEL_ID, "system_channel_id": CHANNEL_ID, "rules_channel_id": CHANNEL_ID, "public_updates_channel_id": CHANNEL_ID, + "safety_alerts_channel_id": CHANNEL_ID, "afk_timeout": 3600, }, } -def test_minimum_allowed_values_and_nullable_fields(): +def test_nullable_fields_and_falsey_values_are_preserved(): request = edit_guild_request( GUILD_ID, - verification_level=0, + verification_level=None, default_message_notifications=0, - explicit_content_filter=0, - nsfw_level=0, + explicit_content_filter=None, premium_progress_bar_enabled=False, - afk_timeout=60, description=None, - system_channel_id=None, + afk_channel_id=None, ) assert request["json"] == { - "verification_level": 0, + "verification_level": None, "default_message_notifications": 0, - "explicit_content_filter": 0, - "nsfw_level": 0, + "explicit_content_filter": None, "premium_progress_bar_enabled": False, - "afk_timeout": 60, "description": None, - "system_channel_id": None, + "afk_channel_id": None, } @pytest.mark.parametrize( "bad_key", - ["widget_enabled", "system_channel_flags", "bogus_field"], + ["nsfw_level", "widget_enabled", "system_channel_flags", "bogus_field"], ) def test_disallowed_key_rejected(bad_key): with pytest.raises(GuildSettingsError, match="unsupported guild setting"): edit_guild_request(GUILD_ID, **{bad_key: True}) -def test_name_max_length_ok(): +def test_name_contract(): + assert edit_guild_request(GUILD_ID, name="xx")["json"]["name"] == "xx" assert edit_guild_request(GUILD_ID, name="x" * 100)["json"]["name"] == "x" * 100 + for bad in ("x", " x", "x ", "x" * 101, 123): + with pytest.raises(GuildSettingsError): + edit_guild_request(GUILD_ID, name=bad) -def test_name_too_long_rejected(): - with pytest.raises(GuildSettingsError, match="exceeds 100"): - edit_guild_request(GUILD_ID, name="x" * 101) - -def test_name_must_be_string(): - with pytest.raises(GuildSettingsError, match="must be a string"): - edit_guild_request(GUILD_ID, name=123) - - -def test_description_max_ok(): +def test_description_max_contract(): assert len(edit_guild_request(GUILD_ID, description="x" * 1024)["json"]["description"]) == 1024 - - -def test_description_too_long_rejected(): with pytest.raises(GuildSettingsError, match="exceeds 1024"): edit_guild_request(GUILD_ID, description="x" * 1025) @pytest.mark.parametrize("level", [-1, 5, 100]) def test_verification_level_out_of_range(level): - with pytest.raises(GuildSettingsError, match="between 0 and 4"): + with pytest.raises(GuildSettingsError, match="must be one of"): edit_guild_request(GUILD_ID, verification_level=level) @@ -114,7 +104,13 @@ def test_verification_level_wrong_type(bad): @pytest.mark.parametrize( "field", - ["system_channel_id", "rules_channel_id", "public_updates_channel_id"], + [ + "afk_channel_id", + "system_channel_id", + "rules_channel_id", + "public_updates_channel_id", + "safety_alerts_channel_id", + ], ) @pytest.mark.parametrize( "bad", @@ -125,15 +121,9 @@ def test_channel_id_invalid_rejected(field, bad): edit_guild_request(GUILD_ID, **{field: bad}) -def test_channel_id_none_allowed(): - assert edit_guild_request(GUILD_ID, system_channel_id=None)["json"] == { - "system_channel_id": None - } - - def test_snowflakes_are_canonical_decimal_strings(): request = edit_guild_request( - 123456789012345678, + "000123456789012345678", rules_channel_id=987654321098765432, system_channel_id="000987654321098765432", ) @@ -149,9 +139,16 @@ def test_invalid_guild_id_rejected(guild_id): edit_guild_request(guild_id, name="Hermes") -@pytest.mark.parametrize("timeout", [59, 3601, 0, -1]) -def test_afk_timeout_out_of_range(timeout): - with pytest.raises(GuildSettingsError, match="between 60 and 3600"): +@pytest.mark.parametrize("timeout", [60, 300, 900, 1800, 3600]) +def test_afk_timeout_discrete_values_allowed(timeout): + assert edit_guild_request(GUILD_ID, afk_timeout=timeout)["json"] == { + "afk_timeout": timeout + } + + +@pytest.mark.parametrize("timeout", [59, 61, 299, 301, 3601, 0, -1]) +def test_afk_timeout_non_enum_values_rejected(timeout): + with pytest.raises(GuildSettingsError, match="60, 300, 900, 1800, 3600"): edit_guild_request(GUILD_ID, afk_timeout=timeout) From bfc828192935a3863fdbd8644c3b8edbe9c0dd11 Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Thu, 20 Aug 2026 11:34:02 -0500 Subject: [PATCH 4/9] feat(discord): add request-owned guild-settings consumer --- tools/discord_guild_settings_tool.py | 192 +++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 tools/discord_guild_settings_tool.py diff --git a/tools/discord_guild_settings_tool.py b/tools/discord_guild_settings_tool.py new file mode 100644 index 000000000000..b82392545af8 --- /dev/null +++ b/tools/discord_guild_settings_tool.py @@ -0,0 +1,192 @@ +"""Request-owned Discord guild-settings consumer. + +The model may choose *which approved scalar fields* to change, but never the +Discord guild, requester, profile credential, or transport. Those identities +come from the task-local gateway context and the existing Discord REST adapter. +""" + +from __future__ import annotations + +import json +from typing import Any + +from gateway.session_context import get_session_env +from tools import discord_tool as _discord +from tools.discord_api.guild_settings import GuildSettingsError, edit_guild_request +from tools.registry import registry, tool_error + +_ACTION_NAME = "edit_current_guild_settings" + +_SETTINGS_SCHEMA: dict[str, Any] = { + "type": "object", + "description": ( + "Approved scalar settings for the active Discord request guild. " + "The target guild is request-owned and cannot be supplied by the model." + ), + "properties": { + "name": {"type": "string", "minLength": 2, "maxLength": 100}, + "description": { + "anyOf": [ + {"type": "string", "maxLength": 1024}, + {"type": "null"}, + ] + }, + "verification_level": { + "anyOf": [ + {"type": "integer", "enum": [0, 1, 2, 3, 4]}, + {"type": "null"}, + ] + }, + "default_message_notifications": { + "anyOf": [ + {"type": "integer", "enum": [0, 1]}, + {"type": "null"}, + ] + }, + "explicit_content_filter": { + "anyOf": [ + {"type": "integer", "enum": [0, 1, 2]}, + {"type": "null"}, + ] + }, + "premium_progress_bar_enabled": {"type": "boolean"}, + "afk_channel_id": { + "anyOf": [ + {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "null"}, + ] + }, + "system_channel_id": { + "anyOf": [ + {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "null"}, + ] + }, + "rules_channel_id": { + "anyOf": [ + {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "null"}, + ] + }, + "public_updates_channel_id": { + "anyOf": [ + {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "null"}, + ] + }, + "safety_alerts_channel_id": { + "anyOf": [ + {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "null"}, + ] + }, + "afk_timeout": { + "type": "integer", + "enum": [60, 300, 900, 1800, 3600], + }, + }, + "additionalProperties": False, +} + +SCHEMA = { + "name": "discord_guild_settings", + "description": ( + "Edit approved scalar settings on the Discord guild that owns the " + "active authenticated request. The target cannot be redirected to a " + "model-supplied guild ID. Requires the bot's MANAGE_GUILD permission." + ), + "parameters": { + "type": "object", + "properties": {"settings": _SETTINGS_SCHEMA}, + "required": ["settings"], + "additionalProperties": False, + }, +} + + +def _action_enabled() -> bool: + allowlist = _discord._load_allowed_actions_config() + return allowlist is None or _ACTION_NAME in allowlist + + +def check_discord_guild_settings_requirements() -> bool: + """Require the active profile token and the shared admin-action gate.""" + return bool(_discord._get_bot_token()) and _action_enabled() + + +def edit_current_guild_settings(settings: Any = None) -> str: + """Validate and PATCH the exact guild that owns the active Discord turn.""" + platform = get_session_env("HERMES_SESSION_PLATFORM").strip().lower() + requester_id = get_session_env("HERMES_SESSION_USER_ID").strip() + guild_id = get_session_env("HERMES_SESSION_SCOPE_ID").strip() + + if platform != "discord": + return tool_error( + "discord_guild_settings requires an active Discord request context." + ) + if not requester_id: + return tool_error( + "discord_guild_settings requires an authenticated Discord requester." + ) + if not guild_id: + return tool_error( + "discord_guild_settings requires an active Discord guild context; " + "it is unavailable in DMs and unowned cross-platform sessions." + ) + if not _action_enabled(): + return tool_error( + "Action 'edit_current_guild_settings' is disabled by config " + "(discord.server_actions)." + ) + if settings is None: + settings = {} + if not isinstance(settings, dict): + return tool_error("'settings' must be a JSON object.") + + token = _discord._get_bot_token() + if not token: + return tool_error("DISCORD_BOT_TOKEN not configured for the active profile.") + + try: + request = edit_guild_request(guild_id, **settings) + _discord._discord_request( + request["method"], + request["path"], + token, + body=request["json"], + ) + except GuildSettingsError as exc: + return tool_error(str(exc)) + except _discord.DiscordAPIError as exc: + if exc.status == 403: + return tool_error( + "Discord API 403 (forbidden) on 'edit_current_guild_settings'. " + "Bot lacks MANAGE_GUILD in the active Discord server. " + f"(Raw: {exc.body})" + ) + return tool_error(str(exc)) + except Exception as exc: + return tool_error(f"Unexpected Discord guild-settings error: {exc}") + + canonical_guild_id = request["path"].rsplit("/", 1)[-1] + return json.dumps( + { + "success": True, + "guild_id": canonical_guild_id, + "updated_settings": request["json"], + } + ) + + +def _handler(args: dict[str, Any], **_kwargs: Any) -> str: + return edit_current_guild_settings(args.get("settings")) + + +registry.register( + name="discord_guild_settings", + toolset="discord_admin", + schema=SCHEMA, + handler=_handler, + check_fn=check_discord_guild_settings_requirements, + requires_env=["DISCORD_BOT_TOKEN"], +) From e5cdfe290ff229a2056a72d72988da1c848265ad Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Thu, 20 Aug 2026 11:34:38 -0500 Subject: [PATCH 5/9] test(discord): prove guild-settings owner-to-transport contract --- .../test_discord_guild_settings_consumer.py | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 tests/tools/test_discord_guild_settings_consumer.py diff --git a/tests/tools/test_discord_guild_settings_consumer.py b/tests/tools/test_discord_guild_settings_consumer.py new file mode 100644 index 000000000000..778557ac20fb --- /dev/null +++ b/tests/tools/test_discord_guild_settings_consumer.py @@ -0,0 +1,215 @@ +"""Cross-layer contract for the request-owned guild-settings consumer.""" + +import json +from unittest.mock import Mock + +import pytest + +from gateway.session_context import clear_session_vars, reset_session_vars, set_session_vars +from tools import discord_guild_settings_tool as consumer + +CURRENT_GUILD = "123456789012345678" +OTHER_GUILD = "999999999999999999" +CHANNEL_ID = "987654321098765432" + + +@pytest.fixture(autouse=True) +def _isolated_consumer(monkeypatch): + reset_session_vars() + monkeypatch.setattr(consumer._discord, "_get_bot_token", lambda: "active-profile-token") + monkeypatch.setattr(consumer._discord, "_load_allowed_actions_config", lambda: None) + yield + reset_session_vars() + + +def _bind_discord_request(*, guild_id: str = CURRENT_GUILD, user_id: str = "42"): + return set_session_vars( + platform="discord", + user_id=user_id, + scope_id=guild_id, + profile="worker", + session_key=f"agent:worker:discord:channel:{guild_id}:123", + ) + + +def _error(result: str) -> str: + return str(json.loads(result)["error"]) + + +def test_consumer_uses_request_owned_guild_and_active_profile_token(monkeypatch): + request = Mock(return_value={"id": CURRENT_GUILD}) + monkeypatch.setattr(consumer._discord, "_discord_request", request) + tokens = _bind_discord_request(guild_id=f"000{CURRENT_GUILD}") + try: + result = json.loads( + consumer.edit_current_guild_settings( + { + "name": "Hermes HQ", + "system_channel_id": f"000{CHANNEL_ID}", + "afk_timeout": 300, + } + ) + ) + finally: + clear_session_vars(tokens) + + request.assert_called_once_with( + "PATCH", + f"/guilds/{CURRENT_GUILD}", + "active-profile-token", + body={ + "name": "Hermes HQ", + "system_channel_id": CHANNEL_ID, + "afk_timeout": 300, + }, + ) + assert result == { + "success": True, + "guild_id": CURRENT_GUILD, + "updated_settings": { + "name": "Hermes HQ", + "system_channel_id": CHANNEL_ID, + "afk_timeout": 300, + }, + } + assert OTHER_GUILD not in json.dumps(result) + + +def test_consumer_preserves_explicit_false_zero_and_null(monkeypatch): + request = Mock(return_value={"id": CURRENT_GUILD}) + monkeypatch.setattr(consumer._discord, "_discord_request", request) + tokens = _bind_discord_request() + try: + result = json.loads( + consumer.edit_current_guild_settings( + { + "premium_progress_bar_enabled": False, + "default_message_notifications": 0, + "description": None, + "afk_channel_id": None, + } + ) + ) + finally: + clear_session_vars(tokens) + + expected = { + "premium_progress_bar_enabled": False, + "default_message_notifications": 0, + "description": None, + "afk_channel_id": None, + } + request.assert_called_once_with( + "PATCH", + f"/guilds/{CURRENT_GUILD}", + "active-profile-token", + body=expected, + ) + assert result["updated_settings"] == expected + + +@pytest.mark.parametrize( + ("platform", "user_id", "guild_id", "message"), + [ + ("slack", "42", CURRENT_GUILD, "active Discord request context"), + ("discord", "", CURRENT_GUILD, "authenticated Discord requester"), + ("discord", "42", "", "active Discord guild context"), + ], +) +def test_consumer_fails_closed_without_complete_request_owner( + monkeypatch, + platform, + user_id, + guild_id, + message, +): + request = Mock() + monkeypatch.setattr(consumer._discord, "_discord_request", request) + tokens = set_session_vars(platform=platform, user_id=user_id, scope_id=guild_id) + try: + result = consumer.edit_current_guild_settings({"name": "Hermes HQ"}) + finally: + clear_session_vars(tokens) + + assert message in _error(result) + request.assert_not_called() + + +@pytest.mark.parametrize( + ("settings", "message"), + [ + ({}, "no guild settings provided"), + ({"nsfw_level": 1}, "unsupported guild setting"), + ({"afk_timeout": 61}, "must be one of"), + ([], "must be a JSON object"), + ], +) +def test_consumer_rejects_invalid_or_empty_patch_before_transport( + monkeypatch, + settings, + message, +): + request = Mock() + monkeypatch.setattr(consumer._discord, "_discord_request", request) + tokens = _bind_discord_request() + try: + result = consumer.edit_current_guild_settings(settings) + finally: + clear_session_vars(tokens) + + assert message in _error(result) + request.assert_not_called() + + +def test_consumer_respects_shared_server_action_allowlist(monkeypatch): + request = Mock() + monkeypatch.setattr(consumer._discord, "_discord_request", request) + monkeypatch.setattr(consumer._discord, "_load_allowed_actions_config", lambda: ["list_roles"]) + tokens = _bind_discord_request() + try: + result = consumer.edit_current_guild_settings({"name": "Hermes HQ"}) + finally: + clear_session_vars(tokens) + + assert "disabled by config" in _error(result) + assert consumer.check_discord_guild_settings_requirements() is False + request.assert_not_called() + + +def test_schema_exposes_only_owned_settings_and_no_target_id(): + parameters = consumer.SCHEMA["parameters"] + assert set(parameters["properties"]) == {"settings"} + assert parameters["additionalProperties"] is False + settings = parameters["properties"]["settings"] + assert settings["additionalProperties"] is False + assert "nsfw_level" not in settings["properties"] + assert settings["properties"]["afk_timeout"]["enum"] == [60, 300, 900, 1800, 3600] + + +def test_sequential_request_owners_do_not_share_guild_authority(monkeypatch): + request = Mock(return_value={}) + monkeypatch.setattr(consumer._discord, "_discord_request", request) + + first = _bind_discord_request(guild_id=CURRENT_GUILD) + try: + consumer.edit_current_guild_settings({"name": "First Guild"}) + finally: + clear_session_vars(first) + + second_guild = "222222222222222222" + second = _bind_discord_request(guild_id=second_guild, user_id="84") + try: + consumer.edit_current_guild_settings({"name": "Second Guild"}) + finally: + clear_session_vars(second) + + assert request.call_args_list[0].args[:3] == ( + "PATCH", + f"/guilds/{CURRENT_GUILD}", + "active-profile-token", + ) + assert request.call_args_list[1].args[:3] == ( + "PATCH", + f"/guilds/{second_guild}", + "active-profile-token", + ) From e6e022c1aa0e6d8071040bff8e93b6740c3a6c31 Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Thu, 20 Aug 2026 11:38:41 -0500 Subject: [PATCH 6/9] docs(discord): name the request-owned guild-settings consumer --- tools/discord_api/guild_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/discord_api/guild_settings.py b/tools/discord_api/guild_settings.py index ae5216b76d84..c483b9c2d8f6 100644 --- a/tools/discord_api/guild_settings.py +++ b/tools/discord_api/guild_settings.py @@ -1,8 +1,8 @@ """Discord REST v10 guild-settings request builder (approved scalars only). This module is deliberately transport-free. It validates the scalar guild -settings Hermes exposes and returns a request descriptor for the existing -``discord_admin`` consumer. +settings Hermes exposes and returns a request descriptor for the request-owned +``discord_guild_settings`` consumer. """ from __future__ import annotations From 78e144195b614651d4edc7c41ef771f154b716ec Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Thu, 20 Aug 2026 11:39:12 -0500 Subject: [PATCH 7/9] fix(discord): expose canonical snowflakes in guild-settings schema --- tools/discord_guild_settings_tool.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/discord_guild_settings_tool.py b/tools/discord_guild_settings_tool.py index b82392545af8..d35897522483 100644 --- a/tools/discord_guild_settings_tool.py +++ b/tools/discord_guild_settings_tool.py @@ -52,31 +52,31 @@ "premium_progress_bar_enabled": {"type": "boolean"}, "afk_channel_id": { "anyOf": [ - {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, {"type": "null"}, ] }, "system_channel_id": { "anyOf": [ - {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, {"type": "null"}, ] }, "rules_channel_id": { "anyOf": [ - {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, {"type": "null"}, ] }, "public_updates_channel_id": { "anyOf": [ - {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, {"type": "null"}, ] }, "safety_alerts_channel_id": { "anyOf": [ - {"type": "string", "pattern": "^[0-9]{1,64}$"}, + {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, {"type": "null"}, ] }, From 7168fb6eb23edf0074083fda34510024d0ee515b Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Thu, 20 Aug 2026 11:39:39 -0500 Subject: [PATCH 8/9] style(discord): format guild-settings contract tests --- tests/tools/test_discord_guild_settings.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_discord_guild_settings.py b/tests/tools/test_discord_guild_settings.py index f90e9419bf64..3bea39a99c12 100644 --- a/tests/tools/test_discord_guild_settings.py +++ b/tests/tools/test_discord_guild_settings.py @@ -85,7 +85,10 @@ def test_name_contract(): def test_description_max_contract(): - assert len(edit_guild_request(GUILD_ID, description="x" * 1024)["json"]["description"]) == 1024 + description = edit_guild_request(GUILD_ID, description="x" * 1024)["json"][ + "description" + ] + assert len(description) == 1024 with pytest.raises(GuildSettingsError, match="exceeds 1024"): edit_guild_request(GUILD_ID, description="x" * 1025) From 7520f14b521fe84a079470a4890408fd0c70075f Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Thu, 20 Aug 2026 11:40:14 -0500 Subject: [PATCH 9/9] test(discord): pin canonical model schema and format owner proof --- .../test_discord_guild_settings_consumer.py | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_discord_guild_settings_consumer.py b/tests/tools/test_discord_guild_settings_consumer.py index 778557ac20fb..3051f163d3db 100644 --- a/tests/tools/test_discord_guild_settings_consumer.py +++ b/tests/tools/test_discord_guild_settings_consumer.py @@ -5,7 +5,11 @@ import pytest -from gateway.session_context import clear_session_vars, reset_session_vars, set_session_vars +from gateway.session_context import ( + clear_session_vars, + reset_session_vars, + set_session_vars, +) from tools import discord_guild_settings_tool as consumer CURRENT_GUILD = "123456789012345678" @@ -16,8 +20,16 @@ @pytest.fixture(autouse=True) def _isolated_consumer(monkeypatch): reset_session_vars() - monkeypatch.setattr(consumer._discord, "_get_bot_token", lambda: "active-profile-token") - monkeypatch.setattr(consumer._discord, "_load_allowed_actions_config", lambda: None) + monkeypatch.setattr( + consumer._discord, + "_get_bot_token", + lambda: "active-profile-token", + ) + monkeypatch.setattr( + consumer._discord, + "_load_allowed_actions_config", + lambda: None, + ) yield reset_session_vars() @@ -164,7 +176,11 @@ def test_consumer_rejects_invalid_or_empty_patch_before_transport( def test_consumer_respects_shared_server_action_allowlist(monkeypatch): request = Mock() monkeypatch.setattr(consumer._discord, "_discord_request", request) - monkeypatch.setattr(consumer._discord, "_load_allowed_actions_config", lambda: ["list_roles"]) + monkeypatch.setattr( + consumer._discord, + "_load_allowed_actions_config", + lambda: ["list_roles"], + ) tokens = _bind_discord_request() try: result = consumer.edit_current_guild_settings({"name": "Hermes HQ"}) @@ -183,7 +199,16 @@ def test_schema_exposes_only_owned_settings_and_no_target_id(): settings = parameters["properties"]["settings"] assert settings["additionalProperties"] is False assert "nsfw_level" not in settings["properties"] - assert settings["properties"]["afk_timeout"]["enum"] == [60, 300, 900, 1800, 3600] + assert settings["properties"]["afk_timeout"]["enum"] == [ + 60, + 300, + 900, + 1800, + 3600, + ] + assert settings["properties"]["system_channel_id"]["anyOf"][0]["pattern"] == ( + "^[1-9][0-9]{0,19}$" + ) def test_sequential_request_owners_do_not_share_guild_authority(monkeypatch):