fix(gateway): preserve home-channel thread targets across restart notifications
This commit is contained in:
@@ -186,18 +186,24 @@ class HomeChannel:
|
||||
Default destination for a platform.
|
||||
|
||||
When a cron job specifies deliver="telegram" without a specific chat ID,
|
||||
messages are sent to this home channel.
|
||||
messages are sent to this home channel. Thread-aware platforms may also
|
||||
store a thread/topic ID so the bare platform target routes to the exact
|
||||
conversation where /sethome was run.
|
||||
"""
|
||||
platform: Platform
|
||||
chat_id: str
|
||||
name: str # Human-readable name for display
|
||||
thread_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
result = {
|
||||
"platform": self.platform.value,
|
||||
"chat_id": self.chat_id,
|
||||
"name": self.name,
|
||||
}
|
||||
if self.thread_id:
|
||||
result["thread_id"] = self.thread_id
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel":
|
||||
@@ -205,6 +211,7 @@ class HomeChannel:
|
||||
platform=Platform(data["platform"]),
|
||||
chat_id=str(data["chat_id"]),
|
||||
name=data.get("name", "Home"),
|
||||
thread_id=str(data["thread_id"]) if data.get("thread_id") else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -1071,6 +1078,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=telegram_home,
|
||||
name=os.getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("TELEGRAM_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# Discord
|
||||
@@ -1087,6 +1095,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.DISCORD,
|
||||
chat_id=discord_home,
|
||||
name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("DISCORD_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# Reply threading mode for Discord (off/first/all)
|
||||
@@ -1108,6 +1117,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.WHATSAPP,
|
||||
chat_id=whatsapp_home,
|
||||
name=os.getenv("WHATSAPP_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("WHATSAPP_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# Slack
|
||||
@@ -1135,6 +1145,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.SLACK,
|
||||
chat_id=slack_home,
|
||||
name=os.getenv("SLACK_HOME_CHANNEL_NAME", ""),
|
||||
thread_id=os.getenv("SLACK_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# Signal
|
||||
@@ -1155,6 +1166,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.SIGNAL,
|
||||
chat_id=signal_home,
|
||||
name=os.getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("SIGNAL_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# Mattermost
|
||||
@@ -1174,6 +1186,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.MATTERMOST,
|
||||
chat_id=mattermost_home,
|
||||
name=os.getenv("MATTERMOST_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("MATTERMOST_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# Matrix
|
||||
@@ -1205,6 +1218,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.MATRIX,
|
||||
chat_id=matrix_home,
|
||||
name=os.getenv("MATRIX_HOME_ROOM_NAME", "Home"),
|
||||
thread_id=os.getenv("MATRIX_HOME_ROOM_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# Home Assistant
|
||||
@@ -1238,6 +1252,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.EMAIL,
|
||||
chat_id=email_home,
|
||||
name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"),
|
||||
thread_id=os.getenv("EMAIL_HOME_ADDRESS_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# SMS (Twilio)
|
||||
@@ -1253,6 +1268,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.SMS,
|
||||
chat_id=sms_home,
|
||||
name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("SMS_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# API Server
|
||||
@@ -1315,6 +1331,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.DINGTALK,
|
||||
chat_id=dingtalk_home,
|
||||
name=os.getenv("DINGTALK_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("DINGTALK_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# Feishu / Lark
|
||||
@@ -1342,6 +1359,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.FEISHU,
|
||||
chat_id=feishu_home,
|
||||
name=os.getenv("FEISHU_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("FEISHU_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# WeCom (Enterprise WeChat)
|
||||
@@ -1364,6 +1382,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.WECOM,
|
||||
chat_id=wecom_home,
|
||||
name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("WECOM_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# WeCom callback mode (self-built apps)
|
||||
@@ -1422,6 +1441,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.WEIXIN,
|
||||
chat_id=weixin_home,
|
||||
name=os.getenv("WEIXIN_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("WEIXIN_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# BlueBubbles (iMessage)
|
||||
@@ -1445,6 +1465,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.BLUEBUBBLES,
|
||||
chat_id=bluebubbles_home,
|
||||
name=os.getenv("BLUEBUBBLES_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("BLUEBUBBLES_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# QQ (Official Bot API v2)
|
||||
@@ -1482,6 +1503,11 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.QQBOT,
|
||||
chat_id=qq_home,
|
||||
name=os.getenv("QQBOT_HOME_CHANNEL_NAME") or os.getenv(qq_home_name_env, "Home"),
|
||||
thread_id=(
|
||||
os.getenv("QQBOT_HOME_CHANNEL_THREAD_ID")
|
||||
or os.getenv("QQ_HOME_CHANNEL_THREAD_ID")
|
||||
or None
|
||||
),
|
||||
)
|
||||
|
||||
# Yuanbao — YUANBAO_APP_ID preferred
|
||||
@@ -1512,6 +1538,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
platform=Platform.YUANBAO,
|
||||
chat_id=yuanbao_home,
|
||||
name=os.getenv("YUANBAO_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("YUANBAO_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
yuanbao_dm_policy = os.getenv("YUANBAO_DM_POLICY")
|
||||
if yuanbao_dm_policy:
|
||||
|
||||
205
gateway/run.py
205
gateway/run.py
@@ -283,6 +283,16 @@ def _home_target_env_var(platform_name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _home_thread_env_var(platform_name: str) -> str:
|
||||
"""Return the optional thread/topic env var for a platform home target."""
|
||||
return f"{_home_target_env_var(platform_name)}_THREAD_ID"
|
||||
|
||||
|
||||
def _restart_notification_pending() -> bool:
|
||||
"""Return True when a /restart completion marker is waiting to be delivered."""
|
||||
return (_hermes_home / ".restart_notify.json").exists()
|
||||
|
||||
|
||||
_ensure_ssl_certs()
|
||||
|
||||
# Add parent directory to path
|
||||
@@ -507,6 +517,8 @@ from gateway.config import (
|
||||
Platform,
|
||||
_BUILTIN_PLATFORM_VALUES,
|
||||
GatewayConfig,
|
||||
HomeChannel,
|
||||
PlatformConfig,
|
||||
load_gateway_config,
|
||||
)
|
||||
from gateway.session import (
|
||||
@@ -2257,15 +2269,13 @@ class GatewayRunner:
|
||||
logger.debug("Failed interrupting agent during shutdown: %s", e)
|
||||
|
||||
async def _notify_active_sessions_of_shutdown(self) -> None:
|
||||
"""Send a notification to every chat with an active agent.
|
||||
"""Send shutdown/restart notifications to active chats and home channels.
|
||||
|
||||
Called at the very start of stop() — adapters are still connected so
|
||||
messages can be delivered. Best-effort: individual send failures are
|
||||
messages can be delivered. Best-effort: individual send failures are
|
||||
logged and swallowed so they never block the shutdown sequence.
|
||||
"""
|
||||
active = self._snapshot_running_agents()
|
||||
if not active:
|
||||
return
|
||||
|
||||
action = "restarting" if self._restart_requested else "shutting down"
|
||||
hint = (
|
||||
@@ -2276,7 +2286,7 @@ class GatewayRunner:
|
||||
)
|
||||
msg = f"⚠️ Gateway {action} — {hint}"
|
||||
|
||||
notified: set = set()
|
||||
notified: set[tuple[str, str, Optional[str]]] = set()
|
||||
for session_key in active:
|
||||
source = None
|
||||
try:
|
||||
@@ -2293,7 +2303,7 @@ class GatewayRunner:
|
||||
|
||||
if source is not None:
|
||||
platform_str = source.platform.value
|
||||
chat_id = source.chat_id
|
||||
chat_id = str(source.chat_id)
|
||||
thread_id = source.thread_id
|
||||
else:
|
||||
# Fall back to parsing the session key when no persisted
|
||||
@@ -2305,9 +2315,10 @@ class GatewayRunner:
|
||||
chat_id = _parsed["chat_id"]
|
||||
thread_id = _parsed.get("thread_id")
|
||||
|
||||
# Deduplicate: one notification per chat, even if multiple
|
||||
# sessions (different users/threads) share the same chat.
|
||||
dedup_key = (platform_str, chat_id)
|
||||
# Deduplicate only identical delivery targets. Thread/topic-aware
|
||||
# platforms can share a parent chat while still routing to distinct
|
||||
# destinations via metadata.
|
||||
dedup_key = (platform_str, chat_id, str(thread_id) if thread_id else None)
|
||||
if dedup_key in notified:
|
||||
continue
|
||||
|
||||
@@ -2321,10 +2332,19 @@ class GatewayRunner:
|
||||
# correct forum topic / thread.
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
|
||||
await adapter.send(chat_id, msg, metadata=metadata)
|
||||
result = await adapter.send(chat_id, msg, metadata=metadata)
|
||||
if result is not None and getattr(result, "success", True) is False:
|
||||
logger.debug(
|
||||
"Failed to send shutdown notification to %s:%s: %s",
|
||||
platform_str,
|
||||
chat_id,
|
||||
getattr(result, "error", "send returned success=False"),
|
||||
)
|
||||
continue
|
||||
|
||||
notified.add(dedup_key)
|
||||
logger.info(
|
||||
"Sent shutdown notification to %s:%s",
|
||||
"Sent shutdown notification to active chat %s:%s",
|
||||
platform_str, chat_id,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -2333,6 +2353,44 @@ class GatewayRunner:
|
||||
platform_str, chat_id, e,
|
||||
)
|
||||
|
||||
for platform, adapter in self.adapters.items():
|
||||
home = self.config.get_home_channel(platform)
|
||||
if not home or not home.chat_id:
|
||||
continue
|
||||
|
||||
dedup_key = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None)
|
||||
if dedup_key in notified:
|
||||
continue
|
||||
|
||||
try:
|
||||
metadata = {"thread_id": home.thread_id} if home.thread_id else None
|
||||
if metadata:
|
||||
result = await adapter.send(str(home.chat_id), msg, metadata=metadata)
|
||||
else:
|
||||
result = await adapter.send(str(home.chat_id), msg)
|
||||
if result is not None and getattr(result, "success", True) is False:
|
||||
logger.debug(
|
||||
"Failed to send shutdown notification to home channel %s:%s: %s",
|
||||
platform.value,
|
||||
home.chat_id,
|
||||
getattr(result, "error", "send returned success=False"),
|
||||
)
|
||||
continue
|
||||
|
||||
notified.add(dedup_key)
|
||||
logger.info(
|
||||
"Sent shutdown notification to home channel %s:%s",
|
||||
platform.value,
|
||||
home.chat_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to send shutdown notification to home channel %s:%s: %s",
|
||||
platform.value,
|
||||
home.chat_id,
|
||||
e,
|
||||
)
|
||||
|
||||
def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None:
|
||||
for agent in active_agents.values():
|
||||
try:
|
||||
@@ -2953,8 +3011,28 @@ class GatewayRunner:
|
||||
):
|
||||
self._schedule_update_notification_watch()
|
||||
|
||||
# Give freshly connected platform adapters a brief moment to settle
|
||||
# before sending restart/startup lifecycle messages. In practice this
|
||||
# helps Discord thread deliveries right after reconnect.
|
||||
if connected_count > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Notify the chat that initiated /restart that the gateway is back.
|
||||
await self._send_restart_notification()
|
||||
restart_notification_pending = _restart_notification_pending()
|
||||
delivered_restart_target = await self._send_restart_notification()
|
||||
|
||||
# Broadcast a lightweight "gateway is back" message to configured
|
||||
# home channels only when this startup is resuming from /restart. If a
|
||||
# /restart requester already received a direct completion notice in the
|
||||
# same chat, skip the generic broadcast there to avoid duplicates while
|
||||
# still allowing a home-channel fallback when the direct send fails.
|
||||
if restart_notification_pending or delivered_restart_target is not None:
|
||||
skip_home_targets = (
|
||||
{delivered_restart_target} if delivered_restart_target else None
|
||||
)
|
||||
await self._send_home_channel_startup_notifications(
|
||||
skip_targets=skip_home_targets,
|
||||
)
|
||||
|
||||
# Drain any recovered process watchers (from crash recovery checkpoint)
|
||||
try:
|
||||
@@ -7976,14 +8054,33 @@ class GatewayRunner:
|
||||
chat_name = source.chat_name or chat_id
|
||||
|
||||
env_key = _home_target_env_var(platform_name)
|
||||
thread_env_key = _home_thread_env_var(platform_name)
|
||||
thread_id = source.thread_id
|
||||
|
||||
# Save to .env so it persists across restarts
|
||||
try:
|
||||
from hermes_cli.config import save_env_value
|
||||
save_env_value(env_key, str(chat_id))
|
||||
# Keep thread/topic routing explicit and clear stale values when
|
||||
# /sethome is run from the parent chat instead of a thread.
|
||||
save_env_value(thread_env_key, str(thread_id or ""))
|
||||
except Exception as e:
|
||||
return f"Failed to save home channel: {e}"
|
||||
|
||||
# Keep the running gateway config in sync too. The pre-restart
|
||||
# notification path reads self.config before the process reloads env.
|
||||
if source.platform:
|
||||
platform_config = self.config.platforms.setdefault(
|
||||
source.platform,
|
||||
PlatformConfig(enabled=True),
|
||||
)
|
||||
platform_config.home_channel = HomeChannel(
|
||||
platform=source.platform,
|
||||
chat_id=str(chat_id),
|
||||
name=chat_name,
|
||||
thread_id=str(thread_id) if thread_id else None,
|
||||
)
|
||||
|
||||
return (
|
||||
f"✅ Home channel set to **{chat_name}** (ID: {chat_id}).\n"
|
||||
f"Cron jobs and cross-platform messages will be delivered here."
|
||||
@@ -10467,11 +10564,11 @@ class GatewayRunner:
|
||||
|
||||
return True
|
||||
|
||||
async def _send_restart_notification(self) -> None:
|
||||
async def _send_restart_notification(self) -> Optional[tuple[str, str, Optional[str]]]:
|
||||
"""Notify the chat that initiated /restart that the gateway is back."""
|
||||
notify_path = _hermes_home / ".restart_notify.json"
|
||||
if not notify_path.exists():
|
||||
return
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(notify_path.read_text())
|
||||
@@ -10480,7 +10577,7 @@ class GatewayRunner:
|
||||
thread_id = data.get("thread_id")
|
||||
|
||||
if not platform_str or not chat_id:
|
||||
return
|
||||
return None
|
||||
|
||||
platform = Platform(platform_str)
|
||||
adapter = self.adapters.get(platform)
|
||||
@@ -10489,11 +10586,11 @@ class GatewayRunner:
|
||||
"Restart notification skipped: %s adapter not connected",
|
||||
platform_str,
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
result = await adapter.send(
|
||||
chat_id,
|
||||
str(chat_id),
|
||||
"♻ Gateway restarted successfully. Your session continues.",
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -10501,24 +10598,82 @@ class GatewayRunner:
|
||||
# and returns SendResult(success=False) rather than raising, so
|
||||
# we must inspect the result before claiming success — otherwise
|
||||
# the log line is misleading and hides real delivery failures.
|
||||
if getattr(result, "success", False):
|
||||
logger.info(
|
||||
"Sent restart notification to %s:%s",
|
||||
platform_str,
|
||||
chat_id,
|
||||
)
|
||||
else:
|
||||
if result is not None and getattr(result, "success", True) is False:
|
||||
logger.warning(
|
||||
"Restart notification to %s:%s was not delivered: %s",
|
||||
platform_str,
|
||||
chat_id,
|
||||
getattr(result, "error", "unknown error"),
|
||||
getattr(result, "error", "send returned success=False"),
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Sent restart notification to %s:%s",
|
||||
platform_str,
|
||||
chat_id,
|
||||
)
|
||||
return str(platform_str), str(chat_id), str(thread_id) if thread_id else None
|
||||
except Exception as e:
|
||||
logger.warning("Restart notification failed: %s", e)
|
||||
return None
|
||||
finally:
|
||||
notify_path.unlink(missing_ok=True)
|
||||
|
||||
async def _send_home_channel_startup_notifications(
|
||||
self,
|
||||
*,
|
||||
skip_targets: Optional[set[tuple[str, str, Optional[str]]]] = None,
|
||||
) -> set[tuple[str, str, Optional[str]]]:
|
||||
"""Notify configured home channels that the gateway is back online.
|
||||
|
||||
The notification is best-effort and sent once per connected platform
|
||||
home channel. ``skip_targets`` lets startup avoid duplicate messages
|
||||
when a more specific restart notification is queued for the same chat.
|
||||
"""
|
||||
delivered: set[tuple[str, str, Optional[str]]] = set()
|
||||
skipped = skip_targets or set()
|
||||
message = "♻️ Gateway online — Hermes is back and ready."
|
||||
|
||||
for platform, adapter in self.adapters.items():
|
||||
home = self.config.get_home_channel(platform)
|
||||
if not home or not home.chat_id:
|
||||
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
|
||||
|
||||
try:
|
||||
metadata = {"thread_id": home.thread_id} if home.thread_id else None
|
||||
if metadata:
|
||||
result = await adapter.send(str(home.chat_id), message, metadata=metadata)
|
||||
else:
|
||||
result = await adapter.send(str(home.chat_id), message)
|
||||
if result is not None and getattr(result, "success", True) is False:
|
||||
logger.warning(
|
||||
"Home-channel startup notification failed for %s:%s: %s",
|
||||
platform.value,
|
||||
home.chat_id,
|
||||
getattr(result, "error", "send returned success=False"),
|
||||
)
|
||||
continue
|
||||
|
||||
delivered.add(target)
|
||||
logger.info(
|
||||
"Sent home-channel startup notification to %s:%s",
|
||||
platform.value,
|
||||
home.chat_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Home-channel startup notification failed for %s:%s: %s",
|
||||
platform.value,
|
||||
home.chat_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return delivered
|
||||
|
||||
def _set_session_env(self, context: SessionContext) -> list:
|
||||
"""Set session context variables for the current async task.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user