From b71f80e6ce2af7a75e319170340dec9d64461576 Mon Sep 17 00:00:00 2001 From: Guillaume Meyer Date: Wed, 6 May 2026 15:37:04 +0000 Subject: [PATCH] feat(gateway): per-platform gateway_restart_notification flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-out toggle on PlatformConfig that gates both restart lifecycle pings: the "♻ Gateway restarted" message sent to the chat that issued /restart, and the "♻️ Gateway online" home-channel startup notification. Defaults to True so existing deployments are unaffected. The motivating split is operator vs. end-user surfaces: a back-channel like Telegram should keep these pings, while a Slack workspace shared with end users should not surface gateway lifecycle noise. Co-Authored-By: Claude Opus 4.7 (1M context) --- gateway/config.py | 19 ++++-- gateway/run.py | 16 +++++ tests/gateway/test_config.py | 13 ++++ tests/gateway/test_restart_notification.py | 76 ++++++++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 2e0e3276b7..8eb39ba54a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -271,15 +271,23 @@ class PlatformConfig: # - "first": Only first chunk threads to user's message (default) # - "all": All chunks in multi-part replies thread to user's message reply_to_mode: str = "first" - + + # Whether the gateway is allowed to send "♻️ Gateway online" / + # "♻ Gateway restarted" lifecycle notifications on this platform. + # Default True preserves prior behavior. Set False on platforms used + # by end users (e.g. Slack) where operator-flavored restart pings are + # noise; keep True for back-channels where the operator wants them. + gateway_restart_notification: bool = True + # Platform-specific settings extra: Dict[str, Any] = field(default_factory=dict) - + def to_dict(self) -> Dict[str, Any]: result = { "enabled": self.enabled, "extra": self.extra, "reply_to_mode": self.reply_to_mode, + "gateway_restart_notification": self.gateway_restart_notification, } if self.token: result["token"] = self.token @@ -288,19 +296,22 @@ class PlatformConfig: if self.home_channel: result["home_channel"] = self.home_channel.to_dict() return result - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": home_channel = None if "home_channel" in data: home_channel = HomeChannel.from_dict(data["home_channel"]) - + 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 + ), extra=data.get("extra", {}), ) diff --git a/gateway/run.py b/gateway/run.py index 1c125d9aff..77f20178d1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11386,6 +11386,14 @@ class GatewayRunner: ) return None + platform_cfg = self.config.platforms.get(platform) + if platform_cfg is not None and not platform_cfg.gateway_restart_notification: + logger.info( + "Restart notification suppressed: %s has gateway_restart_notification=false", + platform_str, + ) + return None + metadata = {"thread_id": thread_id} if thread_id else None result = await adapter.send( str(chat_id), @@ -11437,6 +11445,14 @@ class GatewayRunner: if not home or not home.chat_id: continue + platform_cfg = self.config.platforms.get(platform) + if platform_cfg is not None and not platform_cfg.gateway_restart_notification: + logger.info( + "Home-channel startup notification suppressed: %s has gateway_restart_notification=false", + platform.value, + ) + continue + target = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None) if target in skipped or target in delivered: continue diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 3df2a7d50b..c53e34b757 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -57,6 +57,19 @@ class TestPlatformConfigRoundtrip: restored = PlatformConfig.from_dict({"enabled": "false"}) assert restored.enabled is False + def test_gateway_restart_notification_defaults_true(self): + assert PlatformConfig().gateway_restart_notification is True + assert PlatformConfig.from_dict({}).gateway_restart_notification is True + + def test_gateway_restart_notification_roundtrip_false(self): + pc = PlatformConfig(enabled=True, gateway_restart_notification=False) + restored = PlatformConfig.from_dict(pc.to_dict()) + assert restored.gateway_restart_notification is False + + def test_gateway_restart_notification_coerces_quoted_false(self): + restored = PlatformConfig.from_dict({"gateway_restart_notification": "false"}) + assert restored.gateway_restart_notification is False + class TestGetConnectedPlatforms: def test_returns_enabled_with_token(self): diff --git a/tests/gateway/test_restart_notification.py b/tests/gateway/test_restart_notification.py index e97216072a..d48ced6bb7 100644 --- a/tests/gateway/test_restart_notification.py +++ b/tests/gateway/test_restart_notification.py @@ -496,6 +496,82 @@ async def test_send_restart_notification_logs_warning_on_sendresult_failure( assert not notify_path.exists() +@pytest.mark.asyncio +async def test_send_home_channel_startup_notification_skipped_when_flag_disabled( + tmp_path, monkeypatch +): + """Per-platform opt-out: gateway_restart_notification=False mutes the home-channel ping.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, adapter = make_restart_runner() + runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, + chat_id="home-42", + name="Ops Home", + ) + runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification = False + adapter.send = AsyncMock() + + delivered = await runner._send_home_channel_startup_notifications() + + assert delivered == set() + adapter.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_home_channel_startup_notification_default_flag_true( + tmp_path, monkeypatch +): + """Default behavior is unchanged: missing flag means notifications still fire.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, adapter = make_restart_runner() + # Sanity-check the dataclass default — guards against future refactors + # silently flipping the default to False. + assert runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification is True + + runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, + chat_id="home-42", + name="Ops Home", + ) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="home")) + + delivered = await runner._send_home_channel_startup_notifications() + + assert delivered == {("telegram", "home-42", None)} + adapter.send.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_restart_notification_skipped_when_flag_disabled( + tmp_path, monkeypatch +): + """The /restart originator's notification also honors the per-platform flag. + + Slack used by end users → flag off → no "Gateway restarted" message even + when an end user accidentally triggers /restart. The marker file is still + cleaned up so the notification doesn't leak into the next boot. + """ + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + notify_path = tmp_path / ".restart_notify.json" + notify_path.write_text(json.dumps({ + "platform": "telegram", + "chat_id": "42", + })) + + runner, adapter = make_restart_runner() + runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification = False + adapter.send = AsyncMock() + + delivered_target = await runner._send_restart_notification() + + assert delivered_target is None + adapter.send.assert_not_called() + assert not notify_path.exists() + + @pytest.mark.asyncio async def test_send_restart_notification_logs_info_on_sendresult_success( tmp_path, monkeypatch, caplog