mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
* 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
275 lines
10 KiB
Python
275 lines
10 KiB
Python
"""Tests for DingTalk platform adapter."""
|
|
import asyncio
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Requirements check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDingTalkRequirements:
|
|
|
|
def test_returns_false_when_sdk_missing(self, monkeypatch):
|
|
with patch.dict("sys.modules", {"dingtalk_stream": None}):
|
|
monkeypatch.setattr(
|
|
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False
|
|
)
|
|
from gateway.platforms.dingtalk import check_dingtalk_requirements
|
|
assert check_dingtalk_requirements() is False
|
|
|
|
def test_returns_false_when_env_vars_missing(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", True
|
|
)
|
|
monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True)
|
|
monkeypatch.delenv("DINGTALK_CLIENT_ID", raising=False)
|
|
monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False)
|
|
from gateway.platforms.dingtalk import check_dingtalk_requirements
|
|
assert check_dingtalk_requirements() is False
|
|
|
|
def test_returns_true_when_all_available(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", True
|
|
)
|
|
monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True)
|
|
monkeypatch.setenv("DINGTALK_CLIENT_ID", "test-id")
|
|
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "test-secret")
|
|
from gateway.platforms.dingtalk import check_dingtalk_requirements
|
|
assert check_dingtalk_requirements() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDingTalkAdapterInit:
|
|
|
|
def test_reads_config_from_extra(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
extra={"client_id": "cfg-id", "client_secret": "cfg-secret"},
|
|
)
|
|
adapter = DingTalkAdapter(config)
|
|
assert adapter._client_id == "cfg-id"
|
|
assert adapter._client_secret == "cfg-secret"
|
|
assert adapter.name == "Dingtalk" # base class uses .title()
|
|
|
|
def test_falls_back_to_env_vars(self, monkeypatch):
|
|
monkeypatch.setenv("DINGTALK_CLIENT_ID", "env-id")
|
|
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env-secret")
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
config = PlatformConfig(enabled=True)
|
|
adapter = DingTalkAdapter(config)
|
|
assert adapter._client_id == "env-id"
|
|
assert adapter._client_secret == "env-secret"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Message text extraction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExtractText:
|
|
|
|
def test_extracts_dict_text(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
msg = MagicMock()
|
|
msg.text = {"content": " hello world "}
|
|
msg.rich_text = None
|
|
assert DingTalkAdapter._extract_text(msg) == "hello world"
|
|
|
|
def test_extracts_string_text(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
msg = MagicMock()
|
|
msg.text = "plain text"
|
|
msg.rich_text = None
|
|
assert DingTalkAdapter._extract_text(msg) == "plain text"
|
|
|
|
def test_falls_back_to_rich_text(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
msg = MagicMock()
|
|
msg.text = ""
|
|
msg.rich_text = [{"text": "part1"}, {"text": "part2"}, {"image": "url"}]
|
|
assert DingTalkAdapter._extract_text(msg) == "part1 part2"
|
|
|
|
def test_returns_empty_for_no_content(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
msg = MagicMock()
|
|
msg.text = ""
|
|
msg.rich_text = None
|
|
assert DingTalkAdapter._extract_text(msg) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Deduplication
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeduplication:
|
|
|
|
def test_first_message_not_duplicate(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
|
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._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._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
|
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
|
max_size = adapter._dedup._max_size
|
|
# Fill beyond max
|
|
for i in range(max_size + 10):
|
|
adapter._dedup.is_duplicate(f"msg-{i}")
|
|
# Cache should have been pruned
|
|
assert len(adapter._dedup._seen) <= max_size + 10
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Send
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSend:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_posts_to_webhook(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.text = "OK"
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
adapter._http_client = mock_client
|
|
|
|
result = await adapter.send(
|
|
"chat-123", "Hello!",
|
|
metadata={"session_webhook": "https://dingtalk.example/webhook"}
|
|
)
|
|
assert result.success is True
|
|
mock_client.post.assert_called_once()
|
|
call_args = mock_client.post.call_args
|
|
assert call_args[0][0] == "https://dingtalk.example/webhook"
|
|
payload = call_args[1]["json"]
|
|
assert payload["msgtype"] == "markdown"
|
|
assert payload["markdown"]["title"] == "Hermes"
|
|
assert payload["markdown"]["text"] == "Hello!"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_fails_without_webhook(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
|
adapter._http_client = AsyncMock()
|
|
|
|
result = await adapter.send("chat-123", "Hello!")
|
|
assert result.success is False
|
|
assert "session_webhook" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_uses_cached_webhook(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
adapter._http_client = mock_client
|
|
adapter._session_webhooks["chat-123"] = "https://cached.example/webhook"
|
|
|
|
result = await adapter.send("chat-123", "Hello!")
|
|
assert result.success is True
|
|
assert mock_client.post.call_args[0][0] == "https://cached.example/webhook"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_handles_http_error(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 400
|
|
mock_response.text = "Bad Request"
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
adapter._http_client = mock_client
|
|
|
|
result = await adapter.send(
|
|
"chat-123", "Hello!",
|
|
metadata={"session_webhook": "https://example/webhook"}
|
|
)
|
|
assert result.success is False
|
|
assert "400" in result.error
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connect / disconnect
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConnect:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_fails_without_sdk(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False
|
|
)
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
|
result = await adapter.connect()
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_fails_without_credentials(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
|
adapter._client_id = ""
|
|
adapter._client_secret = ""
|
|
result = await adapter.connect()
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_cleans_up(self):
|
|
from gateway.platforms.dingtalk import DingTalkAdapter
|
|
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
|
adapter._session_webhooks["a"] = "http://x"
|
|
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._dedup._seen) == 0
|
|
assert adapter._http_client is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Platform enum
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPlatformEnum:
|
|
|
|
def test_dingtalk_in_platform_enum(self):
|
|
assert Platform.DINGTALK.value == "dingtalk"
|