Skip to content
Closed
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
4 changes: 4 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,10 @@ def _merge_platform_map(source_platforms: Any) -> None:
bridged["channel_prompts"] = channel_prompts
if "gateway_restart_notification" in platform_cfg:
bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"]
if "gateway_restart_notification_channels" in platform_cfg:
bridged["gateway_restart_notification_channels"] = platform_cfg[
"gateway_restart_notification_channels"
]
if "typing_indicator" in platform_cfg:
bridged["typing_indicator"] = platform_cfg["typing_indicator"]
enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg
Expand Down
62 changes: 54 additions & 8 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,48 @@ def _clear_planned_restart_notification() -> None:
_planned_restart_notification_path().unlink(missing_ok=True)


def _restart_notification_target_allowed(platform_cfg: Any, chat_id: Any) -> bool:
"""Return whether restart lifecycle notifications may be sent to chat_id.

``gateway_restart_notification`` remains the coarse per-platform kill switch.
``extra.gateway_restart_notification_channels`` optionally narrows delivery
to a small allowlist, which is useful when a platform has many active
sessions but only one ops channel should receive lifecycle noise.
"""
if platform_cfg is not None and not getattr(platform_cfg, "gateway_restart_notification", True):
return False

extra = getattr(platform_cfg, "extra", None) if platform_cfg is not None else None
if not isinstance(extra, dict):
return True

raw_allowed = extra.get("gateway_restart_notification_channels")
if raw_allowed in (None, ""):
return True

if isinstance(raw_allowed, str):
raw_allowed_text = raw_allowed.strip()
if raw_allowed_text.startswith("["):
try:
parsed_allowed = json.loads(raw_allowed_text)
except Exception:
parsed_allowed = None
if isinstance(parsed_allowed, list):
allowed = {str(part).strip() for part in parsed_allowed if str(part).strip()}
else:
allowed = {part.strip().strip("'\"") for part in raw_allowed_text.split(",") if part.strip()}
else:
allowed = {part.strip() for part in raw_allowed_text.split(",") if part.strip()}
elif isinstance(raw_allowed, (list, tuple, set)):
allowed = {str(part).strip() for part in raw_allowed if str(part).strip()}
else:
allowed = {str(raw_allowed).strip()}

if not allowed:
return True
return str(chat_id) in allowed


# Mark this process as a gateway so cli.py's module-level load_cli_config()
# knows not to clobber TERMINAL_CWD if lazily imported.
os.environ["_HERMES_GATEWAY"] = "1"
Expand Down Expand Up @@ -5148,10 +5190,11 @@ async def _notify_active_sessions_of_shutdown(self) -> None:
continue

platform_cfg = self.config.platforms.get(platform)
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
if not _restart_notification_target_allowed(platform_cfg, chat_id):
logger.info(
"Shutdown notification suppressed for active session: %s has gateway_restart_notification=false",
"Shutdown notification suppressed for active session: %s target %s not allowed",
platform_str,
chat_id,
)
continue

Expand Down Expand Up @@ -5237,10 +5280,11 @@ async def _notify_active_sessions_of_shutdown(self) -> None:
continue

platform_cfg = self.config.platforms.get(platform)
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
if not _restart_notification_target_allowed(platform_cfg, home.chat_id):
logger.info(
"Shutdown notification suppressed for home channel: %s has gateway_restart_notification=false",
"Shutdown notification suppressed for home channel: %s target %s not allowed",
platform.value,
home.chat_id,
)
continue

Expand Down Expand Up @@ -13558,10 +13602,11 @@ async def _send_restart_notification(self) -> Optional[tuple[str, str, Optional[
return None

platform_cfg = self.config.platforms.get(platform)
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
if not _restart_notification_target_allowed(platform_cfg, chat_id):
logger.info(
"Restart notification suppressed: %s has gateway_restart_notification=false",
"Restart notification suppressed: %s target %s not allowed",
platform_str,
chat_id,
)
return None

Expand Down Expand Up @@ -13624,10 +13669,11 @@ async def _send_home_channel_startup_notifications(
continue

platform_cfg = self.config.platforms.get(platform)
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
if not _restart_notification_target_allowed(platform_cfg, home.chat_id):
logger.info(
"Home-channel startup notification suppressed: %s has gateway_restart_notification=false",
"Home-channel startup notification suppressed: %s target %s not allowed",
platform.value,
home.chat_id,
)
continue

Expand Down
48 changes: 48 additions & 0 deletions tests/gateway/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ 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_resolved_from_extra(self):
restored = PlatformConfig.from_dict(
{"extra": {"gateway_restart_notification": False}}
)
assert restored.gateway_restart_notification is False

def test_typing_indicator_defaults_true(self):
assert PlatformConfig().typing_indicator is True
assert PlatformConfig.from_dict({}).typing_indicator is True
Expand Down Expand Up @@ -571,6 +577,48 @@ def test_bridges_discord_platform_extra_allow_from_to_env(self, tmp_path, monkey
]
assert os.environ.get("DISCORD_ALLOWED_USERS") == "123456789012345678"

def test_bridges_restart_notification_channels_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"discord:\n"
" gateway_restart_notification_channels:\n"
" - \"1489802072038572215\"\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))

config = load_gateway_config()

assert config.platforms[Platform.DISCORD].extra[
"gateway_restart_notification_channels"
] == ["1489802072038572215"]

def test_bridges_restart_notification_channels_from_nested_gateway_config(
self, tmp_path, monkeypatch
):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n"
" platforms:\n"
" discord:\n"
" gateway_restart_notification_channels:\n"
" - \"1489802072038572215\"\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))

config = load_gateway_config()

assert config.platforms[Platform.DISCORD].extra[
"gateway_restart_notification_channels"
] == ["1489802072038572215"]

def test_bridges_quoted_false_platform_enabled_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
Expand Down
77 changes: 77 additions & 0 deletions tests/gateway/test_restart_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,83 @@ async def test_send_home_channel_startup_notification_skipped_when_flag_disabled
adapter.send.assert_not_called()


@pytest.mark.asyncio
async def test_send_home_channel_startup_notification_respects_allowed_channels(
tmp_path, monkeypatch
):
"""Per-platform allowlist narrows restart lifecycle pings to one ops channel."""
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="noisy-chat",
name="Noisy Home",
)
runner.config.platforms[Platform.TELEGRAM].extra[
"gateway_restart_notification_channels"
] = ["ops-only"]
adapter.send = AsyncMock()

delivered = await runner._send_home_channel_startup_notifications()

assert delivered == set()
adapter.send.assert_not_called()

home = runner.config.platforms[Platform.TELEGRAM].home_channel
assert home is not None
home.chat_id = "ops-only"
delivered = await runner._send_home_channel_startup_notifications()

assert delivered == {("telegram", "ops-only", None)}
adapter.send.assert_called_once()


@pytest.mark.asyncio
async def test_restart_notification_respects_allowed_channels(
tmp_path, monkeypatch
):
"""A /restart originator outside the allowlist does not get lifecycle noise."""
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": "noisy-chat",
}))

runner, adapter = make_restart_runner()
runner.config.platforms[Platform.TELEGRAM].extra[
"gateway_restart_notification_channels"
] = "ops-only"
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_shutdown_active_session_notification_respects_allowed_channels():
"""Restart/shutdown interrupt notices do not leak into every active chat."""
runner, adapter = make_restart_runner()
source = make_restart_source(chat_id="noisy-chat")
session_key = build_session_key(source)

runner.config.platforms[Platform.TELEGRAM].extra[
"gateway_restart_notification_channels"
] = ["ops-only"]
runner._running_agents[session_key] = object()
runner.session_store._entries[session_key] = MagicMock(origin=source)
adapter.send = AsyncMock()

await runner._notify_active_sessions_of_shutdown()

adapter.send.assert_not_called()


@pytest.mark.asyncio
async def test_send_home_channel_startup_notification_default_flag_true(
tmp_path, monkeypatch
Expand Down
13 changes: 12 additions & 1 deletion website/docs/user-guide/messaging/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,18 @@ gateway:
# gateway_restart_notification omitted → defaults to true
```

Disable it on noisy or low-priority platforms while leaving it on for your primary chat. The notification is sent once per restart, regardless of how many sessions were in flight.
Disable it on noisy or low-priority platforms while leaving it on for your primary chat. To keep restart notices enabled for a platform but limit them to an ops channel, set `gateway_restart_notification_channels` to the allowed chat/channel IDs:

```yaml
gateway:
platforms:
discord:
home_chat_id: "987654321"
gateway_restart_notification_channels:
- "987654321" # only this channel receives lifecycle notices
```

When `gateway_restart_notification_channels` is omitted, the platform keeps the default behavior. The notification is sent once per restart, regardless of how many sessions were in flight.

### Typing indicators

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ gateway:
Scheduled auto-resume for N restart-interrupted session(s)
```

无需配置。如果你不想要提示消息,在该平台上设置 `gateway_restart_notification: false`。
无需配置。如果你不想要提示消息,在该平台上设置 `gateway_restart_notification: false`。如果只想把提示限制到某个运维频道,请设置 `gateway_restart_notification_channels` 为允许的聊天/频道 ID 列表。

### 进度气泡清理(可选启用)

Expand Down