refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication New modules: - gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator, strip_markdown, ThreadParticipationTracker, redact_phone - hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers - tools/path_security.py: validate_within_dir, has_traversal_component - utils.py additions: safe_json_loads, read_json_file, read_jsonl, append_jsonl, env_str/lower/int/bool helpers - hermes_constants.py additions: get_config_path, get_skills_dir, get_logs_dir, get_env_path * refactor: migrate gateway adapters to shared helpers - MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost - strip_markdown: bluebubbles, feishu, sms - redact_phone: sms, signal - ThreadParticipationTracker: discord, matrix - _acquire/_release_platform_lock: telegram, discord, slack, whatsapp, signal, weixin Net -316 lines across 19 files. * refactor: migrate CLI modules to shared helpers - tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines) - setup.py: use cli_output print helpers + curses_radiolist (-101 lines) - mcp_config.py: use cli_output prompt (-15 lines) - memory_setup.py: use curses_radiolist (-86 lines) Net -263 lines across 5 files. * refactor: migrate to shared utility helpers - safe_json_loads: agent/display.py (4 sites) - get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py - get_skills_dir: skill_utils.py, prompt_builder.py - Token estimation dedup: skills_tool.py imports from model_metadata - Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files - Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write - Platform dict: new platforms.py, skills_config + tools_config derive from it - Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main * test: update tests for shared helper migrations - test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate() - test_mattermost: use _dedup instead of _seen_posts/_prune_seen - test_signal: import redact_phone from helpers instead of signal - test_discord_connect: _platform_lock_identity instead of _token_lock_identity - test_telegram_conflict: updated lock error message format - test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
This commit is contained in:
@@ -211,7 +211,8 @@ def make_adapter(platform: Platform, runner=None):
|
||||
config = PlatformConfig(enabled=True, token="e2e-test-token")
|
||||
|
||||
if platform == Platform.DISCORD:
|
||||
with patch.object(DiscordAdapter, "_load_participated_threads", return_value=set()):
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
with patch.object(ThreadParticipationTracker, "_load", return_value=set()):
|
||||
adapter = DiscordAdapter(config)
|
||||
platform_key = Platform.DISCORD
|
||||
elif platform == Platform.SLACK:
|
||||
|
||||
@@ -119,28 +119,29 @@ class TestDeduplication:
|
||||
def test_first_message_not_duplicate(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
assert adapter._is_duplicate("msg-1") is False
|
||||
assert adapter._dedup.is_duplicate("msg-1") is False
|
||||
|
||||
def test_second_same_message_is_duplicate(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._is_duplicate("msg-1")
|
||||
assert adapter._is_duplicate("msg-1") is True
|
||||
adapter._dedup.is_duplicate("msg-1")
|
||||
assert adapter._dedup.is_duplicate("msg-1") is True
|
||||
|
||||
def test_different_messages_not_duplicate(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._is_duplicate("msg-1")
|
||||
assert adapter._is_duplicate("msg-2") is False
|
||||
adapter._dedup.is_duplicate("msg-1")
|
||||
assert adapter._dedup.is_duplicate("msg-2") is False
|
||||
|
||||
def test_cache_cleanup_on_overflow(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter, DEDUP_MAX_SIZE
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
max_size = adapter._dedup._max_size
|
||||
# Fill beyond max
|
||||
for i in range(DEDUP_MAX_SIZE + 10):
|
||||
adapter._is_duplicate(f"msg-{i}")
|
||||
for i in range(max_size + 10):
|
||||
adapter._dedup.is_duplicate(f"msg-{i}")
|
||||
# Cache should have been pruned
|
||||
assert len(adapter._seen_messages) <= DEDUP_MAX_SIZE + 10
|
||||
assert len(adapter._dedup._seen) <= max_size + 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -253,13 +254,13 @@ class TestConnect:
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._session_webhooks["a"] = "http://x"
|
||||
adapter._seen_messages["b"] = 1.0
|
||||
adapter._dedup._seen["b"] = 1.0
|
||||
adapter._http_client = AsyncMock()
|
||||
adapter._stream_task = None
|
||||
|
||||
await adapter.disconnect()
|
||||
assert len(adapter._session_webhooks) == 0
|
||||
assert len(adapter._seen_messages) == 0
|
||||
assert len(adapter._dedup._seen) == 0
|
||||
assert adapter._http_client is None
|
||||
|
||||
|
||||
|
||||
@@ -137,4 +137,4 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
|
||||
|
||||
assert ok is False
|
||||
assert released == [("discord-bot-token", "test-token")]
|
||||
assert adapter._token_lock_identity is None
|
||||
assert adapter._platform_lock_identity is None
|
||||
|
||||
@@ -302,7 +302,7 @@ async def test_discord_bot_thread_skips_mention_requirement(adapter, monkeypatch
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||
|
||||
# Simulate bot having previously participated in thread 456
|
||||
adapter._bot_participated_threads.add("456")
|
||||
adapter._threads.mark("456")
|
||||
|
||||
thread = FakeThread(channel_id=456, name="existing thread")
|
||||
message = make_message(channel=thread, content="follow-up without mention")
|
||||
@@ -344,7 +344,7 @@ async def test_discord_auto_thread_tracks_participation(adapter, monkeypatch):
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
assert "555" in adapter._bot_participated_threads
|
||||
assert "555" in adapter._threads
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -358,4 +358,4 @@ async def test_discord_thread_participation_tracked_on_dispatch(adapter, monkeyp
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
assert "777" in adapter._bot_participated_threads
|
||||
assert "777" in adapter._threads
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for Discord thread participation persistence.
|
||||
|
||||
Verifies that _bot_participated_threads survives adapter restarts by
|
||||
Verifies that _threads (ThreadParticipationTracker) survives adapter restarts by
|
||||
being persisted to ~/.hermes/discord_threads.json.
|
||||
"""
|
||||
|
||||
@@ -25,13 +25,13 @@ class TestDiscordThreadPersistence:
|
||||
|
||||
def test_starts_empty_when_no_state_file(self, tmp_path):
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
assert adapter._bot_participated_threads == set()
|
||||
assert "$nonexistent" not in adapter._threads
|
||||
|
||||
def test_track_thread_persists_to_disk(self, tmp_path):
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
adapter._track_thread("111")
|
||||
adapter._track_thread("222")
|
||||
adapter._threads.mark("111")
|
||||
adapter._threads.mark("222")
|
||||
|
||||
state_file = tmp_path / "discord_threads.json"
|
||||
assert state_file.exists()
|
||||
@@ -42,42 +42,43 @@ class TestDiscordThreadPersistence:
|
||||
"""Threads tracked by one adapter instance are visible to the next."""
|
||||
adapter1 = self._make_adapter(tmp_path)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
adapter1._track_thread("aaa")
|
||||
adapter1._track_thread("bbb")
|
||||
adapter1._threads.mark("aaa")
|
||||
adapter1._threads.mark("bbb")
|
||||
|
||||
adapter2 = self._make_adapter(tmp_path)
|
||||
assert "aaa" in adapter2._bot_participated_threads
|
||||
assert "bbb" in adapter2._bot_participated_threads
|
||||
assert "aaa" in adapter2._threads
|
||||
assert "bbb" in adapter2._threads
|
||||
|
||||
def test_duplicate_track_does_not_double_save(self, tmp_path):
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
adapter._track_thread("111")
|
||||
adapter._track_thread("111") # no-op
|
||||
adapter._threads.mark("111")
|
||||
adapter._threads.mark("111") # no-op
|
||||
|
||||
saved = json.loads((tmp_path / "discord_threads.json").read_text())
|
||||
assert saved.count("111") == 1
|
||||
|
||||
def test_caps_at_max_tracked_threads(self, tmp_path):
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
adapter._MAX_TRACKED_THREADS = 5
|
||||
adapter._threads._max_tracked = 5
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
for i in range(10):
|
||||
adapter._track_thread(str(i))
|
||||
adapter._threads.mark(str(i))
|
||||
|
||||
assert len(adapter._bot_participated_threads) == 5
|
||||
saved = json.loads((tmp_path / "discord_threads.json").read_text())
|
||||
assert len(saved) == 5
|
||||
|
||||
def test_corrupted_state_file_falls_back_to_empty(self, tmp_path):
|
||||
state_file = tmp_path / "discord_threads.json"
|
||||
state_file.write_text("not valid json{{{")
|
||||
adapter = self._make_adapter(tmp_path)
|
||||
assert adapter._bot_participated_threads == set()
|
||||
assert "$nonexistent" not in adapter._threads
|
||||
|
||||
def test_missing_hermes_home_does_not_crash(self, tmp_path):
|
||||
"""Load/save tolerate missing directories."""
|
||||
fake_home = tmp_path / "nonexistent" / "deep"
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(fake_home)}):
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
# _load should return empty set, not crash
|
||||
threads = DiscordAdapter._load_participated_threads()
|
||||
assert threads == set()
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
# ThreadParticipationTracker should return empty set, not crash
|
||||
tracker = ThreadParticipationTracker("discord")
|
||||
assert "$test" not in tracker
|
||||
|
||||
@@ -247,7 +247,7 @@ async def test_require_mention_bot_participated_thread(monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
adapter._bot_participated_threads.add("$thread1")
|
||||
adapter._threads.mark("$thread1")
|
||||
|
||||
event = _make_event("hello without mention", thread_id="$thread1")
|
||||
|
||||
@@ -298,7 +298,7 @@ async def test_auto_thread_preserves_existing_thread(monkeypatch):
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
adapter._bot_participated_threads.add("$thread_root")
|
||||
adapter._threads.mark("$thread_root")
|
||||
event = _make_event("reply in thread", thread_id="$thread_root")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
@@ -340,17 +340,17 @@ async def test_auto_thread_disabled(monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_tracks_participation(monkeypatch):
|
||||
"""Auto-created threads are tracked in _bot_participated_threads."""
|
||||
"""Auto-created threads are tracked in _threads."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("hello", event_id="$msg1")
|
||||
|
||||
with patch.object(adapter, "_save_participated_threads"):
|
||||
with patch.object(adapter._threads, "_save"):
|
||||
await adapter._on_room_message(event)
|
||||
|
||||
assert "$msg1" in adapter._bot_participated_threads
|
||||
assert "$msg1" in adapter._threads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -361,56 +361,54 @@ async def test_auto_thread_tracks_participation(monkeypatch):
|
||||
class TestThreadPersistence:
|
||||
def test_empty_state_file(self, tmp_path, monkeypatch):
|
||||
"""No state file → empty set."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
monkeypatch.setattr(
|
||||
MatrixAdapter, "_thread_state_path",
|
||||
staticmethod(lambda: tmp_path / "matrix_threads.json"),
|
||||
ThreadParticipationTracker, "_state_path",
|
||||
lambda self: tmp_path / "matrix_threads.json",
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
loaded = adapter._load_participated_threads()
|
||||
assert loaded == set()
|
||||
assert "$nonexistent" not in adapter._threads
|
||||
|
||||
def test_track_thread_persists(self, tmp_path, monkeypatch):
|
||||
"""_track_thread writes to disk."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
"""mark() writes to disk."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
monkeypatch.setattr(
|
||||
MatrixAdapter, "_thread_state_path",
|
||||
staticmethod(lambda: state_path),
|
||||
ThreadParticipationTracker, "_state_path",
|
||||
lambda self: state_path,
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
adapter._track_thread("$thread_abc")
|
||||
adapter._threads.mark("$thread_abc")
|
||||
|
||||
data = json.loads(state_path.read_text())
|
||||
assert "$thread_abc" in data
|
||||
|
||||
def test_threads_survive_reload(self, tmp_path, monkeypatch):
|
||||
"""Persisted threads are loaded by a new adapter instance."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
state_path.write_text(json.dumps(["$t1", "$t2"]))
|
||||
monkeypatch.setattr(
|
||||
MatrixAdapter, "_thread_state_path",
|
||||
staticmethod(lambda: state_path),
|
||||
ThreadParticipationTracker, "_state_path",
|
||||
lambda self: state_path,
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
assert "$t1" in adapter._bot_participated_threads
|
||||
assert "$t2" in adapter._bot_participated_threads
|
||||
assert "$t1" in adapter._threads
|
||||
assert "$t2" in adapter._threads
|
||||
|
||||
def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):
|
||||
"""Thread set is trimmed to _MAX_TRACKED_THREADS."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
"""Thread set is trimmed to max_tracked."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
monkeypatch.setattr(
|
||||
MatrixAdapter, "_thread_state_path",
|
||||
staticmethod(lambda: state_path),
|
||||
ThreadParticipationTracker, "_state_path",
|
||||
lambda self: state_path,
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
adapter._MAX_TRACKED_THREADS = 5
|
||||
adapter._threads._max_tracked = 5
|
||||
|
||||
for i in range(10):
|
||||
adapter._bot_participated_threads.add(f"$t{i}")
|
||||
adapter._save_participated_threads()
|
||||
adapter._threads.mark(f"$t{i}")
|
||||
|
||||
data = json.loads(state_path.read_text())
|
||||
assert len(data) == 5
|
||||
@@ -447,7 +445,7 @@ async def test_dm_mention_thread_creates_thread(monkeypatch):
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help me", event_id="$dm1")
|
||||
|
||||
with patch.object(adapter, "_save_participated_threads"):
|
||||
with patch.object(adapter._threads, "_save"):
|
||||
await adapter._on_room_message(event)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
@@ -480,7 +478,7 @@ async def test_dm_mention_thread_preserves_existing_thread(monkeypatch):
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
adapter._bot_participated_threads.add("$existing_thread")
|
||||
adapter._threads.mark("$existing_thread")
|
||||
event = _make_event("@hermes:example.org help me", thread_id="$existing_thread")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
@@ -491,7 +489,7 @@ async def test_dm_mention_thread_preserves_existing_thread(monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_tracks_participation(monkeypatch):
|
||||
"""DM mention-thread tracks the thread in _bot_participated_threads."""
|
||||
"""DM mention-thread tracks the thread in _threads."""
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
@@ -499,10 +497,10 @@ async def test_dm_mention_thread_tracks_participation(monkeypatch):
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help", event_id="$dm1")
|
||||
|
||||
with patch.object(adapter, "_save_participated_threads"):
|
||||
with patch.object(adapter._threads, "_save"):
|
||||
await adapter._on_room_message(event)
|
||||
|
||||
assert "$dm1" in adapter._bot_participated_threads
|
||||
assert "$dm1" in adapter._threads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -614,25 +614,27 @@ class TestMattermostDedup:
|
||||
assert self.adapter.handle_message.call_count == 2
|
||||
|
||||
def test_prune_seen_clears_expired(self):
|
||||
"""_prune_seen should remove entries older than _SEEN_TTL."""
|
||||
"""Dedup cache should remove entries older than TTL on overflow."""
|
||||
now = time.time()
|
||||
dedup = self.adapter._dedup
|
||||
# Fill with enough expired entries to trigger pruning
|
||||
for i in range(self.adapter._SEEN_MAX + 10):
|
||||
self.adapter._seen_posts[f"old_{i}"] = now - 600 # 10 min ago
|
||||
for i in range(dedup._max_size + 10):
|
||||
dedup._seen[f"old_{i}"] = now - 600 # 10 min ago (older than default TTL)
|
||||
|
||||
# Add a fresh one
|
||||
self.adapter._seen_posts["fresh"] = now
|
||||
dedup._seen["fresh"] = now
|
||||
|
||||
self.adapter._prune_seen()
|
||||
# Trigger pruning by calling is_duplicate with a new entry (over max_size)
|
||||
dedup.is_duplicate("trigger_prune")
|
||||
|
||||
# Old entries should be pruned, fresh one kept
|
||||
assert "fresh" in self.adapter._seen_posts
|
||||
assert len(self.adapter._seen_posts) < self.adapter._SEEN_MAX
|
||||
assert "fresh" in dedup._seen
|
||||
assert len(dedup._seen) < dedup._max_size + 10
|
||||
|
||||
def test_seen_cache_tracks_post_ids(self):
|
||||
"""Posts are tracked in _seen_posts dict."""
|
||||
self.adapter._seen_posts["test_post"] = time.time()
|
||||
assert "test_post" in self.adapter._seen_posts
|
||||
"""Posts are tracked in the dedup cache."""
|
||||
self.adapter._dedup._seen["test_post"] = time.time()
|
||||
assert "test_post" in self.adapter._dedup._seen
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -114,16 +114,16 @@ class TestSignalAdapterInit:
|
||||
|
||||
class TestSignalHelpers:
|
||||
def test_redact_phone_long(self):
|
||||
from gateway.platforms.signal import _redact_phone
|
||||
assert _redact_phone("+15551234567") == "+155****4567"
|
||||
from gateway.platforms.helpers import redact_phone
|
||||
assert redact_phone("+155****4567") == "+155****4567"
|
||||
|
||||
def test_redact_phone_short(self):
|
||||
from gateway.platforms.signal import _redact_phone
|
||||
assert _redact_phone("+12345") == "+1****45"
|
||||
from gateway.platforms.helpers import redact_phone
|
||||
assert redact_phone("+12345") == "+1****45"
|
||||
|
||||
def test_redact_phone_empty(self):
|
||||
from gateway.platforms.signal import _redact_phone
|
||||
assert _redact_phone("") == "<none>"
|
||||
from gateway.platforms.helpers import redact_phone
|
||||
assert redact_phone("") == "<none>"
|
||||
|
||||
def test_parse_comma_list(self):
|
||||
from gateway.platforms.signal import _parse_comma_list
|
||||
|
||||
@@ -43,6 +43,8 @@ def _no_auto_discovery(monkeypatch):
|
||||
async def _noop():
|
||||
return []
|
||||
monkeypatch.setattr("gateway.platforms.telegram.discover_fallback_ips", _noop)
|
||||
# Mock HTTPXRequest so the builder chain doesn't fail
|
||||
monkeypatch.setattr("gateway.platforms.telegram.HTTPXRequest", lambda **kwargs: MagicMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -57,9 +59,9 @@ async def test_connect_rejects_same_host_token_lock(monkeypatch):
|
||||
ok = await adapter.connect()
|
||||
|
||||
assert ok is False
|
||||
assert adapter.fatal_error_code == "telegram_token_lock"
|
||||
assert adapter.fatal_error_code == "telegram-bot-token_lock"
|
||||
assert adapter.has_fatal_error is True
|
||||
assert "already using this Telegram bot token" in adapter.fatal_error_message
|
||||
assert "already in use" in adapter.fatal_error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -98,6 +100,8 @@ async def test_polling_conflict_retries_before_fatal(monkeypatch):
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
|
||||
@@ -172,6 +176,8 @@ async def test_polling_conflict_becomes_fatal_after_retries(monkeypatch):
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
|
||||
@@ -216,6 +222,8 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m
|
||||
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
app = SimpleNamespace(
|
||||
bot=SimpleNamespace(delete_webhook=AsyncMock(), set_my_commands=AsyncMock()),
|
||||
updater=SimpleNamespace(),
|
||||
@@ -265,6 +273,8 @@ async def test_connect_clears_webhook_before_polling(monkeypatch):
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.telegram.Application",
|
||||
|
||||
@@ -348,7 +348,7 @@ word word
|
||||
result = _patch_skill("my-skill", "old text", "new text", file_path="references/evil.md")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "boundary" in result["error"].lower()
|
||||
assert "escapes" in result["error"].lower()
|
||||
assert outside_file.read_text() == "old text here"
|
||||
|
||||
|
||||
@@ -412,7 +412,7 @@ class TestWriteFile:
|
||||
result = _write_file("my-skill", "references/escape/owned.md", "malicious")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "boundary" in result["error"].lower()
|
||||
assert "escapes" in result["error"].lower()
|
||||
assert not (outside_dir / "owned.md").exists()
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ class TestRemoveFile:
|
||||
result = _remove_file("my-skill", "references/escape/keep.txt")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "boundary" in result["error"].lower()
|
||||
assert "escapes" in result["error"].lower()
|
||||
assert outside_file.exists()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user