From 4c50b4689ebbb9bdc150920dd53007995ac88219 Mon Sep 17 00:00:00 2001 From: Junass1 Date: Sun, 19 Apr 2026 05:09:05 +0300 Subject: [PATCH] fix(gateway): make Telegram DM topic config writes atomic --- gateway/platforms/telegram.py | 20 ++++++++++++++-- tests/gateway/test_dm_topics.py | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 16c20701..cf9a0a43 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -11,6 +11,7 @@ import asyncio import json import logging import os +import tempfile import html as _html import re from typing import Dict, List, Optional, Any @@ -534,8 +535,23 @@ class TelegramAdapter(BasePlatformAdapter): break if changed: - with open(config_path, "w") as f: - _yaml.dump(config, f, default_flow_style=False, sort_keys=False) + fd, tmp_path = tempfile.mkstemp( + dir=str(config_path.parent), + suffix=".tmp", + prefix=".config_", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + _yaml.dump(config, f, default_flow_style=False, sort_keys=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, config_path) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise logger.info( "[%s] Persisted thread_id=%s for topic '%s' in config.yaml", self.name, thread_id, topic_name, diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 69e9629b..39cabd95 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -283,6 +283,48 @@ def test_persist_dm_topic_thread_id_skips_if_already_set(tmp_path): # ── _get_dm_topic_info ── +def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path): + """Failed writes should leave the original config.yaml intact.""" + import yaml + + config_data = { + "platforms": { + "telegram": { + "extra": { + "dm_topics": [ + { + "chat_id": 111, + "topics": [ + {"name": "General", "icon_color": 123}, + ], + } + ] + } + } + } + } + + config_file = tmp_path / ".hermes" / "config.yaml" + config_file.parent.mkdir(parents=True) + original_text = yaml.dump(config_data) + config_file.write_text(original_text, encoding="utf-8") + + adapter = _make_adapter() + + def fail_dump(*args, **kwargs): + raise RuntimeError("boom") + + with patch.object(Path, "home", return_value=tmp_path), \ + patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}), \ + patch("yaml.dump", side_effect=fail_dump): + adapter._persist_dm_topic_thread_id(111, "General", 999) + + assert config_file.read_text(encoding="utf-8") == original_text + result = yaml.safe_load(config_file.read_text(encoding="utf-8")) + topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"] + assert "thread_id" not in topics[0] + + def test_get_dm_topic_info_finds_cached_topic(): """Should return topic config when thread_id is in cache.""" adapter = _make_adapter([