diff --git a/cli.py b/cli.py index f17eeecfc..70cbc55f3 100755 --- a/cli.py +++ b/cli.py @@ -3271,7 +3271,7 @@ class HermesCLI: print(" To start the gateway:") print(" python cli.py --gateway") print() - print(" Configuration file: ~/.hermes/gateway.json") + print(" Configuration file: ~/.hermes/config.yaml") print() except Exception as e: @@ -3281,7 +3281,7 @@ class HermesCLI: print(" 1. Set environment variables:") print(" TELEGRAM_BOT_TOKEN=your_token") print(" DISCORD_BOT_TOKEN=your_token") - print(" 2. Or create ~/.hermes/gateway.json") + print(" 2. Or configure settings in ~/.hermes/config.yaml") print() def process_command(self, command: str) -> bool: diff --git a/gateway/config.py b/gateway/config.py index c99756c35..55a811aa8 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -350,65 +350,73 @@ class GatewayConfig: def load_gateway_config() -> GatewayConfig: """ Load gateway configuration from multiple sources. - + Priority (highest to lowest): 1. Environment variables - 2. ~/.hermes/gateway.json - 3. cli-config.yaml gateway section - 4. Defaults + 2. ~/.hermes/config.yaml (primary user-facing config) + 3. ~/.hermes/gateway.json (legacy — provides defaults under config.yaml) + 4. Built-in defaults """ - config = GatewayConfig() - - # Try loading from ~/.hermes/gateway.json _home = get_hermes_home() - gateway_config_path = _home / "gateway.json" - if gateway_config_path.exists(): - try: - with open(gateway_config_path, "r", encoding="utf-8") as f: - data = json.load(f) - config = GatewayConfig.from_dict(data) - except Exception as e: - print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}") + gw_data: dict = {} - # Bridge session_reset from config.yaml (the user-facing config file) - # into the gateway config. config.yaml takes precedence over gateway.json - # for session reset policy since that's where hermes setup writes it. + # Legacy fallback: gateway.json provides the base layer. + # config.yaml keys always win when both specify the same setting. + gateway_json_path = _home / "gateway.json" + if gateway_json_path.exists(): + try: + with open(gateway_json_path, "r", encoding="utf-8") as f: + gw_data = json.load(f) or {} + logger.info( + "Loaded legacy %s — consider moving settings to config.yaml", + gateway_json_path, + ) + except Exception as e: + logger.warning("Failed to load %s: %s", gateway_json_path, e) + + # Primary source: config.yaml try: import yaml config_yaml_path = _home / "config.yaml" if config_yaml_path.exists(): with open(config_yaml_path, encoding="utf-8") as f: yaml_cfg = yaml.safe_load(f) or {} + + # Map config.yaml keys → GatewayConfig.from_dict() schema. + # Each key overwrites whatever gateway.json may have set. sr = yaml_cfg.get("session_reset") if sr and isinstance(sr, dict): - config.default_reset_policy = SessionResetPolicy.from_dict(sr) + gw_data["default_reset_policy"] = sr - # Bridge quick commands from config.yaml into gateway runtime config. - # config.yaml is the user-facing config source, so when present it - # should override gateway.json for this setting. qc = yaml_cfg.get("quick_commands") if qc is not None: if isinstance(qc, dict): - config.quick_commands = qc + gw_data["quick_commands"] = qc else: - logger.warning("Ignoring invalid quick_commands in config.yaml (expected mapping, got %s)", type(qc).__name__) + logger.warning( + "Ignoring invalid quick_commands in config.yaml " + "(expected mapping, got %s)", + type(qc).__name__, + ) - # Bridge STT enable/disable from config.yaml into gateway runtime. - # This keeps the gateway aligned with the user-facing config source. stt_cfg = yaml_cfg.get("stt") - if isinstance(stt_cfg, dict) and "enabled" in stt_cfg: - config.stt_enabled = _coerce_bool(stt_cfg.get("enabled"), True) + if isinstance(stt_cfg, dict): + gw_data["stt"] = stt_cfg - # Bridge group session isolation from config.yaml into gateway runtime. - # Secure default is per-user isolation in shared chats. if "group_sessions_per_user" in yaml_cfg: - config.group_sessions_per_user = _coerce_bool( - yaml_cfg.get("group_sessions_per_user"), - True, - ) + gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"] - # Bridge discord settings from config.yaml to env vars - # (env vars take precedence — only set if not already defined) + streaming_cfg = yaml_cfg.get("streaming") + if isinstance(streaming_cfg, dict): + gw_data["streaming"] = streaming_cfg + + if "reset_triggers" in yaml_cfg: + gw_data["reset_triggers"] = yaml_cfg["reset_triggers"] + + if "always_log_local" in yaml_cfg: + gw_data["always_log_local"] = yaml_cfg["always_log_local"] + + # Discord settings → env vars (env vars take precedence) discord_cfg = yaml_cfg.get("discord", {}) if isinstance(discord_cfg, dict): if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): @@ -430,6 +438,8 @@ def load_gateway_config() -> GatewayConfig: except Exception: pass + config = GatewayConfig.from_dict(gw_data) + # Override with environment variables _apply_env_overrides(config) @@ -680,10 +690,4 @@ def _apply_env_overrides(config: GatewayConfig) -> None: pass -def save_gateway_config(config: GatewayConfig) -> None: - """Save gateway configuration to ~/.hermes/gateway.json.""" - gateway_config_path = get_hermes_home() / "gateway.json" - gateway_config_path.parent.mkdir(parents=True, exist_ok=True) - - with open(gateway_config_path, "w", encoding="utf-8") as f: - json.dump(config.to_dict(), f, indent=2) + diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 978c800f3..5a3c80630 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -414,7 +414,10 @@ class TelegramAdapter(BasePlatformAdapter): text=formatted, parse_mode=ParseMode.MARKDOWN_V2, ) - except Exception: + except Exception as fmt_err: + # "Message is not modified" is a no-op, not an error + if "not modified" in str(fmt_err).lower(): + return SendResult(success=True, message_id=message_id) # Fallback: retry without markdown formatting await self._bot.edit_message_text( chat_id=int(chat_id), @@ -423,6 +426,32 @@ class TelegramAdapter(BasePlatformAdapter): ) return SendResult(success=True, message_id=message_id) except Exception as e: + err_str = str(e).lower() + # "Message is not modified" — content identical, treat as success + if "not modified" in err_str: + return SendResult(success=True, message_id=message_id) + # Flood control / RetryAfter — back off and retry once + retry_after = getattr(e, "retry_after", None) + if retry_after is not None or "retry after" in err_str: + wait = retry_after if retry_after else 1.0 + logger.warning( + "[%s] Telegram flood control, waiting %.1fs", + self.name, wait, + ) + await asyncio.sleep(wait) + try: + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=int(message_id), + text=content, + ) + return SendResult(success=True, message_id=message_id) + except Exception as retry_err: + logger.error( + "[%s] Edit retry failed after flood wait: %s", + self.name, retry_err, + ) + return SendResult(success=False, error=str(retry_err)) logger.error( "[%s] Failed to edit Telegram message %s: %s", self.name, diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 42d9dd70f..1b264c534 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -68,6 +68,7 @@ class GatewayStreamConsumer: self._already_sent = False self._edit_supported = True # Disabled on first edit failure (Signal/Email/HA) self._last_edit_time = 0.0 + self._last_sent_text = "" # Track last-sent text to skip redundant edits @property def already_sent(self) -> bool: @@ -141,6 +142,9 @@ class GatewayStreamConsumer: try: if self._message_id is not None: if self._edit_supported: + # Skip if text is identical to what we last sent + if text == self._last_sent_text: + return # Edit existing message result = await self.adapter.edit_message( chat_id=self.chat_id, @@ -149,6 +153,7 @@ class GatewayStreamConsumer: ) if result.success: self._already_sent = True + self._last_sent_text = text else: # Edit not supported by this adapter — stop streaming, # let the normal send path handle the final response. @@ -170,6 +175,7 @@ class GatewayStreamConsumer: if result.success and result.message_id: self._message_id = result.message_id self._already_sent = True + self._last_sent_text = text else: # Initial send failed — disable streaming for this session self._edit_supported = False diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index bd25e1dbc..3ebd6c5b7 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -134,7 +134,7 @@ def _handle_send(args): pconfig = config.platforms.get(platform) if not pconfig or not pconfig.enabled: - return json.dumps({"error": f"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/gateway.json or environment variables."}) + return json.dumps({"error": f"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables."}) from gateway.platforms.base import BasePlatformAdapter