Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -35,6 +35,7 @@ def make_restart_source(chat_id: str = "123456", chat_type: str = "dm") -> Sessi
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=chat_id,
|
||||
chat_type=chat_type,
|
||||
user_id="u1",
|
||||
)
|
||||
|
||||
|
||||
|
||||
87
tests/gateway/test_api_server_normalize.py
Normal file
87
tests/gateway/test_api_server_normalize.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Tests for _normalize_chat_content in the API server adapter."""
|
||||
|
||||
from gateway.platforms.api_server import _normalize_chat_content
|
||||
|
||||
|
||||
class TestNormalizeChatContent:
|
||||
"""Content normalization converts array-based content parts to plain text."""
|
||||
|
||||
def test_none_returns_empty_string(self):
|
||||
assert _normalize_chat_content(None) == ""
|
||||
|
||||
def test_plain_string_returned_as_is(self):
|
||||
assert _normalize_chat_content("hello world") == "hello world"
|
||||
|
||||
def test_empty_string_returned_as_is(self):
|
||||
assert _normalize_chat_content("") == ""
|
||||
|
||||
def test_text_content_part(self):
|
||||
content = [{"type": "text", "text": "hello"}]
|
||||
assert _normalize_chat_content(content) == "hello"
|
||||
|
||||
def test_input_text_content_part(self):
|
||||
content = [{"type": "input_text", "text": "user input"}]
|
||||
assert _normalize_chat_content(content) == "user input"
|
||||
|
||||
def test_output_text_content_part(self):
|
||||
content = [{"type": "output_text", "text": "assistant output"}]
|
||||
assert _normalize_chat_content(content) == "assistant output"
|
||||
|
||||
def test_multiple_text_parts_joined_with_newline(self):
|
||||
content = [
|
||||
{"type": "text", "text": "first"},
|
||||
{"type": "text", "text": "second"},
|
||||
]
|
||||
assert _normalize_chat_content(content) == "first\nsecond"
|
||||
|
||||
def test_mixed_string_and_dict_parts(self):
|
||||
content = ["plain string", {"type": "text", "text": "dict part"}]
|
||||
assert _normalize_chat_content(content) == "plain string\ndict part"
|
||||
|
||||
def test_image_url_parts_silently_skipped(self):
|
||||
content = [
|
||||
{"type": "text", "text": "check this:"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
|
||||
]
|
||||
assert _normalize_chat_content(content) == "check this:"
|
||||
|
||||
def test_integer_content_converted(self):
|
||||
assert _normalize_chat_content(42) == "42"
|
||||
|
||||
def test_boolean_content_converted(self):
|
||||
assert _normalize_chat_content(True) == "True"
|
||||
|
||||
def test_deeply_nested_list_respects_depth_limit(self):
|
||||
"""Nesting beyond max_depth returns empty string."""
|
||||
content = [[[[[[[[[[[["deep"]]]]]]]]]]]]
|
||||
result = _normalize_chat_content(content)
|
||||
# The deep nesting should be truncated, not crash
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_large_list_capped(self):
|
||||
"""Lists beyond MAX_CONTENT_LIST_SIZE are truncated."""
|
||||
content = [{"type": "text", "text": f"item{i}"} for i in range(2000)]
|
||||
result = _normalize_chat_content(content)
|
||||
# Should not contain all 2000 items
|
||||
assert result.count("item") <= 1000
|
||||
|
||||
def test_oversized_string_truncated(self):
|
||||
"""Strings beyond 64KB are truncated."""
|
||||
huge = "x" * 100_000
|
||||
result = _normalize_chat_content(huge)
|
||||
assert len(result) == 65_536
|
||||
|
||||
def test_empty_text_parts_filtered(self):
|
||||
content = [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "text", "text": "actual"},
|
||||
{"type": "text", "text": ""},
|
||||
]
|
||||
assert _normalize_chat_content(content) == "actual"
|
||||
|
||||
def test_dict_without_type_skipped(self):
|
||||
content = [{"foo": "bar"}, {"type": "text", "text": "real"}]
|
||||
assert _normalize_chat_content(content) == "real"
|
||||
|
||||
def test_empty_list_returns_empty(self):
|
||||
assert _normalize_chat_content([]) == ""
|
||||
@@ -359,3 +359,44 @@ async def test_discord_thread_participation_tracked_on_dispatch(adapter, monkeyp
|
||||
await adapter._handle_message(message)
|
||||
|
||||
assert "777" in adapter._threads
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_thread(adapter, monkeypatch):
|
||||
"""Active voice-linked text channels should behave like free-response channels."""
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter._voice_text_channels[111] = 789
|
||||
adapter._auto_create_thread = AsyncMock()
|
||||
|
||||
message = make_message(
|
||||
channel=FakeTextChannel(channel_id=789),
|
||||
content="follow-up from voice text chat",
|
||||
)
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter._auto_create_thread.assert_not_awaited()
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.text == "follow-up from voice text chat"
|
||||
assert event.source.chat_type == "group"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter, monkeypatch):
|
||||
"""Threads under a voice-linked channel should still require @mention."""
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
|
||||
adapter._voice_text_channels[111] = 789
|
||||
message = make_message(
|
||||
channel=FakeThread(channel_id=790, parent=FakeTextChannel(channel_id=789)),
|
||||
content="thread reply without mention",
|
||||
)
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
@@ -124,7 +124,7 @@ class TestSendWithReplyToMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_off_mode_no_reply_reference(self):
|
||||
adapter, channel, ref_msg = _make_discord_adapter("off")
|
||||
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
|
||||
|
||||
await adapter.send("12345", "test content", reply_to="999")
|
||||
|
||||
@@ -137,7 +137,7 @@ class TestSendWithReplyToMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_mode_only_first_chunk_references(self):
|
||||
adapter, channel, ref_msg = _make_discord_adapter("first")
|
||||
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
|
||||
|
||||
await adapter.send("12345", "test content", reply_to="999")
|
||||
|
||||
@@ -152,7 +152,7 @@ class TestSendWithReplyToMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_mode_all_chunks_reference(self):
|
||||
adapter, channel, ref_msg = _make_discord_adapter("all")
|
||||
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
|
||||
|
||||
await adapter.send("12345", "test content", reply_to="999")
|
||||
|
||||
@@ -165,7 +165,7 @@ class TestSendWithReplyToMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reply_to_param_no_reference(self):
|
||||
adapter, channel, ref_msg = _make_discord_adapter("all")
|
||||
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"]
|
||||
|
||||
await adapter.send("12345", "test content", reply_to=None)
|
||||
|
||||
@@ -176,7 +176,7 @@ class TestSendWithReplyToMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_chunk_respects_first_mode(self):
|
||||
adapter, channel, ref_msg = _make_discord_adapter("first")
|
||||
adapter.truncate_message = lambda content, max_len: ["single chunk"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"]
|
||||
|
||||
await adapter.send("12345", "test", reply_to="999")
|
||||
|
||||
@@ -187,7 +187,7 @@ class TestSendWithReplyToMode:
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_chunk_off_mode(self):
|
||||
adapter, channel, ref_msg = _make_discord_adapter("off")
|
||||
adapter.truncate_message = lambda content, max_len: ["single chunk"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"]
|
||||
|
||||
await adapter.send("12345", "test", reply_to="999")
|
||||
|
||||
@@ -200,7 +200,7 @@ class TestSendWithReplyToMode:
|
||||
async def test_invalid_mode_falls_back_to_first_behavior(self):
|
||||
"""Invalid mode behaves like 'first' — only first chunk gets reference."""
|
||||
adapter, channel, ref_msg = _make_discord_adapter("banana")
|
||||
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"]
|
||||
|
||||
await adapter.send("12345", "test", reply_to="999")
|
||||
|
||||
|
||||
@@ -189,14 +189,14 @@ class TestPlatformDefaults:
|
||||
"""Slack, Mattermost, Matrix default to 'new' tool progress."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
for plat in ("slack", "mattermost", "matrix", "feishu"):
|
||||
for plat in ("slack", "mattermost", "matrix", "feishu", "whatsapp"):
|
||||
assert resolve_display_setting({}, plat, "tool_progress") == "new", plat
|
||||
|
||||
def test_low_tier_platforms(self):
|
||||
"""Signal, WhatsApp, etc. default to 'off' tool progress."""
|
||||
"""Signal, BlueBubbles, etc. default to 'off' tool progress."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
for plat in ("signal", "whatsapp", "bluebubbles", "weixin", "wecom", "dingtalk"):
|
||||
for plat in ("signal", "bluebubbles", "weixin", "wecom", "dingtalk"):
|
||||
assert resolve_display_setting({}, plat, "tool_progress") == "off", plat
|
||||
|
||||
def test_minimal_tier_platforms(self):
|
||||
|
||||
438
tests/gateway/test_feishu_onboard.py
Normal file
438
tests/gateway/test_feishu_onboard.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""Tests for gateway.platforms.feishu — Feishu scan-to-create registration."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def _mock_urlopen(response_data, status=200):
|
||||
"""Create a mock for urllib.request.urlopen that returns JSON response_data."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(response_data).encode("utf-8")
|
||||
mock_response.status = status
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
return mock_response
|
||||
|
||||
|
||||
class TestPostRegistration:
|
||||
"""Tests for the low-level HTTP helper."""
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_post_registration_returns_parsed_json(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _post_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({"nonce": "abc", "supported_auth_methods": ["client_secret"]})
|
||||
result = _post_registration("https://accounts.feishu.cn", {"action": "init"})
|
||||
assert result["nonce"] == "abc"
|
||||
assert "client_secret" in result["supported_auth_methods"]
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_post_registration_sends_form_encoded_body(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _post_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({})
|
||||
_post_registration("https://accounts.feishu.cn", {"action": "init", "key": "val"})
|
||||
call_args = mock_urlopen_fn.call_args
|
||||
request = call_args[0][0]
|
||||
body = request.data.decode("utf-8")
|
||||
assert "action=init" in body
|
||||
assert "key=val" in body
|
||||
assert request.get_header("Content-type") == "application/x-www-form-urlencoded"
|
||||
|
||||
|
||||
class TestInitRegistration:
|
||||
"""Tests for the init step."""
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_init_succeeds_when_client_secret_supported(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _init_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"nonce": "abc",
|
||||
"supported_auth_methods": ["client_secret"],
|
||||
})
|
||||
_init_registration("feishu")
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_init_raises_when_client_secret_not_supported(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _init_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"nonce": "abc",
|
||||
"supported_auth_methods": ["other_method"],
|
||||
})
|
||||
with pytest.raises(RuntimeError, match="client_secret"):
|
||||
_init_registration("feishu")
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_init_uses_lark_url_for_lark_domain(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _init_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"nonce": "abc",
|
||||
"supported_auth_methods": ["client_secret"],
|
||||
})
|
||||
_init_registration("lark")
|
||||
call_args = mock_urlopen_fn.call_args
|
||||
request = call_args[0][0]
|
||||
assert "larksuite.com" in request.full_url
|
||||
|
||||
|
||||
class TestBeginRegistration:
|
||||
"""Tests for the begin step."""
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_begin_returns_device_code_and_qr_url(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _begin_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"device_code": "dc_123",
|
||||
"verification_uri_complete": "https://accounts.feishu.cn/qr/abc",
|
||||
"user_code": "ABCD-1234",
|
||||
"interval": 5,
|
||||
"expire_in": 600,
|
||||
})
|
||||
result = _begin_registration("feishu")
|
||||
assert result["device_code"] == "dc_123"
|
||||
assert "qr_url" in result
|
||||
assert "accounts.feishu.cn" in result["qr_url"]
|
||||
assert result["user_code"] == "ABCD-1234"
|
||||
assert result["interval"] == 5
|
||||
assert result["expire_in"] == 600
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_begin_sends_correct_archetype(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _begin_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"device_code": "dc_123",
|
||||
"verification_uri_complete": "https://example.com/qr",
|
||||
"user_code": "X",
|
||||
"interval": 5,
|
||||
"expire_in": 600,
|
||||
})
|
||||
_begin_registration("feishu")
|
||||
request = mock_urlopen_fn.call_args[0][0]
|
||||
body = request.data.decode("utf-8")
|
||||
assert "archetype=PersonalAgent" in body
|
||||
assert "auth_method=client_secret" in body
|
||||
|
||||
|
||||
class TestPollRegistration:
|
||||
"""Tests for the poll step."""
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_poll_returns_credentials_on_success(self, mock_urlopen_fn, mock_time):
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
|
||||
mock_time.time.side_effect = [0, 1]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"client_id": "cli_app123",
|
||||
"client_secret": "secret456",
|
||||
"user_info": {"open_id": "ou_owner", "tenant_brand": "feishu"},
|
||||
})
|
||||
result = _poll_registration(
|
||||
device_code="dc_123", interval=1, expire_in=60, domain="feishu"
|
||||
)
|
||||
assert result is not None
|
||||
assert result["app_id"] == "cli_app123"
|
||||
assert result["app_secret"] == "secret456"
|
||||
assert result["domain"] == "feishu"
|
||||
assert result["open_id"] == "ou_owner"
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_poll_switches_domain_on_lark_tenant_brand(self, mock_urlopen_fn, mock_time):
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
|
||||
mock_time.time.side_effect = [0, 1, 2]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
||||
pending_resp = _mock_urlopen({
|
||||
"error": "authorization_pending",
|
||||
"user_info": {"tenant_brand": "lark"},
|
||||
})
|
||||
success_resp = _mock_urlopen({
|
||||
"client_id": "cli_lark",
|
||||
"client_secret": "secret_lark",
|
||||
"user_info": {"open_id": "ou_lark", "tenant_brand": "lark"},
|
||||
})
|
||||
mock_urlopen_fn.side_effect = [pending_resp, success_resp]
|
||||
|
||||
result = _poll_registration(
|
||||
device_code="dc_123", interval=0, expire_in=60, domain="feishu"
|
||||
)
|
||||
assert result is not None
|
||||
assert result["domain"] == "lark"
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_poll_success_with_lark_brand_in_same_response(self, mock_urlopen_fn, mock_time):
|
||||
"""Credentials and lark tenant_brand in one response must not be discarded."""
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
|
||||
mock_time.time.side_effect = [0, 1]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"client_id": "cli_lark_direct",
|
||||
"client_secret": "secret_lark_direct",
|
||||
"user_info": {"open_id": "ou_lark_direct", "tenant_brand": "lark"},
|
||||
})
|
||||
result = _poll_registration(
|
||||
device_code="dc_123", interval=1, expire_in=60, domain="feishu"
|
||||
)
|
||||
assert result is not None
|
||||
assert result["app_id"] == "cli_lark_direct"
|
||||
assert result["domain"] == "lark"
|
||||
assert result["open_id"] == "ou_lark_direct"
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_poll_returns_none_on_access_denied(self, mock_urlopen_fn, mock_time):
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
|
||||
mock_time.time.side_effect = [0, 1]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"error": "access_denied",
|
||||
})
|
||||
result = _poll_registration(
|
||||
device_code="dc_123", interval=1, expire_in=60, domain="feishu"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_poll_returns_none_on_timeout(self, mock_urlopen_fn, mock_time):
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
|
||||
mock_time.time.side_effect = [0, 999]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"error": "authorization_pending",
|
||||
})
|
||||
result = _poll_registration(
|
||||
device_code="dc_123", interval=1, expire_in=1, domain="feishu"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRenderQr:
|
||||
"""Tests for QR code terminal rendering."""
|
||||
|
||||
@patch("gateway.platforms.feishu._qrcode_mod", create=True)
|
||||
def test_render_qr_returns_true_on_success(self, mock_qrcode_mod):
|
||||
from gateway.platforms.feishu import _render_qr
|
||||
|
||||
mock_qr = MagicMock()
|
||||
mock_qrcode_mod.QRCode.return_value = mock_qr
|
||||
assert _render_qr("https://example.com/qr") is True
|
||||
mock_qr.add_data.assert_called_once_with("https://example.com/qr")
|
||||
mock_qr.make.assert_called_once_with(fit=True)
|
||||
mock_qr.print_ascii.assert_called_once()
|
||||
|
||||
def test_render_qr_returns_false_when_qrcode_missing(self):
|
||||
from gateway.platforms.feishu import _render_qr
|
||||
|
||||
with patch("gateway.platforms.feishu._qrcode_mod", None):
|
||||
assert _render_qr("https://example.com/qr") is False
|
||||
|
||||
|
||||
class TestProbeBot:
|
||||
"""Tests for bot connectivity verification."""
|
||||
|
||||
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True)
|
||||
def test_probe_returns_bot_info_on_success(self):
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
|
||||
with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk:
|
||||
mock_sdk.return_value = {"bot_name": "TestBot", "bot_open_id": "ou_bot123"}
|
||||
result = probe_bot("cli_app", "secret", "feishu")
|
||||
|
||||
assert result is not None
|
||||
assert result["bot_name"] == "TestBot"
|
||||
assert result["bot_open_id"] == "ou_bot123"
|
||||
|
||||
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True)
|
||||
def test_probe_returns_none_on_failure(self):
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
|
||||
with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk:
|
||||
mock_sdk.return_value = None
|
||||
result = probe_bot("bad_id", "bad_secret", "feishu")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False)
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_http_fallback_when_sdk_unavailable(self, mock_urlopen_fn):
|
||||
"""Without lark_oapi, probe falls back to raw HTTP."""
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
|
||||
token_resp = _mock_urlopen({"code": 0, "tenant_access_token": "t-123"})
|
||||
bot_resp = _mock_urlopen({"code": 0, "bot": {"bot_name": "HttpBot", "open_id": "ou_http"}})
|
||||
mock_urlopen_fn.side_effect = [token_resp, bot_resp]
|
||||
|
||||
result = probe_bot("cli_app", "secret", "feishu")
|
||||
assert result is not None
|
||||
assert result["bot_name"] == "HttpBot"
|
||||
|
||||
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False)
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
def test_http_fallback_returns_none_on_network_error(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
from urllib.error import URLError
|
||||
|
||||
mock_urlopen_fn.side_effect = URLError("connection refused")
|
||||
result = probe_bot("cli_app", "secret", "feishu")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestQrRegister:
|
||||
"""Tests for the public qr_register entry point."""
|
||||
|
||||
@patch("gateway.platforms.feishu.probe_bot")
|
||||
@patch("gateway.platforms.feishu._render_qr")
|
||||
@patch("gateway.platforms.feishu._poll_registration")
|
||||
@patch("gateway.platforms.feishu._begin_registration")
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
def test_qr_register_success_flow(
|
||||
self, mock_init, mock_begin, mock_poll, mock_render, mock_probe
|
||||
):
|
||||
from gateway.platforms.feishu import qr_register
|
||||
|
||||
mock_begin.return_value = {
|
||||
"device_code": "dc_123",
|
||||
"qr_url": "https://example.com/qr",
|
||||
"user_code": "ABCD",
|
||||
"interval": 1,
|
||||
"expire_in": 60,
|
||||
}
|
||||
mock_poll.return_value = {
|
||||
"app_id": "cli_app",
|
||||
"app_secret": "secret",
|
||||
"domain": "feishu",
|
||||
"open_id": "ou_owner",
|
||||
}
|
||||
mock_probe.return_value = {"bot_name": "MyBot", "bot_open_id": "ou_bot"}
|
||||
|
||||
result = qr_register()
|
||||
assert result is not None
|
||||
assert result["app_id"] == "cli_app"
|
||||
assert result["app_secret"] == "secret"
|
||||
assert result["bot_name"] == "MyBot"
|
||||
mock_init.assert_called_once()
|
||||
mock_render.assert_called_once()
|
||||
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
def test_qr_register_returns_none_on_init_failure(self, mock_init):
|
||||
from gateway.platforms.feishu import qr_register
|
||||
|
||||
mock_init.side_effect = RuntimeError("not supported")
|
||||
result = qr_register()
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu._render_qr")
|
||||
@patch("gateway.platforms.feishu._poll_registration")
|
||||
@patch("gateway.platforms.feishu._begin_registration")
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
def test_qr_register_returns_none_on_poll_failure(
|
||||
self, mock_init, mock_begin, mock_poll, mock_render
|
||||
):
|
||||
from gateway.platforms.feishu import qr_register
|
||||
|
||||
mock_begin.return_value = {
|
||||
"device_code": "dc_123",
|
||||
"qr_url": "https://example.com/qr",
|
||||
"user_code": "ABCD",
|
||||
"interval": 1,
|
||||
"expire_in": 60,
|
||||
}
|
||||
mock_poll.return_value = None
|
||||
|
||||
result = qr_register()
|
||||
assert result is None
|
||||
|
||||
# -- Contract: expected errors → None, unexpected errors → propagate --
|
||||
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
def test_qr_register_returns_none_on_network_error(self, mock_init):
|
||||
"""URLError (network down) is an expected failure → None."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from urllib.error import URLError
|
||||
|
||||
mock_init.side_effect = URLError("DNS resolution failed")
|
||||
result = qr_register()
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
def test_qr_register_returns_none_on_json_error(self, mock_init):
|
||||
"""Malformed server response is an expected failure → None."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
|
||||
mock_init.side_effect = json.JSONDecodeError("bad json", "", 0)
|
||||
result = qr_register()
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
def test_qr_register_propagates_unexpected_errors(self, mock_init):
|
||||
"""Bugs (e.g. AttributeError) must not be swallowed — they propagate."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
|
||||
mock_init.side_effect = AttributeError("some internal bug")
|
||||
with pytest.raises(AttributeError, match="some internal bug"):
|
||||
qr_register()
|
||||
|
||||
# -- Negative paths: partial/malformed server responses --
|
||||
|
||||
@patch("gateway.platforms.feishu._render_qr")
|
||||
@patch("gateway.platforms.feishu._begin_registration")
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
def test_qr_register_returns_none_when_begin_missing_device_code(
|
||||
self, mock_init, mock_begin, mock_render
|
||||
):
|
||||
"""Server returns begin response without device_code → RuntimeError → None."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
|
||||
mock_begin.side_effect = RuntimeError("Feishu registration did not return a device_code")
|
||||
result = qr_register()
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu.probe_bot")
|
||||
@patch("gateway.platforms.feishu._render_qr")
|
||||
@patch("gateway.platforms.feishu._poll_registration")
|
||||
@patch("gateway.platforms.feishu._begin_registration")
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
def test_qr_register_succeeds_even_when_probe_fails(
|
||||
self, mock_init, mock_begin, mock_poll, mock_render, mock_probe
|
||||
):
|
||||
"""Registration succeeds but probe fails → result with bot_name=None."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
|
||||
mock_begin.return_value = {
|
||||
"device_code": "dc_123",
|
||||
"qr_url": "https://example.com/qr",
|
||||
"user_code": "ABCD",
|
||||
"interval": 1,
|
||||
"expire_in": 60,
|
||||
}
|
||||
mock_poll.return_value = {
|
||||
"app_id": "cli_app",
|
||||
"app_secret": "secret",
|
||||
"domain": "feishu",
|
||||
"open_id": "ou_owner",
|
||||
}
|
||||
mock_probe.return_value = None # probe failed
|
||||
|
||||
result = qr_register()
|
||||
assert result is not None
|
||||
assert result["app_id"] == "cli_app"
|
||||
assert result["bot_name"] is None
|
||||
assert result["bot_open_id"] is None
|
||||
@@ -48,6 +48,7 @@ def _make_event(
|
||||
room_id="!room1:example.org",
|
||||
formatted_body=None,
|
||||
thread_id=None,
|
||||
mention_user_ids=None,
|
||||
):
|
||||
"""Create a fake room message event.
|
||||
|
||||
@@ -60,6 +61,9 @@ def _make_event(
|
||||
content["formatted_body"] = formatted_body
|
||||
content["format"] = "org.matrix.custom.html"
|
||||
|
||||
if mention_user_ids is not None:
|
||||
content["m.mentions"] = {"user_ids": mention_user_ids}
|
||||
|
||||
relates_to = {}
|
||||
if thread_id:
|
||||
relates_to["rel_type"] = "m.thread"
|
||||
@@ -108,6 +112,44 @@ class TestIsBotMentioned:
|
||||
# "hermesbot" should not match word-boundary check for "hermes"
|
||||
assert not self.adapter._is_bot_mentioned("hermesbot is here")
|
||||
|
||||
# m.mentions.user_ids — MSC3952 / Matrix v1.7 authoritative mentions
|
||||
# Ported from openclaw/openclaw#64796
|
||||
|
||||
def test_m_mentions_user_ids_authoritative(self):
|
||||
"""m.mentions.user_ids alone is sufficient — no body text needed."""
|
||||
assert self.adapter._is_bot_mentioned(
|
||||
"please reply", # no @hermes anywhere in body
|
||||
mention_user_ids=["@hermes:example.org"],
|
||||
)
|
||||
|
||||
def test_m_mentions_user_ids_with_body_mention(self):
|
||||
"""Both m.mentions and body mention — should still be True."""
|
||||
assert self.adapter._is_bot_mentioned(
|
||||
"hey @hermes:example.org help",
|
||||
mention_user_ids=["@hermes:example.org"],
|
||||
)
|
||||
|
||||
def test_m_mentions_user_ids_other_user_only(self):
|
||||
"""m.mentions with a different user — bot is NOT mentioned."""
|
||||
assert not self.adapter._is_bot_mentioned(
|
||||
"hello",
|
||||
mention_user_ids=["@alice:example.org"],
|
||||
)
|
||||
|
||||
def test_m_mentions_user_ids_empty_list(self):
|
||||
"""Empty user_ids list — falls through to text detection."""
|
||||
assert not self.adapter._is_bot_mentioned(
|
||||
"hello everyone",
|
||||
mention_user_ids=[],
|
||||
)
|
||||
|
||||
def test_m_mentions_user_ids_none(self):
|
||||
"""None mention_user_ids — falls through to text detection."""
|
||||
assert not self.adapter._is_bot_mentioned(
|
||||
"hello everyone",
|
||||
mention_user_ids=None,
|
||||
)
|
||||
|
||||
|
||||
class TestStripMention:
|
||||
def setup_method(self):
|
||||
@@ -176,6 +218,44 @@ async def test_require_mention_html_pill(monkeypatch):
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_m_mentions_user_ids(monkeypatch):
|
||||
"""m.mentions.user_ids is authoritative per MSC3952 — no body mention needed.
|
||||
|
||||
Ported from openclaw/openclaw#64796.
|
||||
"""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
# Body has NO mention, but m.mentions.user_ids includes the bot.
|
||||
event = _make_event(
|
||||
"please reply",
|
||||
mention_user_ids=["@hermes:example.org"],
|
||||
)
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_m_mentions_other_user_ignored(monkeypatch):
|
||||
"""m.mentions.user_ids mentioning another user should NOT activate the bot."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event(
|
||||
"hey alice check this",
|
||||
mention_user_ids=["@alice:example.org"],
|
||||
)
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_dm_always_responds(monkeypatch):
|
||||
"""DMs always respond regardless of mention setting."""
|
||||
|
||||
@@ -9,6 +9,8 @@ from gateway.platforms.base import (
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
safe_url_for_log,
|
||||
utf16_len,
|
||||
_prefix_within_utf16_limit,
|
||||
)
|
||||
|
||||
|
||||
@@ -448,3 +450,135 @@ class TestGetHumanDelay:
|
||||
with patch.dict(os.environ, env):
|
||||
delay = BasePlatformAdapter._get_human_delay()
|
||||
assert 0.1 <= delay <= 0.2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# utf16_len / _prefix_within_utf16_limit / truncate_message with len_fn
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ported from nearai/ironclaw#2304 — Telegram counts message length in UTF-16
|
||||
# code units, not Unicode code-points. Astral-plane characters (emoji, CJK
|
||||
# Extension B) are surrogate pairs: 1 Python char but 2 UTF-16 units.
|
||||
|
||||
|
||||
class TestUtf16Len:
|
||||
"""Verify the UTF-16 length helper."""
|
||||
|
||||
def test_ascii(self):
|
||||
assert utf16_len("hello") == 5
|
||||
|
||||
def test_bmp_cjk(self):
|
||||
# CJK ideographs in the BMP are 1 code unit each
|
||||
assert utf16_len("你好") == 2
|
||||
|
||||
def test_emoji_surrogate_pair(self):
|
||||
# 😀 (U+1F600) is outside BMP → 2 UTF-16 code units
|
||||
assert utf16_len("😀") == 2
|
||||
|
||||
def test_mixed(self):
|
||||
# "hi😀" = 2 + 2 = 4 UTF-16 units
|
||||
assert utf16_len("hi😀") == 4
|
||||
|
||||
def test_musical_symbol(self):
|
||||
# 𝄞 (U+1D11E) — Musical Symbol G Clef, surrogate pair
|
||||
assert utf16_len("𝄞") == 2
|
||||
|
||||
def test_empty(self):
|
||||
assert utf16_len("") == 0
|
||||
|
||||
|
||||
class TestPrefixWithinUtf16Limit:
|
||||
"""Verify UTF-16-aware prefix truncation."""
|
||||
|
||||
def test_fits_entirely(self):
|
||||
assert _prefix_within_utf16_limit("hello", 10) == "hello"
|
||||
|
||||
def test_ascii_truncation(self):
|
||||
result = _prefix_within_utf16_limit("hello world", 5)
|
||||
assert result == "hello"
|
||||
assert utf16_len(result) <= 5
|
||||
|
||||
def test_does_not_split_surrogate_pair(self):
|
||||
# "a😀b" = 1 + 2 + 1 = 4 UTF-16 units; limit 2 should give "a"
|
||||
result = _prefix_within_utf16_limit("a😀b", 2)
|
||||
assert result == "a"
|
||||
assert utf16_len(result) <= 2
|
||||
|
||||
def test_emoji_at_limit(self):
|
||||
# "😀" = 2 UTF-16 units; limit 2 should include it
|
||||
result = _prefix_within_utf16_limit("😀x", 2)
|
||||
assert result == "😀"
|
||||
|
||||
def test_all_emoji(self):
|
||||
msg = "😀" * 10 # 20 UTF-16 units
|
||||
result = _prefix_within_utf16_limit(msg, 6)
|
||||
assert result == "😀😀😀"
|
||||
assert utf16_len(result) == 6
|
||||
|
||||
def test_empty(self):
|
||||
assert _prefix_within_utf16_limit("", 5) == ""
|
||||
|
||||
|
||||
class TestTruncateMessageUtf16:
|
||||
"""Verify truncate_message respects UTF-16 lengths when len_fn=utf16_len."""
|
||||
|
||||
def test_short_emoji_message_no_split(self):
|
||||
"""A short message under the UTF-16 limit should not be split."""
|
||||
msg = "Hello 😀 world"
|
||||
chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len)
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0] == msg
|
||||
|
||||
def test_emoji_near_limit_triggers_split(self):
|
||||
"""A message at 4096 codepoints but >4096 UTF-16 units must split."""
|
||||
# 2049 emoji = 2049 codepoints but 4098 UTF-16 units → exceeds 4096
|
||||
msg = "😀" * 2049
|
||||
assert len(msg) == 2049 # Python len sees 2049 chars
|
||||
assert utf16_len(msg) == 4098 # but it's 4098 UTF-16 units
|
||||
|
||||
# Without UTF-16 awareness, this would NOT split (2049 < 4096)
|
||||
chunks_naive = BasePlatformAdapter.truncate_message(msg, 4096)
|
||||
assert len(chunks_naive) == 1, "Without len_fn, no split expected"
|
||||
|
||||
# With UTF-16 awareness, it MUST split
|
||||
chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len)
|
||||
assert len(chunks) > 1, "With utf16_len, message should be split"
|
||||
|
||||
# Each chunk must fit within the UTF-16 limit
|
||||
for i, chunk in enumerate(chunks):
|
||||
assert utf16_len(chunk) <= 4096, (
|
||||
f"Chunk {i} exceeds 4096 UTF-16 units: {utf16_len(chunk)}"
|
||||
)
|
||||
|
||||
def test_each_utf16_chunk_within_limit(self):
|
||||
"""All chunks produced with utf16_len must fit the limit."""
|
||||
# Mix of BMP and astral-plane characters
|
||||
msg = ("Hello 😀 world 🎵 test 𝄞 " * 200).strip()
|
||||
max_len = 200
|
||||
chunks = BasePlatformAdapter.truncate_message(msg, max_len, len_fn=utf16_len)
|
||||
for i, chunk in enumerate(chunks):
|
||||
u16_len = utf16_len(chunk)
|
||||
assert u16_len <= max_len + 20, (
|
||||
f"Chunk {i} UTF-16 length {u16_len} exceeds {max_len}"
|
||||
)
|
||||
|
||||
def test_all_content_preserved(self):
|
||||
"""Splitting with utf16_len must not lose content."""
|
||||
words = ["emoji😀", "music🎵", "cjk你好", "plain"] * 100
|
||||
msg = " ".join(words)
|
||||
chunks = BasePlatformAdapter.truncate_message(msg, 200, len_fn=utf16_len)
|
||||
reassembled = " ".join(chunks)
|
||||
for word in words:
|
||||
assert word in reassembled, f"Word '{word}' lost during UTF-16 split"
|
||||
|
||||
def test_code_blocks_preserved_with_utf16(self):
|
||||
"""Code block fence handling should work with utf16_len too."""
|
||||
msg = "Before\n```python\n" + "x = '😀'\n" * 200 + "```\nAfter"
|
||||
chunks = BasePlatformAdapter.truncate_message(msg, 300, len_fn=utf16_len)
|
||||
assert len(chunks) > 1
|
||||
# Each chunk should have balanced fences
|
||||
for i, chunk in enumerate(chunks):
|
||||
fence_count = chunk.count("```")
|
||||
assert fence_count % 2 == 0, (
|
||||
f"Chunk {i} has unbalanced fences ({fence_count})"
|
||||
)
|
||||
|
||||
|
||||
215
tests/gateway/test_restart_notification.py
Normal file
215
tests/gateway/test_restart_notification.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Tests for /restart notification — the gateway notifies the requester on comeback."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import build_session_key
|
||||
from tests.gateway.restart_test_helpers import (
|
||||
make_restart_runner,
|
||||
make_restart_source,
|
||||
)
|
||||
|
||||
|
||||
# ── _handle_restart_command writes .restart_notify.json ──────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_command_writes_notify_file(tmp_path, monkeypatch):
|
||||
"""When /restart fires, the requester's routing info is persisted to disk."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
|
||||
source = make_restart_source(chat_id="42")
|
||||
event = MessageEvent(
|
||||
text="/restart",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="m1",
|
||||
)
|
||||
|
||||
result = await runner._handle_restart_command(event)
|
||||
assert "Restarting" in result
|
||||
|
||||
notify_path = tmp_path / ".restart_notify.json"
|
||||
assert notify_path.exists()
|
||||
data = json.loads(notify_path.read_text())
|
||||
assert data["platform"] == "telegram"
|
||||
assert data["chat_id"] == "42"
|
||||
assert "thread_id" not in data # no thread → omitted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_command_uses_service_restart_under_systemd(tmp_path, monkeypatch):
|
||||
"""Under systemd (INVOCATION_ID set), /restart uses via_service=True."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setenv("INVOCATION_ID", "abc123")
|
||||
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
|
||||
source = make_restart_source(chat_id="42")
|
||||
event = MessageEvent(
|
||||
text="/restart",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="m1",
|
||||
)
|
||||
|
||||
await runner._handle_restart_command(event)
|
||||
runner.request_restart.assert_called_once_with(detached=False, via_service=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_command_uses_detached_without_systemd(tmp_path, monkeypatch):
|
||||
"""Without systemd, /restart uses the detached subprocess approach."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.delenv("INVOCATION_ID", raising=False)
|
||||
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
|
||||
source = make_restart_source(chat_id="42")
|
||||
event = MessageEvent(
|
||||
text="/restart",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="m1",
|
||||
)
|
||||
|
||||
await runner._handle_restart_command(event)
|
||||
runner.request_restart.assert_called_once_with(detached=True, via_service=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_command_preserves_thread_id(tmp_path, monkeypatch):
|
||||
"""Thread ID is saved when the requester is in a threaded chat."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
|
||||
source = make_restart_source(chat_id="99")
|
||||
source.thread_id = "topic_7"
|
||||
|
||||
event = MessageEvent(
|
||||
text="/restart",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="m2",
|
||||
)
|
||||
|
||||
await runner._handle_restart_command(event)
|
||||
|
||||
data = json.loads((tmp_path / ".restart_notify.json").read_text())
|
||||
assert data["thread_id"] == "topic_7"
|
||||
|
||||
|
||||
# ── _send_restart_notification ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_restart_notification_delivers_and_cleans_up(tmp_path, monkeypatch):
|
||||
"""On startup, the notification is sent and the file is removed."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
notify_path = tmp_path / ".restart_notify.json"
|
||||
notify_path.write_text(json.dumps({
|
||||
"platform": "telegram",
|
||||
"chat_id": "42",
|
||||
}))
|
||||
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.send = AsyncMock()
|
||||
|
||||
await runner._send_restart_notification()
|
||||
|
||||
adapter.send.assert_called_once()
|
||||
call_args = adapter.send.call_args
|
||||
assert call_args[0][0] == "42" # chat_id
|
||||
assert "restarted" in call_args[0][1].lower()
|
||||
assert call_args[1].get("metadata") is None # no thread
|
||||
assert not notify_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_restart_notification_with_thread(tmp_path, monkeypatch):
|
||||
"""Thread ID is passed as metadata so the message lands in the right topic."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
notify_path = tmp_path / ".restart_notify.json"
|
||||
notify_path.write_text(json.dumps({
|
||||
"platform": "telegram",
|
||||
"chat_id": "99",
|
||||
"thread_id": "topic_7",
|
||||
}))
|
||||
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.send = AsyncMock()
|
||||
|
||||
await runner._send_restart_notification()
|
||||
|
||||
call_args = adapter.send.call_args
|
||||
assert call_args[1]["metadata"] == {"thread_id": "topic_7"}
|
||||
assert not notify_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_restart_notification_noop_when_no_file(tmp_path, monkeypatch):
|
||||
"""Nothing happens if there's no pending restart notification."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.send = AsyncMock()
|
||||
|
||||
await runner._send_restart_notification()
|
||||
|
||||
adapter.send.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_restart_notification_skips_when_adapter_missing(tmp_path, monkeypatch):
|
||||
"""If the requester's platform isn't connected, clean up without crashing."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
notify_path = tmp_path / ".restart_notify.json"
|
||||
notify_path.write_text(json.dumps({
|
||||
"platform": "discord", # runner only has telegram adapter
|
||||
"chat_id": "42",
|
||||
}))
|
||||
|
||||
runner, _adapter = make_restart_runner()
|
||||
|
||||
await runner._send_restart_notification()
|
||||
|
||||
# File cleaned up even though we couldn't send
|
||||
assert not notify_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_restart_notification_cleans_up_on_send_failure(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""If the adapter.send() raises, the file is still cleaned up."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
notify_path = tmp_path / ".restart_notify.json"
|
||||
notify_path.write_text(json.dumps({
|
||||
"platform": "telegram",
|
||||
"chat_id": "42",
|
||||
}))
|
||||
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.send = AsyncMock(side_effect=RuntimeError("network down"))
|
||||
|
||||
await runner._send_restart_notification()
|
||||
|
||||
assert not notify_path.exists() # cleaned up despite error
|
||||
@@ -396,6 +396,27 @@ class QueuedCommentaryAgent:
|
||||
}
|
||||
|
||||
|
||||
class VerboseAgent:
|
||||
"""Agent that emits a tool call with args whose JSON exceeds 200 chars."""
|
||||
LONG_CODE = "x" * 300
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, message, conversation_history=None, task_id=None):
|
||||
self.tool_progress_callback(
|
||||
"tool.started", "execute_code", None,
|
||||
{"code": self.LONG_CODE},
|
||||
)
|
||||
time.sleep(0.35)
|
||||
return {
|
||||
"final_response": "done",
|
||||
"messages": [],
|
||||
"api_calls": 1,
|
||||
}
|
||||
|
||||
|
||||
async def _run_with_agent(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
@@ -575,3 +596,45 @@ async def test_run_agent_queued_message_does_not_treat_commentary_as_final(monke
|
||||
assert result["final_response"] == "final response 2"
|
||||
assert "I'll inspect the repo first." in sent_texts
|
||||
assert "final response 1" in sent_texts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verbose_mode_does_not_truncate_args_by_default(monkeypatch, tmp_path):
|
||||
"""Verbose mode with default tool_preview_length (0) should NOT truncate args.
|
||||
|
||||
Previously, verbose mode capped args at 200 chars when tool_preview_length
|
||||
was 0 (default). The user explicitly opted into verbose — show full detail.
|
||||
"""
|
||||
adapter, result = await _run_with_agent(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
VerboseAgent,
|
||||
session_id="sess-verbose-no-truncate",
|
||||
config_data={"display": {"tool_progress": "verbose", "tool_preview_length": 0}},
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
# The full 300-char 'x' string should be present, not truncated to 200
|
||||
all_content = " ".join(call["content"] for call in adapter.sent)
|
||||
all_content += " ".join(call["content"] for call in adapter.edits)
|
||||
assert VerboseAgent.LONG_CODE in all_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verbose_mode_respects_explicit_tool_preview_length(monkeypatch, tmp_path):
|
||||
"""When tool_preview_length is set to a positive value, verbose truncates to that."""
|
||||
adapter, result = await _run_with_agent(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
VerboseAgent,
|
||||
session_id="sess-verbose-explicit-cap",
|
||||
config_data={"display": {"tool_progress": "verbose", "tool_preview_length": 50}},
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
all_content = " ".join(call["content"] for call in adapter.sent)
|
||||
all_content += " ".join(call["content"] for call in adapter.edits)
|
||||
# Should be truncated — full 300-char string NOT present
|
||||
assert VerboseAgent.LONG_CODE not in all_content
|
||||
# But should still contain the truncated portion with "..."
|
||||
assert "..." in all_content
|
||||
|
||||
@@ -552,6 +552,45 @@ class TestLoadTranscriptPreferLongerSource:
|
||||
assert result[0]["content"] == "db-q"
|
||||
|
||||
|
||||
class TestSessionStoreSwitchSession:
|
||||
"""Regression coverage for gateway /resume session switching semantics."""
|
||||
|
||||
def test_switch_session_reopens_target_session_in_db(self, tmp_path):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
config = GatewayConfig()
|
||||
with patch("gateway.session.SessionStore._ensure_loaded"):
|
||||
store = SessionStore(sessions_dir=tmp_path / "sessions", config=config)
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
store._db = db
|
||||
store._loaded = True
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.FEISHU,
|
||||
chat_id="chat-1",
|
||||
chat_type="dm",
|
||||
user_id="user-1",
|
||||
user_name="tester",
|
||||
)
|
||||
current_entry = store.get_or_create_session(source)
|
||||
current_session_id = current_entry.session_id
|
||||
|
||||
target_session_id = "old_session_abc"
|
||||
db.create_session(target_session_id, source="feishu", user_id="user-1")
|
||||
db.end_session(target_session_id, end_reason="user_exit")
|
||||
assert db.get_session(target_session_id)["ended_at"] is not None
|
||||
|
||||
switched = store.switch_session(current_entry.session_key, target_session_id)
|
||||
|
||||
assert switched is not None
|
||||
assert switched.session_id == target_session_id
|
||||
assert db.get_session(current_session_id)["end_reason"] == "session_switch"
|
||||
resumed = db.get_session(target_session_id)
|
||||
assert resumed["ended_at"] is None
|
||||
assert resumed["end_reason"] is None
|
||||
db.close()
|
||||
|
||||
|
||||
class TestWhatsAppDMSessionKeyConsistency:
|
||||
"""Regression: all session-key construction must go through build_session_key
|
||||
so DMs are isolated by chat_id across platforms."""
|
||||
|
||||
@@ -60,7 +60,8 @@ def _make_runner():
|
||||
|
||||
def _make_event(text="hello", chat_id="12345"):
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm"
|
||||
platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm",
|
||||
user_id="u1",
|
||||
)
|
||||
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
|
||||
|
||||
@@ -192,7 +193,8 @@ async def test_command_messages_do_not_leave_sentinel():
|
||||
_handle_message. They must NOT leave a sentinel behind."""
|
||||
runner = _make_runner()
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"
|
||||
platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm",
|
||||
user_id="u1",
|
||||
)
|
||||
event = MessageEvent(
|
||||
text="/help", message_type=MessageType.TEXT, source=source
|
||||
@@ -240,9 +242,7 @@ async def test_stop_during_sentinel_force_cleans_session():
|
||||
stop_event = _make_event(text="/stop")
|
||||
result = await runner._handle_message(stop_event)
|
||||
assert result is not None, "/stop during sentinel should return a message"
|
||||
assert "force-stopped" in result.lower() or "unlocked" in result.lower()
|
||||
|
||||
# Sentinel must be cleaned up
|
||||
assert "stopped" in result.lower()
|
||||
assert session_key not in runner._running_agents, (
|
||||
"/stop must remove sentinel so the session is unlocked"
|
||||
)
|
||||
@@ -268,7 +268,7 @@ async def test_stop_hard_kills_running_agent():
|
||||
forever — showing 'writing...' but never producing output."""
|
||||
runner = _make_runner()
|
||||
session_key = build_session_key(
|
||||
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
|
||||
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="u1")
|
||||
)
|
||||
|
||||
# Simulate a running (possibly hung) agent
|
||||
@@ -289,7 +289,7 @@ async def test_stop_hard_kills_running_agent():
|
||||
|
||||
# Must return a confirmation
|
||||
assert result is not None
|
||||
assert "force-stopped" in result.lower() or "unlocked" in result.lower()
|
||||
assert "stopped" in result.lower()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -301,7 +301,7 @@ async def test_stop_clears_pending_messages():
|
||||
queued during the run must be discarded."""
|
||||
runner = _make_runner()
|
||||
session_key = build_session_key(
|
||||
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
|
||||
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="u1")
|
||||
)
|
||||
|
||||
fake_agent = MagicMock()
|
||||
|
||||
279
tests/gateway/test_setup_feishu.py
Normal file
279
tests/gateway/test_setup_feishu.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Tests for _setup_feishu() in hermes_cli/gateway.py.
|
||||
|
||||
Verifies that the interactive setup writes env vars that correctly drive the
|
||||
Feishu adapter: credentials, connection mode, DM policy, and group policy.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_setup_feishu(
|
||||
*,
|
||||
qr_result=None,
|
||||
prompt_yes_no_responses=None,
|
||||
prompt_choice_responses=None,
|
||||
prompt_responses=None,
|
||||
existing_env=None,
|
||||
):
|
||||
"""Run _setup_feishu() with mocked I/O and return the env vars that were saved.
|
||||
|
||||
Returns a dict of {env_var_name: value} for all save_env_value calls.
|
||||
"""
|
||||
existing_env = existing_env or {}
|
||||
prompt_yes_no_responses = list(prompt_yes_no_responses or [True])
|
||||
# QR path: method(0), dm(0), group(0) — 3 choices (no connection mode)
|
||||
# Manual path: method(1), domain(0), connection(0), dm(0), group(0) — 5 choices
|
||||
prompt_choice_responses = list(prompt_choice_responses or [0, 0, 0])
|
||||
prompt_responses = list(prompt_responses or [""])
|
||||
|
||||
saved_env = {}
|
||||
|
||||
def mock_save(name, value):
|
||||
saved_env[name] = value
|
||||
|
||||
def mock_get(name):
|
||||
return existing_env.get(name, "")
|
||||
|
||||
with patch("hermes_cli.gateway.save_env_value", side_effect=mock_save), \
|
||||
patch("hermes_cli.gateway.get_env_value", side_effect=mock_get), \
|
||||
patch("hermes_cli.gateway.prompt_yes_no", side_effect=prompt_yes_no_responses), \
|
||||
patch("hermes_cli.gateway.prompt_choice", side_effect=prompt_choice_responses), \
|
||||
patch("hermes_cli.gateway.prompt", side_effect=prompt_responses), \
|
||||
patch("hermes_cli.gateway.print_info"), \
|
||||
patch("hermes_cli.gateway.print_success"), \
|
||||
patch("hermes_cli.gateway.print_warning"), \
|
||||
patch("hermes_cli.gateway.print_error"), \
|
||||
patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \
|
||||
patch("gateway.platforms.feishu.qr_register", return_value=qr_result):
|
||||
|
||||
from hermes_cli.gateway import _setup_feishu
|
||||
_setup_feishu()
|
||||
|
||||
return saved_env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR scan-to-create path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSetupFeishuQrPath:
|
||||
"""Tests for the QR scan-to-create happy path."""
|
||||
|
||||
def test_qr_success_saves_core_credentials(self):
|
||||
env = _run_setup_feishu(
|
||||
qr_result={
|
||||
"app_id": "cli_test",
|
||||
"app_secret": "secret_test",
|
||||
"domain": "feishu",
|
||||
"open_id": "ou_owner",
|
||||
"bot_name": "TestBot",
|
||||
"bot_open_id": "ou_bot",
|
||||
},
|
||||
prompt_yes_no_responses=[True], # Start QR
|
||||
prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open
|
||||
prompt_responses=[""], # home channel: skip
|
||||
)
|
||||
assert env["FEISHU_APP_ID"] == "cli_test"
|
||||
assert env["FEISHU_APP_SECRET"] == "secret_test"
|
||||
assert env["FEISHU_DOMAIN"] == "feishu"
|
||||
|
||||
def test_qr_success_does_not_persist_bot_identity(self):
|
||||
"""Bot identity is discovered at runtime by _hydrate_bot_identity — not persisted
|
||||
in env, so it stays fresh if the user renames the bot later."""
|
||||
env = _run_setup_feishu(
|
||||
qr_result={
|
||||
"app_id": "cli_test",
|
||||
"app_secret": "secret_test",
|
||||
"domain": "feishu",
|
||||
"open_id": "ou_owner",
|
||||
"bot_name": "TestBot",
|
||||
"bot_open_id": "ou_bot",
|
||||
},
|
||||
prompt_yes_no_responses=[True],
|
||||
prompt_choice_responses=[0, 0, 0],
|
||||
prompt_responses=[""],
|
||||
)
|
||||
assert "FEISHU_BOT_OPEN_ID" not in env
|
||||
assert "FEISHU_BOT_NAME" not in env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSetupFeishuConnectionMode:
|
||||
"""Connection mode: QR always websocket, manual path lets user choose."""
|
||||
|
||||
def test_qr_path_defaults_to_websocket(self):
|
||||
env = _run_setup_feishu(
|
||||
qr_result={
|
||||
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
|
||||
"open_id": None, "bot_name": None, "bot_open_id": None,
|
||||
},
|
||||
prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open
|
||||
prompt_responses=[""],
|
||||
)
|
||||
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
|
||||
|
||||
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
|
||||
def test_manual_path_websocket(self, _mock_probe):
|
||||
env = _run_setup_feishu(
|
||||
qr_result=None,
|
||||
prompt_choice_responses=[1, 0, 0, 0, 0], # method=manual, domain=feishu, connection=ws, dm=pairing, group=open
|
||||
prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel
|
||||
)
|
||||
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
|
||||
|
||||
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
|
||||
def test_manual_path_webhook(self, _mock_probe):
|
||||
env = _run_setup_feishu(
|
||||
qr_result=None,
|
||||
prompt_choice_responses=[1, 0, 1, 0, 0], # method=manual, domain=feishu, connection=webhook, dm=pairing, group=open
|
||||
prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel
|
||||
)
|
||||
assert env["FEISHU_CONNECTION_MODE"] == "webhook"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DM security policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSetupFeishuDmPolicy:
|
||||
"""DM policy must use platform-scoped FEISHU_ALLOW_ALL_USERS, not the global flag."""
|
||||
|
||||
def _run_with_dm_choice(self, dm_choice_idx, prompt_responses=None):
|
||||
return _run_setup_feishu(
|
||||
qr_result={
|
||||
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
|
||||
"open_id": "ou_owner", "bot_name": None, "bot_open_id": None,
|
||||
},
|
||||
prompt_yes_no_responses=[True],
|
||||
prompt_choice_responses=[0, dm_choice_idx, 0], # method=QR, dm=<choice>, group=open
|
||||
prompt_responses=prompt_responses or [""],
|
||||
)
|
||||
|
||||
def test_pairing_sets_feishu_allow_all_false(self):
|
||||
env = self._run_with_dm_choice(0)
|
||||
assert env["FEISHU_ALLOW_ALL_USERS"] == "false"
|
||||
assert env["FEISHU_ALLOWED_USERS"] == ""
|
||||
assert "GATEWAY_ALLOW_ALL_USERS" not in env
|
||||
|
||||
def test_allow_all_sets_feishu_allow_all_true(self):
|
||||
env = self._run_with_dm_choice(1)
|
||||
assert env["FEISHU_ALLOW_ALL_USERS"] == "true"
|
||||
assert env["FEISHU_ALLOWED_USERS"] == ""
|
||||
assert "GATEWAY_ALLOW_ALL_USERS" not in env
|
||||
|
||||
def test_allowlist_sets_feishu_allow_all_false_with_list(self):
|
||||
env = self._run_with_dm_choice(2, prompt_responses=["ou_user1,ou_user2", ""])
|
||||
assert env["FEISHU_ALLOW_ALL_USERS"] == "false"
|
||||
assert env["FEISHU_ALLOWED_USERS"] == "ou_user1,ou_user2"
|
||||
assert "GATEWAY_ALLOW_ALL_USERS" not in env
|
||||
|
||||
def test_allowlist_prepopulates_with_scan_owner_open_id(self):
|
||||
"""When open_id is available from QR scan, it should be the default allowlist value."""
|
||||
# We return the owner's open_id from prompt (+ empty home channel).
|
||||
env = self._run_with_dm_choice(2, prompt_responses=["ou_owner", ""])
|
||||
assert env["FEISHU_ALLOWED_USERS"] == "ou_owner"
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSetupFeishuGroupPolicy:
|
||||
|
||||
def test_open_with_mention(self):
|
||||
env = _run_setup_feishu(
|
||||
qr_result={
|
||||
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
|
||||
"open_id": None, "bot_name": None, "bot_open_id": None,
|
||||
},
|
||||
prompt_yes_no_responses=[True],
|
||||
prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open
|
||||
prompt_responses=[""],
|
||||
)
|
||||
assert env["FEISHU_GROUP_POLICY"] == "open"
|
||||
|
||||
def test_disabled(self):
|
||||
env = _run_setup_feishu(
|
||||
qr_result={
|
||||
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
|
||||
"open_id": None, "bot_name": None, "bot_open_id": None,
|
||||
},
|
||||
prompt_yes_no_responses=[True],
|
||||
prompt_choice_responses=[0, 0, 1], # method=QR, dm=pairing, group=disabled
|
||||
prompt_responses=[""],
|
||||
)
|
||||
assert env["FEISHU_GROUP_POLICY"] == "disabled"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter integration: env vars → FeishuAdapterSettings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSetupFeishuAdapterIntegration:
|
||||
"""Verify that env vars written by _setup_feishu() produce a valid adapter config.
|
||||
|
||||
This bridges the gap between 'setup wrote the right env vars' and
|
||||
'the adapter will actually initialize correctly from those vars'.
|
||||
"""
|
||||
|
||||
def _make_env_from_setup(self, dm_idx=0, group_idx=0):
|
||||
"""Run _setup_feishu via QR path and return the env vars it would write."""
|
||||
return _run_setup_feishu(
|
||||
qr_result={
|
||||
"app_id": "cli_test_app",
|
||||
"app_secret": "test_secret_value",
|
||||
"domain": "feishu",
|
||||
"open_id": "ou_owner",
|
||||
"bot_name": "IntegrationBot",
|
||||
"bot_open_id": "ou_bot_integration",
|
||||
},
|
||||
prompt_yes_no_responses=[True],
|
||||
prompt_choice_responses=[0, dm_idx, group_idx], # method=QR, dm, group
|
||||
prompt_responses=[""],
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_qr_env_produces_valid_adapter_settings(self):
|
||||
"""QR setup → adapter initializes with websocket mode."""
|
||||
env = self._make_env_from_setup()
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
assert adapter._app_id == "cli_test_app"
|
||||
assert adapter._app_secret == "test_secret_value"
|
||||
assert adapter._domain_name == "feishu"
|
||||
assert adapter._connection_mode == "websocket"
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_open_dm_env_sets_correct_adapter_state(self):
|
||||
"""Setup with 'allow all DMs' → adapter sees allow-all flag."""
|
||||
env = self._make_env_from_setup(dm_idx=1)
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from gateway.config import PlatformConfig
|
||||
# Verify adapter initializes without error and env var is correct.
|
||||
FeishuAdapter(PlatformConfig())
|
||||
assert os.getenv("FEISHU_ALLOW_ALL_USERS") == "true"
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_group_open_env_sets_adapter_group_policy(self):
|
||||
"""Setup with 'open groups' → adapter group_policy is 'open'."""
|
||||
env = self._make_env_from_setup(group_idx=0)
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
assert adapter._group_policy == "open"
|
||||
@@ -209,6 +209,33 @@ class TestScopedLocks:
|
||||
assert payload["pid"] == os.getpid()
|
||||
assert payload["metadata"]["platform"] == "telegram"
|
||||
|
||||
def test_acquire_scoped_lock_recovers_empty_lock_file(self, tmp_path, monkeypatch):
|
||||
"""Empty lock file (0 bytes) left by a crashed process should be treated as stale."""
|
||||
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
|
||||
lock_path = tmp_path / "locks" / "slack-app-token-2bb80d537b1da3e3.lock"
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path.write_text("") # simulate crash between O_CREAT and json.dump
|
||||
|
||||
acquired, existing = status.acquire_scoped_lock("slack-app-token", "secret", metadata={"platform": "slack"})
|
||||
|
||||
assert acquired is True
|
||||
payload = json.loads(lock_path.read_text())
|
||||
assert payload["pid"] == os.getpid()
|
||||
assert payload["metadata"]["platform"] == "slack"
|
||||
|
||||
def test_acquire_scoped_lock_recovers_corrupt_lock_file(self, tmp_path, monkeypatch):
|
||||
"""Lock file with invalid JSON should be treated as stale."""
|
||||
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
|
||||
lock_path = tmp_path / "locks" / "slack-app-token-2bb80d537b1da3e3.lock"
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path.write_text("{truncated") # simulate partial write
|
||||
|
||||
acquired, existing = status.acquire_scoped_lock("slack-app-token", "secret", metadata={"platform": "slack"})
|
||||
|
||||
assert acquired is True
|
||||
payload = json.loads(lock_path.read_text())
|
||||
assert payload["pid"] == os.getpid()
|
||||
|
||||
def test_release_scoped_lock_only_removes_current_owner(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def _make_runner():
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_message_does_not_priority_interrupt_photo_followup():
|
||||
runner = _make_runner()
|
||||
source = SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
|
||||
source = SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="u1")
|
||||
session_key = build_session_key(source)
|
||||
running_agent = MagicMock()
|
||||
runner._running_agents[session_key] = running_agent
|
||||
|
||||
@@ -121,7 +121,7 @@ class TestSendWithReplyToMode:
|
||||
adapter = adapter_factory(reply_to_mode="off")
|
||||
adapter._bot = MagicMock()
|
||||
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
|
||||
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
|
||||
|
||||
await adapter.send("12345", "test content", reply_to="999")
|
||||
|
||||
@@ -133,7 +133,7 @@ class TestSendWithReplyToMode:
|
||||
adapter = adapter_factory(reply_to_mode="first")
|
||||
adapter._bot = MagicMock()
|
||||
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
|
||||
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
|
||||
|
||||
await adapter.send("12345", "test content", reply_to="999")
|
||||
|
||||
@@ -148,7 +148,7 @@ class TestSendWithReplyToMode:
|
||||
adapter = adapter_factory(reply_to_mode="all")
|
||||
adapter._bot = MagicMock()
|
||||
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
|
||||
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
|
||||
|
||||
await adapter.send("12345", "test content", reply_to="999")
|
||||
|
||||
@@ -162,7 +162,7 @@ class TestSendWithReplyToMode:
|
||||
adapter = adapter_factory(reply_to_mode="all")
|
||||
adapter._bot = MagicMock()
|
||||
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
|
||||
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"]
|
||||
|
||||
await adapter.send("12345", "test content", reply_to=None)
|
||||
|
||||
@@ -175,7 +175,7 @@ class TestSendWithReplyToMode:
|
||||
adapter = adapter_factory(reply_to_mode="first")
|
||||
adapter._bot = MagicMock()
|
||||
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
|
||||
adapter.truncate_message = lambda content, max_len: ["single chunk"]
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"]
|
||||
|
||||
await adapter.send("12345", "test", reply_to="999")
|
||||
|
||||
|
||||
@@ -417,6 +417,7 @@ class TestDiscordPlayTtsSkip:
|
||||
adapter.config = config
|
||||
adapter._voice_clients = {}
|
||||
adapter._voice_text_channels = {}
|
||||
adapter._voice_sources = {}
|
||||
adapter._voice_timeout_tasks = {}
|
||||
adapter._voice_receivers = {}
|
||||
adapter._voice_listen_tasks = {}
|
||||
@@ -702,13 +703,18 @@ class TestVoiceChannelCommands:
|
||||
mock_adapter.join_voice_channel = AsyncMock(return_value=True)
|
||||
mock_adapter.get_user_voice_channel = AsyncMock(return_value=mock_channel)
|
||||
mock_adapter._voice_text_channels = {}
|
||||
mock_adapter._voice_sources = {}
|
||||
mock_adapter._voice_input_callback = None
|
||||
event = self._make_discord_event()
|
||||
event.source.chat_type = "group"
|
||||
event.source.chat_name = "Hermes Server / #general"
|
||||
runner.adapters[event.source.platform] = mock_adapter
|
||||
result = await runner._handle_voice_channel_join(event)
|
||||
assert "joined" in result.lower()
|
||||
assert "General" in result
|
||||
assert runner._voice_mode["123"] == "all"
|
||||
assert mock_adapter._voice_sources[111]["chat_id"] == "123"
|
||||
assert mock_adapter._voice_sources[111]["chat_type"] == "group"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_failure(self, runner):
|
||||
@@ -815,6 +821,7 @@ class TestVoiceChannelCommands:
|
||||
from gateway.config import Platform
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter._voice_text_channels = {111: 123}
|
||||
mock_adapter._voice_sources = {}
|
||||
mock_channel = AsyncMock()
|
||||
mock_adapter._client = MagicMock()
|
||||
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
|
||||
@@ -828,12 +835,45 @@ class TestVoiceChannelCommands:
|
||||
assert event.source.chat_id == "123"
|
||||
assert event.source.chat_type == "channel"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_reuses_bound_source_metadata(self, runner):
|
||||
"""Voice input should share the linked text channel session metadata."""
|
||||
from gateway.config import Platform
|
||||
|
||||
bound_source = SessionSource(
|
||||
chat_id="123",
|
||||
chat_name="Hermes Server / #general",
|
||||
chat_type="group",
|
||||
user_id="user1",
|
||||
user_name="user1",
|
||||
platform=Platform.DISCORD,
|
||||
)
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter._voice_text_channels = {111: 123}
|
||||
mock_adapter._voice_sources = {111: bound_source.to_dict()}
|
||||
mock_channel = AsyncMock()
|
||||
mock_adapter._client = MagicMock()
|
||||
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
|
||||
mock_adapter.handle_message = AsyncMock()
|
||||
runner.adapters[Platform.DISCORD] = mock_adapter
|
||||
|
||||
await runner._handle_voice_channel_input(111, 42, "Hello from VC")
|
||||
|
||||
mock_adapter.handle_message.assert_called_once()
|
||||
event = mock_adapter.handle_message.call_args[0][0]
|
||||
assert event.source.chat_id == "123"
|
||||
assert event.source.chat_type == "group"
|
||||
assert event.source.chat_name == "Hermes Server / #general"
|
||||
assert event.source.user_id == "42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_posts_transcript_in_text_channel(self, runner):
|
||||
"""Voice input sends transcript message to text channel."""
|
||||
from gateway.config import Platform
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter._voice_text_channels = {111: 123}
|
||||
mock_adapter._voice_sources = {}
|
||||
mock_channel = AsyncMock()
|
||||
mock_adapter._client = MagicMock()
|
||||
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
|
||||
@@ -892,6 +932,7 @@ class TestDiscordVoiceChannelMethods:
|
||||
adapter._client = MagicMock()
|
||||
adapter._voice_clients = {}
|
||||
adapter._voice_text_channels = {}
|
||||
adapter._voice_sources = {}
|
||||
adapter._voice_timeout_tasks = {}
|
||||
adapter._voice_receivers = {}
|
||||
adapter._voice_listen_tasks = {}
|
||||
@@ -926,6 +967,7 @@ class TestDiscordVoiceChannelMethods:
|
||||
mock_vc.disconnect = AsyncMock()
|
||||
adapter._voice_clients[111] = mock_vc
|
||||
adapter._voice_text_channels[111] = 123
|
||||
adapter._voice_sources[111] = {"chat_id": "123", "chat_type": "group"}
|
||||
|
||||
mock_receiver = MagicMock()
|
||||
adapter._voice_receivers[111] = mock_receiver
|
||||
@@ -944,6 +986,7 @@ class TestDiscordVoiceChannelMethods:
|
||||
mock_timeout.cancel.assert_called_once()
|
||||
assert 111 not in adapter._voice_clients
|
||||
assert 111 not in adapter._voice_text_channels
|
||||
assert 111 not in adapter._voice_sources
|
||||
assert 111 not in adapter._voice_receivers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1670,6 +1713,7 @@ class TestVoiceTimeoutCleansRunnerState:
|
||||
adapter.config = config
|
||||
adapter._voice_clients = {}
|
||||
adapter._voice_text_channels = {}
|
||||
adapter._voice_sources = {}
|
||||
adapter._voice_timeout_tasks = {}
|
||||
adapter._voice_receivers = {}
|
||||
adapter._voice_listen_tasks = {}
|
||||
@@ -1759,6 +1803,7 @@ class TestPlaybackTimeout:
|
||||
adapter.config = config
|
||||
adapter._voice_clients = {}
|
||||
adapter._voice_text_channels = {}
|
||||
adapter._voice_sources = {}
|
||||
adapter._voice_timeout_tasks = {}
|
||||
adapter._voice_receivers = {}
|
||||
adapter._voice_listen_tasks = {}
|
||||
@@ -1939,6 +1984,7 @@ class TestVoiceChannelAwareness:
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter._voice_clients = {}
|
||||
adapter._voice_text_channels = {}
|
||||
adapter._voice_sources = {}
|
||||
adapter._voice_receivers = {}
|
||||
adapter._client = MagicMock()
|
||||
adapter._client.user = SimpleNamespace(id=99999, name="HermesBot")
|
||||
@@ -2408,6 +2454,7 @@ class TestVoiceTTSPlayback:
|
||||
adapter.config = config
|
||||
adapter._voice_clients = {}
|
||||
adapter._voice_text_channels = {}
|
||||
adapter._voice_sources = {}
|
||||
adapter._voice_receivers = {}
|
||||
return adapter
|
||||
|
||||
@@ -2587,6 +2634,7 @@ class TestUDPKeepalive:
|
||||
adapter.config = config
|
||||
adapter._voice_clients = {}
|
||||
adapter._voice_text_channels = {}
|
||||
adapter._voice_sources = {}
|
||||
adapter._voice_receivers = {}
|
||||
adapter._voice_listen_tasks = {}
|
||||
|
||||
|
||||
141
tests/gateway/test_weak_credential_guard.py
Normal file
141
tests/gateway/test_weak_credential_guard.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Tests for gateway weak credential rejection at startup.
|
||||
|
||||
Ported from openclaw/openclaw#64586: rejects known-weak placeholder
|
||||
tokens at gateway startup instead of letting them silently fail
|
||||
against platform APIs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig, Platform, _validate_gateway_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: create a minimal GatewayConfig with one enabled platform
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_gateway_config(platform, token, enabled=True, **extra_kwargs):
|
||||
"""Create a minimal GatewayConfig-like object for validation testing."""
|
||||
from gateway.config import GatewayConfig
|
||||
|
||||
config = GatewayConfig(platforms={})
|
||||
pconfig = PlatformConfig(enabled=enabled, token=token, **extra_kwargs)
|
||||
config.platforms[platform] = pconfig
|
||||
return config
|
||||
|
||||
|
||||
def _validate_and_return(config):
|
||||
"""Call _validate_gateway_config and return the config (mutated in place)."""
|
||||
_validate_gateway_config(config)
|
||||
return config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: platform token placeholder rejection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlatformTokenPlaceholderGuard:
|
||||
"""Verify that _validate_gateway_config disables platforms with placeholder tokens."""
|
||||
|
||||
def test_rejects_triple_asterisk(self, caplog):
|
||||
"""'***' is the .env.example placeholder — should be rejected."""
|
||||
config = _make_gateway_config(Platform.TELEGRAM, "***")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
_validate_and_return(config)
|
||||
assert config.platforms[Platform.TELEGRAM].enabled is False
|
||||
assert "placeholder" in caplog.text.lower()
|
||||
|
||||
def test_rejects_changeme(self, caplog):
|
||||
config = _make_gateway_config(Platform.DISCORD, "changeme")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
_validate_and_return(config)
|
||||
assert config.platforms[Platform.DISCORD].enabled is False
|
||||
|
||||
def test_rejects_your_api_key(self, caplog):
|
||||
config = _make_gateway_config(Platform.SLACK, "your_api_key")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
_validate_and_return(config)
|
||||
assert config.platforms[Platform.SLACK].enabled is False
|
||||
|
||||
def test_rejects_placeholder(self, caplog):
|
||||
config = _make_gateway_config(Platform.MATRIX, "placeholder")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
_validate_and_return(config)
|
||||
assert config.platforms[Platform.MATRIX].enabled is False
|
||||
|
||||
def test_accepts_real_token(self, caplog):
|
||||
"""A real-looking bot token should pass validation."""
|
||||
config = _make_gateway_config(
|
||||
Platform.TELEGRAM, "7123456789:AAHdqTcvCH1vGWJxfSeOfSAs0K5PALDsaw"
|
||||
)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
_validate_and_return(config)
|
||||
assert config.platforms[Platform.TELEGRAM].enabled is True
|
||||
assert "placeholder" not in caplog.text.lower()
|
||||
|
||||
def test_accepts_empty_token_without_error(self, caplog):
|
||||
"""Empty tokens get a warning (existing behavior), not a placeholder error."""
|
||||
config = _make_gateway_config(Platform.TELEGRAM, "")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_validate_and_return(config)
|
||||
# Empty token doesn't trigger placeholder rejection — enabled stays True
|
||||
# (the existing empty-token warning is separate)
|
||||
assert config.platforms[Platform.TELEGRAM].enabled is True
|
||||
|
||||
def test_disabled_platform_not_checked(self, caplog):
|
||||
"""Disabled platforms should not be validated."""
|
||||
config = _make_gateway_config(Platform.TELEGRAM, "***", enabled=False)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
_validate_and_return(config)
|
||||
assert "placeholder" not in caplog.text.lower()
|
||||
|
||||
def test_rejects_whitespace_padded_placeholder(self, caplog):
|
||||
"""Whitespace-padded placeholders should still be caught."""
|
||||
config = _make_gateway_config(Platform.TELEGRAM, " *** ")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
_validate_and_return(config)
|
||||
assert config.platforms[Platform.TELEGRAM].enabled is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test: API server placeholder key on network-accessible host
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAPIServerPlaceholderKeyGuard:
|
||||
"""Verify that the API server rejects placeholder keys on network hosts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_wildcard_with_placeholder_key(self):
|
||||
from gateway.platforms.api_server import APIServerAdapter
|
||||
|
||||
adapter = APIServerAdapter(
|
||||
PlatformConfig(enabled=True, extra={"host": "0.0.0.0", "key": "changeme"})
|
||||
)
|
||||
result = await adapter.connect()
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refuses_wildcard_with_asterisk_key(self):
|
||||
from gateway.platforms.api_server import APIServerAdapter
|
||||
|
||||
adapter = APIServerAdapter(
|
||||
PlatformConfig(enabled=True, extra={"host": "0.0.0.0", "key": "***"})
|
||||
)
|
||||
result = await adapter.connect()
|
||||
assert result is False
|
||||
|
||||
def test_allows_loopback_with_placeholder_key(self):
|
||||
"""Loopback with a placeholder key is fine — not network-exposed."""
|
||||
from gateway.platforms.api_server import APIServerAdapter
|
||||
from gateway.platforms.base import is_network_accessible
|
||||
|
||||
adapter = APIServerAdapter(
|
||||
PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "key": "changeme"})
|
||||
)
|
||||
# On loopback the placeholder guard doesn't fire
|
||||
assert is_network_accessible(adapter._host) is False
|
||||
@@ -30,7 +30,7 @@ class TestWeixinFormatting:
|
||||
|
||||
assert (
|
||||
adapter.format_message(content)
|
||||
== "【Title】\n\n**Plan**\n\nUse **bold** and [docs](https://example.com)."
|
||||
== "【Title】\n\n**Plan**\n\nUse **bold** and docs (https://example.com)."
|
||||
)
|
||||
|
||||
def test_format_message_rewrites_markdown_tables(self):
|
||||
@@ -374,3 +374,149 @@ class TestWeixinRemoteMediaSafety:
|
||||
assert "Blocked unsafe URL" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError for unsafe URL")
|
||||
|
||||
|
||||
class TestWeixinMarkdownLinks:
|
||||
"""Markdown links should be converted to plaintext since WeChat can't render them."""
|
||||
|
||||
def test_format_message_converts_markdown_links_to_plain_text(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = "Check [the docs](https://example.com) and [GitHub](https://github.com) for details"
|
||||
assert (
|
||||
adapter.format_message(content)
|
||||
== "Check the docs (https://example.com) and GitHub (https://github.com) for details"
|
||||
)
|
||||
|
||||
def test_format_message_preserves_links_inside_code_blocks(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = "See below:\n\n```\n[link](https://example.com)\n```\n\nDone."
|
||||
result = adapter.format_message(content)
|
||||
assert "[link](https://example.com)" in result
|
||||
|
||||
|
||||
class TestWeixinBlankMessagePrevention:
|
||||
"""Regression tests for the blank-bubble bugs.
|
||||
|
||||
Three separate guards now prevent a blank WeChat message from ever being
|
||||
dispatched:
|
||||
|
||||
1. ``_split_text_for_weixin_delivery("")`` returns ``[]`` — not ``[""]``.
|
||||
2. ``send()`` filters out empty/whitespace-only chunks before calling
|
||||
``_send_text_chunk``.
|
||||
3. ``_send_message()`` raises ``ValueError`` for empty text as a last-resort
|
||||
safety net.
|
||||
"""
|
||||
|
||||
def test_split_text_returns_empty_list_for_empty_string(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter._split_text("") == []
|
||||
|
||||
def test_split_text_returns_empty_list_for_empty_string_split_per_line(self):
|
||||
adapter = WeixinAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"account_id": "acct",
|
||||
"token": "test-tok",
|
||||
"split_multiline_messages": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
assert adapter._split_text("") == []
|
||||
|
||||
@patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock)
|
||||
def test_send_empty_content_does_not_call_send_message(self, send_message_mock):
|
||||
adapter = _make_adapter()
|
||||
adapter._session = object()
|
||||
adapter._token = "test-token"
|
||||
adapter._base_url = "https://weixin.example.com"
|
||||
adapter._token_store.get = lambda account_id, chat_id: "ctx-token"
|
||||
|
||||
result = asyncio.run(adapter.send("wxid_test123", ""))
|
||||
# Empty content → no chunks → no _send_message calls
|
||||
assert result.success is True
|
||||
send_message_mock.assert_not_awaited()
|
||||
|
||||
def test_send_message_rejects_empty_text(self):
|
||||
"""_send_message raises ValueError for empty/whitespace text."""
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="text must not be empty"):
|
||||
asyncio.run(
|
||||
weixin._send_message(
|
||||
AsyncMock(),
|
||||
base_url="https://example.com",
|
||||
token="tok",
|
||||
to="wxid_test",
|
||||
text="",
|
||||
context_token=None,
|
||||
client_id="cid",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestWeixinStreamingCursorSuppression:
|
||||
"""WeChat doesn't support message editing — cursor must be suppressed."""
|
||||
|
||||
def test_supports_message_editing_is_false(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter.SUPPORTS_MESSAGE_EDITING is False
|
||||
|
||||
|
||||
class TestWeixinMediaBuilder:
|
||||
"""Media builder uses base64(hex_key), not base64(raw_bytes) for aes_key."""
|
||||
|
||||
def test_image_builder_aes_key_is_base64_of_hex(self):
|
||||
import base64
|
||||
adapter = _make_adapter()
|
||||
media_type, builder = adapter._outbound_media_builder("photo.jpg")
|
||||
assert media_type == weixin.MEDIA_IMAGE
|
||||
|
||||
fake_hex_key = "0123456789abcdef0123456789abcdef"
|
||||
expected_aes = base64.b64encode(fake_hex_key.encode("ascii")).decode("ascii")
|
||||
item = builder(
|
||||
encrypt_query_param="eq",
|
||||
aes_key_for_api=expected_aes,
|
||||
ciphertext_size=1024,
|
||||
plaintext_size=1000,
|
||||
filename="photo.jpg",
|
||||
rawfilemd5="abc123",
|
||||
)
|
||||
assert item["image_item"]["media"]["aes_key"] == expected_aes
|
||||
|
||||
def test_video_builder_includes_md5(self):
|
||||
adapter = _make_adapter()
|
||||
media_type, builder = adapter._outbound_media_builder("clip.mp4")
|
||||
assert media_type == weixin.MEDIA_VIDEO
|
||||
|
||||
item = builder(
|
||||
encrypt_query_param="eq",
|
||||
aes_key_for_api="fakekey",
|
||||
ciphertext_size=2048,
|
||||
plaintext_size=2000,
|
||||
filename="clip.mp4",
|
||||
rawfilemd5="deadbeef",
|
||||
)
|
||||
assert item["video_item"]["video_md5"] == "deadbeef"
|
||||
|
||||
def test_voice_builder_for_audio_files(self):
|
||||
adapter = _make_adapter()
|
||||
media_type, builder = adapter._outbound_media_builder("note.mp3")
|
||||
assert media_type == weixin.MEDIA_VOICE
|
||||
|
||||
item = builder(
|
||||
encrypt_query_param="eq",
|
||||
aes_key_for_api="fakekey",
|
||||
ciphertext_size=512,
|
||||
plaintext_size=500,
|
||||
filename="note.mp3",
|
||||
rawfilemd5="abc",
|
||||
)
|
||||
assert item["type"] == weixin.ITEM_VOICE
|
||||
assert "voice_item" in item
|
||||
|
||||
def test_voice_builder_for_silk_files(self):
|
||||
adapter = _make_adapter()
|
||||
media_type, builder = adapter._outbound_media_builder("recording.silk")
|
||||
assert media_type == weixin.MEDIA_VOICE
|
||||
|
||||
271
tests/gateway/test_whatsapp_formatting.py
Normal file
271
tests/gateway/test_whatsapp_formatting.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""Tests for WhatsApp message formatting and chunking.
|
||||
|
||||
Covers:
|
||||
- format_message(): markdown → WhatsApp syntax conversion
|
||||
- send(): message chunking for long responses
|
||||
- MAX_MESSAGE_LENGTH: practical UX limit
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_adapter():
|
||||
"""Create a WhatsAppAdapter with test attributes (bypass __init__)."""
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||
|
||||
adapter = WhatsAppAdapter.__new__(WhatsAppAdapter)
|
||||
adapter.platform = Platform.WHATSAPP
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = {}
|
||||
adapter._bridge_port = 3000
|
||||
adapter._bridge_script = "/tmp/test-bridge.js"
|
||||
adapter._session_path = MagicMock()
|
||||
adapter._bridge_log_fh = None
|
||||
adapter._bridge_log = None
|
||||
adapter._bridge_process = None
|
||||
adapter._reply_prefix = None
|
||||
adapter._running = True
|
||||
adapter._message_handler = None
|
||||
adapter._fatal_error_code = None
|
||||
adapter._fatal_error_message = None
|
||||
adapter._fatal_error_retryable = True
|
||||
adapter._fatal_error_handler = None
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._background_tasks = set()
|
||||
adapter._auto_tts_disabled_chats = set()
|
||||
adapter._message_queue = asyncio.Queue()
|
||||
adapter._http_session = MagicMock()
|
||||
adapter._mention_patterns = []
|
||||
return adapter
|
||||
|
||||
|
||||
class _AsyncCM:
|
||||
"""Minimal async context manager returning a fixed value."""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.value
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_message tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatMessage:
|
||||
"""WhatsApp markdown conversion."""
|
||||
|
||||
def test_bold_double_asterisk(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter.format_message("**hello**") == "*hello*"
|
||||
|
||||
def test_bold_double_underscore(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter.format_message("__hello__") == "*hello*"
|
||||
|
||||
def test_strikethrough(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter.format_message("~~deleted~~") == "~deleted~"
|
||||
|
||||
def test_headers_converted_to_bold(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter.format_message("# Title") == "*Title*"
|
||||
assert adapter.format_message("## Subtitle") == "*Subtitle*"
|
||||
assert adapter.format_message("### Deep") == "*Deep*"
|
||||
|
||||
def test_links_converted(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter.format_message("[click here](https://example.com)")
|
||||
assert result == "click here (https://example.com)"
|
||||
|
||||
def test_code_blocks_protected(self):
|
||||
"""Code blocks should not have their content reformatted."""
|
||||
adapter = _make_adapter()
|
||||
content = "before **bold** ```python\n**not bold**\n``` after **bold**"
|
||||
result = adapter.format_message(content)
|
||||
assert "```python\n**not bold**\n```" in result
|
||||
assert result.startswith("before *bold*")
|
||||
assert result.endswith("after *bold*")
|
||||
|
||||
def test_inline_code_protected(self):
|
||||
"""Inline code should not have its content reformatted."""
|
||||
adapter = _make_adapter()
|
||||
content = "use `**raw**` here"
|
||||
result = adapter.format_message(content)
|
||||
assert "`**raw**`" in result
|
||||
assert result.startswith("use ")
|
||||
|
||||
def test_empty_content(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter.format_message("") == ""
|
||||
assert adapter.format_message(None) is None
|
||||
|
||||
def test_plain_text_unchanged(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter.format_message("hello world") == "hello world"
|
||||
|
||||
def test_already_whatsapp_italic(self):
|
||||
"""Single *italic* should pass through unchanged."""
|
||||
adapter = _make_adapter()
|
||||
# After bold conversion, *text* is WhatsApp italic
|
||||
assert adapter.format_message("*italic*") == "*italic*"
|
||||
|
||||
def test_multiline_mixed(self):
|
||||
adapter = _make_adapter()
|
||||
content = "# Header\n\n**Bold text** and ~~strike~~\n\n```\ncode\n```"
|
||||
result = adapter.format_message(content)
|
||||
assert "*Header*" in result
|
||||
assert "*Bold text*" in result
|
||||
assert "~strike~" in result
|
||||
assert "```\ncode\n```" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAX_MESSAGE_LENGTH tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMessageLimits:
|
||||
"""WhatsApp message length limits."""
|
||||
|
||||
def test_max_message_length_is_practical(self):
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||
assert WhatsAppAdapter.MAX_MESSAGE_LENGTH == 4096
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send() chunking tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSendChunking:
|
||||
"""WhatsApp send() splits long messages into chunks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_message_single_send(self):
|
||||
adapter = _make_adapter()
|
||||
resp = MagicMock(status=200)
|
||||
resp.json = AsyncMock(return_value={"messageId": "msg1"})
|
||||
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||
|
||||
result = await adapter.send("chat1", "short message")
|
||||
assert result.success
|
||||
# Only one call to bridge /send
|
||||
assert adapter._http_session.post.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_message_chunked(self):
|
||||
adapter = _make_adapter()
|
||||
resp = MagicMock(status=200)
|
||||
resp.json = AsyncMock(return_value={"messageId": "msg1"})
|
||||
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||
|
||||
# Create a message longer than MAX_MESSAGE_LENGTH (4096)
|
||||
long_msg = "a " * 3000 # ~6000 chars
|
||||
|
||||
result = await adapter.send("chat1", long_msg)
|
||||
assert result.success
|
||||
# Should have made multiple calls
|
||||
assert adapter._http_session.post.call_count > 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_message_no_send(self):
|
||||
adapter = _make_adapter()
|
||||
result = await adapter.send("chat1", "")
|
||||
assert result.success
|
||||
assert adapter._http_session.post.call_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_no_send(self):
|
||||
adapter = _make_adapter()
|
||||
result = await adapter.send("chat1", " \n ")
|
||||
assert result.success
|
||||
assert adapter._http_session.post.call_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_applied_before_send(self):
|
||||
"""Markdown should be converted to WhatsApp format before sending."""
|
||||
adapter = _make_adapter()
|
||||
resp = MagicMock(status=200)
|
||||
resp.json = AsyncMock(return_value={"messageId": "msg1"})
|
||||
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||
|
||||
await adapter.send("chat1", "**bold text**")
|
||||
|
||||
# Check the payload sent to the bridge
|
||||
call_args = adapter._http_session.post.call_args
|
||||
payload = call_args.kwargs.get("json") or call_args[1].get("json")
|
||||
assert payload["message"] == "*bold text*"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_to_only_on_first_chunk(self):
|
||||
"""reply_to should only be set on the first chunk."""
|
||||
adapter = _make_adapter()
|
||||
resp = MagicMock(status=200)
|
||||
resp.json = AsyncMock(return_value={"messageId": "msg1"})
|
||||
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||
|
||||
long_msg = "word " * 2000 # ~10000 chars, multiple chunks
|
||||
|
||||
await adapter.send("chat1", long_msg, reply_to="orig123")
|
||||
|
||||
calls = adapter._http_session.post.call_args_list
|
||||
assert len(calls) > 1
|
||||
|
||||
# First chunk should have replyTo
|
||||
first_payload = calls[0].kwargs.get("json") or calls[0][1].get("json")
|
||||
assert first_payload.get("replyTo") == "orig123"
|
||||
|
||||
# Subsequent chunks should NOT have replyTo
|
||||
for call in calls[1:]:
|
||||
payload = call.kwargs.get("json") or call[1].get("json")
|
||||
assert "replyTo" not in payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_error_returns_failure(self):
|
||||
adapter = _make_adapter()
|
||||
resp = MagicMock(status=500)
|
||||
resp.text = AsyncMock(return_value="Internal Server Error")
|
||||
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||
|
||||
result = await adapter.send("chat1", "hello")
|
||||
assert not result.success
|
||||
assert "Internal Server Error" in result.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_connected_returns_failure(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._running = False
|
||||
|
||||
result = await adapter.send("chat1", "hello")
|
||||
assert not result.success
|
||||
assert "Not connected" in result.error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# display_config tier classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWhatsAppTier:
|
||||
"""WhatsApp should be classified as TIER_MEDIUM."""
|
||||
|
||||
def test_whatsapp_streaming_follows_global(self):
|
||||
from gateway.display_config import resolve_display_setting
|
||||
# TIER_MEDIUM has streaming: None (follow global), not False
|
||||
assert resolve_display_setting({}, "whatsapp", "streaming") is None
|
||||
|
||||
def test_whatsapp_tool_progress_is_new(self):
|
||||
from gateway.display_config import resolve_display_setting
|
||||
assert resolve_display_setting({}, "whatsapp", "tool_progress") == "new"
|
||||
Reference in New Issue
Block a user