From 59c27720ba9932735fc879464f38c48a0b02dc1a Mon Sep 17 00:00:00 2001 From: Colin Chang Date: Sat, 16 May 2026 19:24:13 +0800 Subject: [PATCH 1/2] fix(gateway): bridge gateway_restart_notification from YAML platform sections to PlatformConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting `gateway_restart_notification: false` in config.yaml platform sections (e.g. `discord:`, `telegram:`) was silently ignored — the setting never reached the PlatformConfig object, so restart/shutdown notifications were always sent regardless of the user's preference. Root cause (two-part): 1. The shared-key bridging loop in load_gateway_config() omitted `gateway_restart_notification` from its bridge list, so the value from `discord:` YAML never entered `platforms_data`. 2. PlatformConfig.from_dict() only read the key from the top-level dict, not from `extra` where bridged values are stored. Fix: - Add `gateway_restart_notification` to the bridging loop so the YAML value propagates into `platforms_data[""].extra`. - Update PlatformConfig.from_dict() to fall back to `extra` when the top-level key is absent, matching the pattern used by other bridged settings. Regression tests added for both from_dict() extra-fallback and the end-to-end load_gateway_config() bridging path. --- gateway/config.py | 14 +++++-- tests/gateway/test_config.py | 80 ++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 7180f1ddb84ac..bdf5ede52d791 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -322,15 +322,21 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": if "home_channel" in data: home_channel = HomeChannel.from_dict(data["home_channel"]) + # gateway_restart_notification may be bridged into extra via the + # shared-key loop in load_gateway_config(); check both top-level + # and extra so YAML ``discord: gateway_restart_notification: false`` + # works without needing a separate platforms: block. + _grn = data.get("gateway_restart_notification") + if _grn is None: + _grn = data.get("extra", {}).get("gateway_restart_notification") + return cls( enabled=_coerce_bool(data.get("enabled"), False), token=data.get("token"), api_key=data.get("api_key"), home_channel=home_channel, reply_to_mode=data.get("reply_to_mode", "first"), - gateway_restart_notification=_coerce_bool( - data.get("gateway_restart_notification"), True - ), + gateway_restart_notification=_coerce_bool(_grn, True), extra=data.get("extra", {}), ) @@ -849,6 +855,8 @@ def load_gateway_config() -> GatewayConfig: bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()} else: bridged["channel_prompts"] = channel_prompts + if "gateway_restart_notification" in platform_cfg: + bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"] enabled_was_explicit = "enabled" in platform_cfg if not bridged and not enabled_was_explicit: continue diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index cf197bd6f7f52..40fb0f0ed5d6d 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -70,6 +70,23 @@ def test_gateway_restart_notification_coerces_quoted_false(self): restored = PlatformConfig.from_dict({"gateway_restart_notification": "false"}) assert restored.gateway_restart_notification is False + def test_gateway_restart_notification_reads_from_extra_fallback(self): + """When gateway_restart_notification is absent at top-level but present + in extra (as happens after the shared-key bridging loop in + load_gateway_config), from_dict must still pick it up.""" + restored = PlatformConfig.from_dict({ + "extra": {"gateway_restart_notification": False}, + }) + assert restored.gateway_restart_notification is False + + def test_gateway_restart_notification_top_level_takes_precedence_over_extra(self): + """Top-level value wins when both top-level and extra provide the key.""" + restored = PlatformConfig.from_dict({ + "gateway_restart_notification": True, + "extra": {"gateway_restart_notification": False}, + }) + assert restored.gateway_restart_notification is True + class TestGetConnectedPlatforms: def test_returns_enabled_with_token(self): @@ -679,3 +696,66 @@ def test_existing_platform_configs_accept_home_channel_env_overrides(self): home = config.platforms[platform].home_channel assert home is not None, f"{platform.value}: home_channel should not be None" assert (home.chat_id, home.name) == expected, platform.value + + +class TestGatewayRestartNotificationBridging: + """Regression tests for the gateway_restart_notification config bridge bug. + + Setting ``discord: gateway_restart_notification: false`` in config.yaml + was silently ignored because: + + 1. The shared-key bridging loop in load_gateway_config() did not include + ``gateway_restart_notification`` in its bridge list, so the value never + reached ``platforms_data["discord"]``. + 2. PlatformConfig.from_dict() only read the key from the top-level dict, + not from ``extra`` where bridged values land. + + Both issues are fixed. These tests verify the end-to-end path. + """ + + def test_discord_yaml_section_bridged_to_platform_config(self, tmp_path, monkeypatch): + """``discord: gateway_restart_notification: false`` in config.yaml + must propagate to PlatformConfig.gateway_restart_notification.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n gateway_restart_notification: false\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + config = load_gateway_config() + discord_cfg = config.platforms.get(Platform.DISCORD) + assert discord_cfg is not None + assert discord_cfg.gateway_restart_notification is False + + def test_telegram_yaml_section_bridged_to_platform_config(self, tmp_path, monkeypatch): + """``telegram: gateway_restart_notification: false`` in config.yaml + must propagate to PlatformConfig.gateway_restart_notification.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "telegram:\n gateway_restart_notification: false\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + config = load_gateway_config() + telegram_cfg = config.platforms.get(Platform.TELEGRAM) + assert telegram_cfg is not None + assert telegram_cfg.gateway_restart_notification is False + + def test_default_true_when_not_set(self, tmp_path, monkeypatch): + """When gateway_restart_notification is absent, the default must be True.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n require_mention: true\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + config = load_gateway_config() + discord_cfg = config.platforms.get(Platform.DISCORD) + assert discord_cfg is not None + assert discord_cfg.gateway_restart_notification is True From 286d50cc853ad933cdf88a60462e30d7585f1ee5 Mon Sep 17 00:00:00 2001 From: Colin Chang Date: Mon, 18 May 2026 21:43:01 +0800 Subject: [PATCH 2/2] fix(mattermost): use thread root_id from metadata for CRT Thread replies When reply_mode=thread and a user sends from inside a CRT Thread, the previous code used reply_to (user's message ID) as root_id. In Mattermost CRT, root_id must point to the root-level post, not a nested reply. Using the wrong ID causes 400 Invalid RootId. Fix: use metadata['thread_id'] (set by _thread_metadata_for_source) which correctly contains the Thread's root message ID. Closes #28005 --- gateway/platforms/mattermost.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 9487f8a1edfc0..9f745fb4c6bee 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -269,9 +269,14 @@ async def send( "channel_id": chat_id, "message": chunk, } - # Thread support: reply_to is the root post ID. + # Thread support: use the thread's root_id from metadata when + # replying inside an existing CRT Thread. Mattermost requires + # root_id to point to the root-level post, not a nested reply. + # Fall back to reply_to for top-level channel messages (where + # the user's message itself is a valid thread root). if reply_to and self._reply_mode == "thread": - payload["root_id"] = reply_to + thread_root = (metadata or {}).get("thread_id") + payload["root_id"] = thread_root or reply_to data = await self._api_post("posts", payload) if not data or "id" not in data: