|
@@ -25,9 +25,11 @@ import pytest_asyncio
|
|
|
# Import chattolib types for tests - using vendored chattolib from adapter
|
|
# Import chattolib types for tests - using vendored chattolib from adapter
|
|
|
|
|
|
|
|
# -- Path setup --
|
|
# -- Path setup --
|
|
|
|
|
+# The Hermes agent itself is not a dependency of this plugin; point HERMES_ROOT
|
|
|
|
|
+# at a checkout to run these tests outside a deployed agent.
|
|
|
PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
|
|
PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
|
|
|
sys.path.insert(0, PLUGIN_ROOT)
|
|
sys.path.insert(0, PLUGIN_ROOT)
|
|
|
-sys.path.insert(0, "/opt/hermes")
|
|
|
|
|
|
|
+sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
|
|
|
sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
|
|
sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
|
|
|
|
|
|
|
|
from adapter import (
|
|
from adapter import (
|
|
@@ -36,9 +38,24 @@ from adapter import (
|
|
|
hermes_validate_config as validate_config,
|
|
hermes_validate_config as validate_config,
|
|
|
register,
|
|
register,
|
|
|
)
|
|
)
|
|
|
|
|
+from chattolib.realtime_types import ReactionPayload
|
|
|
|
|
+from chattolib.types import (
|
|
|
|
|
+ AssetUrl,
|
|
|
|
|
+ Message,
|
|
|
|
|
+ MessageAttachment,
|
|
|
|
|
+ Room,
|
|
|
|
|
+ RoomKind,
|
|
|
|
|
+ User,
|
|
|
|
|
+)
|
|
|
from platform_config import ChattoConstants
|
|
from platform_config import ChattoConstants
|
|
|
from gateway.config import PlatformConfig
|
|
from gateway.config import PlatformConfig
|
|
|
-from gateway.platforms.base import SendResult, MessageEvent, MessageType
|
|
|
|
|
|
|
+from gateway.platforms.base import (
|
|
|
|
|
+ CachedMedia,
|
|
|
|
|
+ MessageEvent,
|
|
|
|
|
+ MessageType,
|
|
|
|
|
+ SendResult,
|
|
|
|
|
+ get_inbound_media_max_bytes,
|
|
|
|
|
+)
|
|
|
|
|
|
|
|
_EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
|
|
_EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
|
|
|
_MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
|
|
_MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
|
|
@@ -72,9 +89,9 @@ class _MockPluginContext:
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_chatto_registered():
|
|
def _ensure_chatto_registered():
|
|
|
- """Register chatto in the platform registry so Platform('chatto') works."""
|
|
|
|
|
|
|
+ """Register the platform so Platform(PLATFORM_NAME) resolves."""
|
|
|
from gateway.platform_registry import platform_registry
|
|
from gateway.platform_registry import platform_registry
|
|
|
- if not platform_registry.is_registered("chatto"):
|
|
|
|
|
|
|
+ if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
|
|
|
ctx = _MockPluginContext()
|
|
ctx = _MockPluginContext()
|
|
|
register(ctx)
|
|
register(ctx)
|
|
|
|
|
|
|
@@ -105,6 +122,53 @@ def _make_config(**extra_overrides):
|
|
|
return PlatformConfig(enabled=True, extra=extra)
|
|
return PlatformConfig(enabled=True, extra=extra)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _make_room(room_id, name, kind):
|
|
|
|
|
+ """Build a real chattolib Room, as the client would return."""
|
|
|
|
|
+ return Room(id=room_id, name=name, kind=kind, description="",
|
|
|
|
|
+ archived=False, group_id="", universal=kind != RoomKind.DM)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _make_user(user_id, login):
|
|
|
|
|
+ """Build a real chattolib User, as the member directory would return."""
|
|
|
|
|
+ return User(id=user_id, login=login, display_name=login.replace("_", " ").title())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _make_attachment(filename, content_type, url="https://cdn.example.com/a"):
|
|
|
|
|
+ """Build a real MessageAttachment carrying a (pre-signed) asset URL."""
|
|
|
|
|
+ return MessageAttachment(
|
|
|
|
|
+ id="asset-" + filename,
|
|
|
|
|
+ filename=filename,
|
|
|
|
|
+ content_type=content_type,
|
|
|
|
|
+ asset_url=AssetUrl(url=url),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _make_message(body="hi", attachments=None, message_id="msg-1", room_id="room-1"):
|
|
|
|
|
+ """Build a real chattolib Message, as fetch_message() would return."""
|
|
|
|
|
+ return Message(
|
|
|
|
|
+ id=message_id,
|
|
|
|
|
+ room_id=room_id,
|
|
|
|
|
+ created_at=None,
|
|
|
|
|
+ actor_id="user-1",
|
|
|
|
|
+ body=body,
|
|
|
|
|
+ attachments=list(attachments or []),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _make_posted_payload(room_id="room-1", message_event_id="msg-1"):
|
|
|
|
|
+ """A message_posted payload whose fetch_message() the caller stubs."""
|
|
|
|
|
+ payload = MagicMock()
|
|
|
|
|
+ payload.room_id = room_id
|
|
|
|
|
+ payload.message_event_id = message_event_id
|
|
|
|
|
+ payload.thread_root_event_id = None
|
|
|
|
|
+ return payload
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _cached(path, media_type, kind):
|
|
|
|
|
+ """The CachedMedia that cache_media_bytes() would return for an attachment."""
|
|
|
|
|
+ return CachedMedia(path=path, media_type=media_type, kind=kind, display_name="f")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _make_adapter(**extra_overrides):
|
|
def _make_adapter(**extra_overrides):
|
|
|
"""Create a ChattoAdapter with mocked config."""
|
|
"""Create a ChattoAdapter with mocked config."""
|
|
|
_clear_chatto_env()
|
|
_clear_chatto_env()
|
|
@@ -145,22 +209,30 @@ class TestAdapterInstantiation:
|
|
|
cfg = _make_config()
|
|
cfg = _make_config()
|
|
|
adapter = ChattoAdapter(cfg)
|
|
adapter = ChattoAdapter(cfg)
|
|
|
assert adapter is not None
|
|
assert adapter is not None
|
|
|
- assert adapter.platform.name == "chatto"
|
|
|
|
|
|
|
+ # Platform members created dynamically from a plugin name carry the
|
|
|
|
|
+ # name upper-cased; the registered identity is the value.
|
|
|
|
|
+ assert adapter.platform.value == ChattoConstants.PLATFORM_NAME
|
|
|
|
|
|
|
|
def test_adapter_max_message_length(self):
|
|
def test_adapter_max_message_length(self):
|
|
|
|
|
+ """The framework chunks via max_message_length_for_chat(), which reads
|
|
|
|
|
+ the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096
|
|
|
|
|
+ when it is missing."""
|
|
|
cfg = _make_config()
|
|
cfg = _make_config()
|
|
|
adapter = ChattoAdapter(cfg)
|
|
adapter = ChattoAdapter(cfg)
|
|
|
assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
|
|
assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
|
|
|
|
|
+ assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH
|
|
|
|
|
|
|
|
def test_adapter_splits_long_messages(self):
|
|
def test_adapter_splits_long_messages(self):
|
|
|
cfg = _make_config()
|
|
cfg = _make_config()
|
|
|
adapter = ChattoAdapter(cfg)
|
|
adapter = ChattoAdapter(cfg)
|
|
|
assert adapter.splits_long_messages is True
|
|
assert adapter.splits_long_messages is True
|
|
|
|
|
|
|
|
- def test_adapter_supports_threads(self):
|
|
|
|
|
|
|
+ def test_adapter_threads_enabled_by_default(self):
|
|
|
|
|
+ """There is no capability flag for threads — Chatto threading is driven
|
|
|
|
|
+ by the auto_thread setting, which defaults to on."""
|
|
|
cfg = _make_config()
|
|
cfg = _make_config()
|
|
|
adapter = ChattoAdapter(cfg)
|
|
adapter = ChattoAdapter(cfg)
|
|
|
- assert adapter.supports_threads() is True
|
|
|
|
|
|
|
+ assert adapter.chatto_config.auto_thread.value is True
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- Registration and requirements --
|
|
# -- Registration and requirements --
|
|
@@ -171,9 +243,10 @@ class TestRegistration:
|
|
|
def test_register_called(self):
|
|
def test_register_called(self):
|
|
|
ctx = _MockPluginContext()
|
|
ctx = _MockPluginContext()
|
|
|
register(ctx)
|
|
register(ctx)
|
|
|
- assert "chatto" in ctx.registered_names
|
|
|
|
|
- assert ctx.registered_kwargs["name"] == "chatto"
|
|
|
|
|
- assert ctx.registered_kwargs["label"] == "Chatto"
|
|
|
|
|
|
|
+ assert ChattoConstants.PLATFORM_NAME in ctx.registered_names
|
|
|
|
|
+ assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME
|
|
|
|
|
+ assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL
|
|
|
|
|
+ assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH
|
|
|
|
|
|
|
|
def test_check_requirements(self):
|
|
def test_check_requirements(self):
|
|
|
assert check_requirements() is True
|
|
assert check_requirements() is True
|
|
@@ -299,113 +372,405 @@ class TestMessageEditing:
|
|
|
|
|
|
|
|
async def test_edit_message(self, adapter):
|
|
async def test_edit_message(self, adapter):
|
|
|
result = await adapter.edit_message("room-1", "msg-1", "New content")
|
|
result = await adapter.edit_message("room-1", "msg-1", "New content")
|
|
|
- assert result is True
|
|
|
|
|
|
|
+ assert result.success is True
|
|
|
adapter._chatto_client.update_message.assert_called_once()
|
|
adapter._chatto_client.update_message.assert_called_once()
|
|
|
|
|
+ call_kwargs = adapter._chatto_client.update_message.call_args.kwargs
|
|
|
|
|
+ assert call_kwargs["room_id"] == "room-1"
|
|
|
|
|
+ assert call_kwargs["event_id"] == "msg-1"
|
|
|
|
|
+ assert call_kwargs["body"] == "New content"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_edit_message_marks_own_edit_seen(self, adapter):
|
|
|
|
|
+ """The edit echoes back as message_edited — it must not look inbound."""
|
|
|
|
|
+ mock_msg = MagicMock()
|
|
|
|
|
+ mock_msg.id = "msg-1"
|
|
|
|
|
+ adapter._chatto_client.update_message.return_value = mock_msg
|
|
|
|
|
+ await adapter.edit_message("room-1", "msg-1", "New content")
|
|
|
|
|
+ assert adapter._is_seen("msg-1") is True
|
|
|
|
|
+
|
|
|
|
|
+ async def test_edit_message_too_long_refuses(self, adapter):
|
|
|
|
|
+ """Overlong content must fall back to send() (which splits), not be
|
|
|
|
|
+ silently truncated into a lossy edit."""
|
|
|
|
|
+ result = await adapter.edit_message(
|
|
|
|
|
+ "room-1", "msg-1", "x" * (_MAX_MESSAGE_LENGTH + 1),
|
|
|
|
|
+ )
|
|
|
|
|
+ assert result.success is False
|
|
|
|
|
+ adapter._chatto_client.update_message.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_edit_message_empty_content(self, adapter):
|
|
|
|
|
+ result = await adapter.edit_message("room-1", "msg-1", "")
|
|
|
|
|
+ assert result.success is False
|
|
|
|
|
+ adapter._chatto_client.update_message.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_edit_message_error_is_retryable(self, adapter):
|
|
|
|
|
+ from chattolib.exceptions import ChattoError
|
|
|
|
|
+
|
|
|
|
|
+ adapter._chatto_client.update_message.side_effect = ChattoError("boom")
|
|
|
|
|
+ result = await adapter.edit_message("room-1", "msg-1", "New content")
|
|
|
|
|
+ assert result.success is False
|
|
|
|
|
+ assert result.retryable is True
|
|
|
|
|
|
|
|
async def test_delete_message(self, adapter):
|
|
async def test_delete_message(self, adapter):
|
|
|
result = await adapter.delete_message("room-1", "msg-1")
|
|
result = await adapter.delete_message("room-1", "msg-1")
|
|
|
assert result is True
|
|
assert result is True
|
|
|
adapter._chatto_client.delete_message.assert_called_once()
|
|
adapter._chatto_client.delete_message.assert_called_once()
|
|
|
|
|
+ call_kwargs = adapter._chatto_client.delete_message.call_args.kwargs
|
|
|
|
|
+ assert call_kwargs["room_id"] == "room-1"
|
|
|
|
|
+ assert call_kwargs["event_id"] == "msg-1"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_delete_message_missing_ids(self, adapter):
|
|
|
|
|
+ assert await adapter.delete_message("", "msg-1") is False
|
|
|
|
|
+ assert await adapter.delete_message("room-1", "") is False
|
|
|
|
|
+ adapter._chatto_client.delete_message.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_delete_message_error_returns_false(self, adapter):
|
|
|
|
|
+ from chattolib.exceptions import ChattoError
|
|
|
|
|
+
|
|
|
|
|
+ adapter._chatto_client.delete_message.side_effect = ChattoError("nope")
|
|
|
|
|
+ assert await adapter.delete_message("room-1", "msg-1") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
-# -- User lookup --
|
|
|
|
|
|
|
+# -- Outgoing text formatting --
|
|
|
|
|
|
|
|
-class TestUserLookup:
|
|
|
|
|
- """Test user lookup functionality."""
|
|
|
|
|
|
|
+class TestFormatMessage:
|
|
|
|
|
+ """format_message() only fixes what renders wrong in Chatto."""
|
|
|
|
|
+
|
|
|
|
|
+ def test_normalises_crlf(self):
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ assert adapter.format_message("a\r\nb\rc") == "a\nb\nc"
|
|
|
|
|
+
|
|
|
|
|
+ def test_collapses_excess_blank_lines(self):
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ assert adapter.format_message("a\n\n\n\n\n\nb") == "a\n\n\nb"
|
|
|
|
|
+
|
|
|
|
|
+ def test_leaves_markdown_untouched(self):
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ text = "**bold** `code`\n\n```py\nx = 1\n```\n- item"
|
|
|
|
|
+ assert adapter.format_message(text) == text
|
|
|
|
|
+
|
|
|
|
|
+ def test_empty_content(self):
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ assert adapter.format_message("") == ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# -- Handoff threads --
|
|
|
|
|
+
|
|
|
|
|
+class TestHandoffThread:
|
|
|
|
|
+ """create_handoff_thread() anchors a handoff on a seed message."""
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
@pytest_asyncio.fixture
|
|
|
def adapter(self):
|
|
def adapter(self):
|
|
|
- _clear_chatto_env()
|
|
|
|
|
- cfg = _make_config()
|
|
|
|
|
- adapter = ChattoAdapter(cfg)
|
|
|
|
|
- adapter._chatto_client = MagicMock()
|
|
|
|
|
- adapter._token = "test-token"
|
|
|
|
|
- adapter._user_cache = {}
|
|
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ adapter._chatto_client.post_message = AsyncMock()
|
|
|
|
|
+ adapter._chatto_client.follow_thread = AsyncMock()
|
|
|
return adapter
|
|
return adapter
|
|
|
|
|
|
|
|
- async def test_get_user_calls_chattolib(self, adapter):
|
|
|
|
|
- # Import chattolib types for testing (try vendored first)
|
|
|
|
|
- try:
|
|
|
|
|
- from chattolib_vendor.chattolib.types import User, GetUserResponse
|
|
|
|
|
- except ImportError:
|
|
|
|
|
- try:
|
|
|
|
|
- from chattolib.types import User, GetUserResponse
|
|
|
|
|
- except ImportError:
|
|
|
|
|
- # Fallback to mocked types if chattolib not installed
|
|
|
|
|
- from unittest.mock import MagicMock
|
|
|
|
|
- User = MagicMock
|
|
|
|
|
- GetUserResponse = MagicMock
|
|
|
|
|
- mock_user = User(id="user-1", login="testuser", display_name="Test User")
|
|
|
|
|
- adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
|
|
|
|
|
- result = await adapter.get_user("user-1")
|
|
|
|
|
- assert result is not None
|
|
|
|
|
- assert result["id"] == "user-1"
|
|
|
|
|
- assert result["login"] == "testuser"
|
|
|
|
|
-
|
|
|
|
|
- async def test_get_user_caching(self, adapter):
|
|
|
|
|
- # Import chattolib types for testing (try vendored first)
|
|
|
|
|
- try:
|
|
|
|
|
- from chattolib_vendor.chattolib.types import User, GetUserResponse
|
|
|
|
|
- except ImportError:
|
|
|
|
|
- try:
|
|
|
|
|
- from chattolib.types import User, GetUserResponse
|
|
|
|
|
- except ImportError:
|
|
|
|
|
- # Fallback to mocked types if chattolib not installed
|
|
|
|
|
- from unittest.mock import MagicMock
|
|
|
|
|
- User = MagicMock
|
|
|
|
|
- GetUserResponse = MagicMock
|
|
|
|
|
- mock_user = User(id="user-1", login="testuser", display_name="Test User")
|
|
|
|
|
- adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
|
|
|
|
|
- result1 = await adapter.get_user("user-1")
|
|
|
|
|
- result2 = await adapter.get_user("user-1")
|
|
|
|
|
- assert result1 == result2
|
|
|
|
|
- assert adapter._chatto_client.get_user.call_count == 1
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# -- Presence and Custom Status --
|
|
|
|
|
-
|
|
|
|
|
-class TestPresence:
|
|
|
|
|
- """Test presence functionality."""
|
|
|
|
|
|
|
+ async def test_returns_seed_message_id(self, adapter):
|
|
|
|
|
+ mock_msg = MagicMock()
|
|
|
|
|
+ mock_msg.id = "seed-1"
|
|
|
|
|
+ adapter._chatto_client.post_message.return_value = mock_msg
|
|
|
|
|
+ adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
|
|
+
|
|
|
|
|
+ result = await adapter.create_handoff_thread("room-1", "Refactor run")
|
|
|
|
|
+
|
|
|
|
|
+ assert result == "seed-1"
|
|
|
|
|
+ assert adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
|
|
|
|
|
+ adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1")
|
|
|
|
|
+ # Our own seed must not come back in as inbound traffic.
|
|
|
|
|
+ assert adapter._is_seen("seed-1") is True
|
|
|
|
|
+
|
|
|
|
|
+ async def test_dm_has_no_threads(self, adapter):
|
|
|
|
|
+ adapter._room_kinds["dm-1"] = RoomKind.DM
|
|
|
|
|
+ assert await adapter.create_handoff_thread("dm-1", "x") is None
|
|
|
|
|
+ adapter._chatto_client.post_message.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_seed_post_failure(self, adapter):
|
|
|
|
|
+ adapter._chatto_client.post_message.side_effect = RuntimeError("down")
|
|
|
|
|
+ adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
|
|
+ assert await adapter.create_handoff_thread("room-1", "x") is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# -- Native file / video / audio delivery --
|
|
|
|
|
+
|
|
|
|
|
+class TestNativeSends:
|
|
|
|
|
+ """send_document/_video/_voice upload instead of apologising in text."""
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
@pytest_asyncio.fixture
|
|
|
def adapter(self):
|
|
def adapter(self):
|
|
|
- _clear_chatto_env()
|
|
|
|
|
- cfg = _make_config()
|
|
|
|
|
- adapter = ChattoAdapter(cfg)
|
|
|
|
|
- adapter._chatto_client = MagicMock()
|
|
|
|
|
- adapter._chatto_client.update_presence = AsyncMock()
|
|
|
|
|
- adapter._token = "test-token"
|
|
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ adapter._chatto_client.post_message = AsyncMock()
|
|
|
|
|
+ adapter._upload_asset = AsyncMock(return_value="asset-1")
|
|
|
|
|
+ adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
|
|
|
|
|
+ mock_msg = MagicMock()
|
|
|
|
|
+ mock_msg.id = "msg-1"
|
|
|
|
|
+ adapter._chatto_client.post_message.return_value = mock_msg
|
|
|
return adapter
|
|
return adapter
|
|
|
|
|
|
|
|
- async def test_set_presence(self, adapter):
|
|
|
|
|
- result = await adapter.set_presence("online")
|
|
|
|
|
- assert result is True
|
|
|
|
|
- adapter._chatto_client.update_presence.assert_called_once()
|
|
|
|
|
|
|
+ @pytest.mark.parametrize(
|
|
|
|
|
+ "method,arg_name",
|
|
|
|
|
+ [
|
|
|
|
|
+ ("send_document", "file_path"),
|
|
|
|
|
+ ("send_video", "video_path"),
|
|
|
|
|
+ ("send_voice", "audio_path"),
|
|
|
|
|
+ ("send_image_file", "image_path"),
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+ async def test_uploads_and_attaches(self, adapter, method, arg_name):
|
|
|
|
|
+ result = await getattr(adapter, method)(
|
|
|
|
|
+ "room-1", **{arg_name: "/tmp/thing.bin"}, caption="here you go",
|
|
|
|
|
+ )
|
|
|
|
|
+ assert result.success is True
|
|
|
|
|
+ adapter._upload_asset.assert_called_once_with("room-1", "/tmp/thing.bin")
|
|
|
|
|
+ call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
|
|
|
|
|
+ assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
|
|
|
|
|
+ assert call_kwargs["body"] == "here you go"
|
|
|
|
|
|
|
|
|
|
+ async def test_unsafe_path_falls_back_to_notice(self, adapter):
|
|
|
|
|
+ adapter.validate_media_delivery_path = MagicMock(return_value=None)
|
|
|
|
|
+ adapter.send = AsyncMock(return_value=SendResult(success=True))
|
|
|
|
|
+ await adapter.send_document("room-1", "/etc/shadow")
|
|
|
|
|
+ adapter._upload_asset.assert_not_called()
|
|
|
|
|
+ # Never echo the host path into chat.
|
|
|
|
|
+ sent_text = adapter.send.call_args.args[1]
|
|
|
|
|
+ assert "/etc/shadow" not in sent_text
|
|
|
|
|
|
|
|
-class TestCustomStatus:
|
|
|
|
|
- """Test custom status functionality."""
|
|
|
|
|
|
|
+ async def test_upload_failure_falls_back_to_notice(self, adapter):
|
|
|
|
|
+ adapter._upload_asset = AsyncMock(return_value=None)
|
|
|
|
|
+ adapter.send = AsyncMock(return_value=SendResult(success=True))
|
|
|
|
|
+ await adapter.send_video("room-1", "/tmp/clip.mp4", caption="a clip")
|
|
|
|
|
+ sent_text = adapter.send.call_args.args[1]
|
|
|
|
|
+ assert sent_text.startswith("a clip\n")
|
|
|
|
|
+ assert "/tmp/clip.mp4" not in sent_text
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# -- Batched image delivery --
|
|
|
|
|
+
|
|
|
|
|
+class TestSendMultipleImages:
|
|
|
|
|
+ """A batch of images belongs in ONE Chatto message."""
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
@pytest_asyncio.fixture
|
|
|
def adapter(self):
|
|
def adapter(self):
|
|
|
- _clear_chatto_env()
|
|
|
|
|
- cfg = _make_config()
|
|
|
|
|
- adapter = ChattoAdapter(cfg)
|
|
|
|
|
- adapter._chatto_client = MagicMock()
|
|
|
|
|
- adapter._chatto_client.update_custom_status = AsyncMock()
|
|
|
|
|
- adapter._chatto_client.delete_custom_status = AsyncMock()
|
|
|
|
|
- adapter._token = "test-token"
|
|
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ adapter._chatto_client.post_message = AsyncMock()
|
|
|
|
|
+ mock_msg = MagicMock()
|
|
|
|
|
+ mock_msg.id = "msg-1"
|
|
|
|
|
+ adapter._chatto_client.post_message.return_value = mock_msg
|
|
|
|
|
+ adapter._upload_asset = AsyncMock(side_effect=["asset-1", "asset-2"])
|
|
|
|
|
+ adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
|
|
|
return adapter
|
|
return adapter
|
|
|
|
|
|
|
|
- async def test_set_custom_status(self, adapter):
|
|
|
|
|
- result = await adapter.set_custom_status("Processing...")
|
|
|
|
|
- assert result is True
|
|
|
|
|
- adapter._chatto_client.update_custom_status.assert_called_once()
|
|
|
|
|
|
|
+ async def test_bundles_into_single_message(self, adapter):
|
|
|
|
|
+ await adapter.send_multiple_images(
|
|
|
|
|
+ "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter._chatto_client.post_message.assert_called_once()
|
|
|
|
|
+ call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
|
|
|
|
|
+ assert call_kwargs["attachment_asset_ids"] == ["asset-1", "asset-2"]
|
|
|
|
|
+ assert call_kwargs["body"] == "first\nsecond"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_single_image_uses_base_path(self, adapter):
|
|
|
|
|
+ """One image is not a batch — leave it to the base implementation.
|
|
|
|
|
+
|
|
|
|
|
+ Also pins the send_image_file signature: the base class calls it with
|
|
|
|
|
+ ``image_path=`` as a keyword, so a renamed parameter degrades every
|
|
|
|
|
+ native image send to a text notice.
|
|
|
|
|
+ """
|
|
|
|
|
+ adapter.send_image_file = AsyncMock(return_value=SendResult(success=True))
|
|
|
|
|
+ await adapter.send_multiple_images("room-1", [("file:///tmp/a.png", "only")])
|
|
|
|
|
+ adapter._upload_asset.assert_not_called()
|
|
|
|
|
+ adapter.send_image_file.assert_called_once()
|
|
|
|
|
+ assert adapter.send_image_file.call_args.kwargs["image_path"] == "/tmp/a.png"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_partial_upload_failure_still_sends_the_rest(self, adapter):
|
|
|
|
|
+ adapter._upload_asset = AsyncMock(side_effect=["asset-1", None])
|
|
|
|
|
+ await adapter.send_multiple_images(
|
|
|
|
|
+ "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
|
|
|
|
|
+ )
|
|
|
|
|
+ call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
|
|
|
|
|
+ assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
|
|
|
|
|
+
|
|
|
|
|
+ async def test_file_uri_is_unquoted(self, adapter):
|
|
|
|
|
+ await adapter.send_multiple_images(
|
|
|
|
|
+ "room-1",
|
|
|
|
|
+ [("file:///tmp/a%20b.png", ""), ("/tmp/c.png", "")],
|
|
|
|
|
+ )
|
|
|
|
|
+ first_path = adapter._upload_asset.call_args_list[0].args[1]
|
|
|
|
|
+ assert first_path == "/tmp/a b.png"
|
|
|
|
|
|
|
|
- async def test_clear_custom_status(self, adapter):
|
|
|
|
|
- result = await adapter.clear_custom_status()
|
|
|
|
|
- assert result is True
|
|
|
|
|
- adapter._chatto_client.delete_custom_status.assert_called_once()
|
|
|
|
|
|
|
+
|
|
|
|
|
+# -- Reaction event forwarding --
|
|
|
|
|
+
|
|
|
|
|
+class TestReactionForwarding:
|
|
|
|
|
+ """Human reactions reach the gateway's reaction hook surface."""
|
|
|
|
|
+
|
|
|
|
|
+ @pytest_asyncio.fixture
|
|
|
|
|
+ def adapter(self):
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ adapter.me = _make_user("bot-user-id", "hermes_bot")
|
|
|
|
|
+ return adapter
|
|
|
|
|
+
|
|
|
|
|
+ def _event(self, kind, actor_id="human-1"):
|
|
|
|
|
+ event = MagicMock()
|
|
|
|
|
+ event.id = "evt-1"
|
|
|
|
|
+ event.kind = kind
|
|
|
|
|
+ event.actor_id = actor_id
|
|
|
|
|
+ payload = ReactionPayload(
|
|
|
|
|
+ room_id="room-1", message_event_id="msg-1", emoji="thumbsup",
|
|
|
|
|
+ )
|
|
|
|
|
+ # RealtimeEvent.get() only yields the payload for its own kind.
|
|
|
|
|
+ event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
|
|
|
|
|
+ return event
|
|
|
|
|
+
|
|
|
|
|
+ async def test_forwards_added_reaction(self, adapter):
|
|
|
|
|
+ handler = AsyncMock()
|
|
|
|
|
+ adapter.set_reaction_handler(handler)
|
|
|
|
|
+
|
|
|
|
|
+ await adapter._handle_realtime_event(self._event("reaction_added"))
|
|
|
|
|
+
|
|
|
|
|
+ handler.assert_called_once()
|
|
|
|
|
+ payload = handler.call_args.args[0]
|
|
|
|
|
+ assert payload["event_name"] == "reaction:added"
|
|
|
|
|
+ assert payload["reaction"] == "thumbsup"
|
|
|
|
|
+ assert payload["channel_id"] == "room-1"
|
|
|
|
|
+ assert payload["message_ts"] == "msg-1"
|
|
|
|
|
+ assert payload["user_id"] == "human-1"
|
|
|
|
|
+ assert payload["item_type"] == "message"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_forwards_removed_reaction(self, adapter):
|
|
|
|
|
+ handler = AsyncMock()
|
|
|
|
|
+ adapter.set_reaction_handler(handler)
|
|
|
|
|
+ await adapter._handle_realtime_event(self._event("reaction_removed"))
|
|
|
|
|
+ assert handler.call_args.args[0]["event_name"] == "reaction:removed"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_ignores_own_lifecycle_reactions(self, adapter):
|
|
|
|
|
+ """👀/✅/❌ are ours — forwarding them would feed the agent its own markers."""
|
|
|
|
|
+ handler = AsyncMock()
|
|
|
|
|
+ adapter.set_reaction_handler(handler)
|
|
|
|
|
+ await adapter._handle_realtime_event(
|
|
|
|
|
+ self._event("reaction_added", actor_id="bot-user-id"),
|
|
|
|
|
+ )
|
|
|
|
|
+ handler.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_no_handler_registered_is_harmless(self, adapter):
|
|
|
|
|
+ await adapter._handle_realtime_event(self._event("reaction_added"))
|
|
|
|
|
+
|
|
|
|
|
+ async def test_handler_exception_does_not_propagate(self, adapter):
|
|
|
|
|
+ adapter.set_reaction_handler(AsyncMock(side_effect=RuntimeError("hook boom")))
|
|
|
|
|
+ await adapter._handle_realtime_event(self._event("reaction_added"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# -- Inbound attachments --
|
|
|
|
|
+
|
|
|
|
|
+class TestInboundAttachments:
|
|
|
|
|
+ """Messages carrying files must reach the agent, body or not."""
|
|
|
|
|
+
|
|
|
|
|
+ @pytest_asyncio.fixture
|
|
|
|
|
+ def adapter(self):
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ adapter.chatto_config.allow_all_users.value = True
|
|
|
|
|
+ adapter.me = _make_user("bot-user-id", "hermes_bot")
|
|
|
|
|
+ adapter._room_kinds["room-1"] = RoomKind.DM
|
|
|
|
|
+ adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
|
|
|
+ adapter.handle_message = AsyncMock()
|
|
|
|
|
+ adapter._download_attachment_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nrest")
|
|
|
|
|
+ return adapter
|
|
|
|
|
+
|
|
|
|
|
+ async def test_image_attachment_becomes_media_url(self, adapter):
|
|
|
|
|
+ payload = _make_posted_payload()
|
|
|
|
|
+ adapter._chatto_client.get_room = AsyncMock()
|
|
|
|
|
+ message = _make_message(
|
|
|
|
|
+ body="look at this",
|
|
|
|
|
+ attachments=[_make_attachment("shot.png", "image/png")],
|
|
|
|
|
+ )
|
|
|
|
|
+ payload.fetch_message = AsyncMock(return_value=message)
|
|
|
|
|
+
|
|
|
|
|
+ with patch("adapter.cache_media_bytes", return_value=_cached("/cache/shot.png", "image/png", "image")):
|
|
|
|
|
+ await adapter._dispatch_message_posted(payload)
|
|
|
|
|
+
|
|
|
|
|
+ event = adapter.handle_message.call_args.args[0]
|
|
|
|
|
+ assert event.media_urls == ["/cache/shot.png"]
|
|
|
|
|
+ assert event.media_types == ["image/png"]
|
|
|
|
|
+ assert event.message_type == MessageType.PHOTO
|
|
|
|
|
+
|
|
|
|
|
+ async def test_attachment_only_message_is_not_dropped(self, adapter):
|
|
|
|
|
+ """The empty-body early return is what silently ate file uploads."""
|
|
|
|
|
+ payload = _make_posted_payload()
|
|
|
|
|
+ message = _make_message(
|
|
|
|
|
+ body="", attachments=[_make_attachment("report.pdf", "application/pdf")],
|
|
|
|
|
+ )
|
|
|
|
|
+ payload.fetch_message = AsyncMock(return_value=message)
|
|
|
|
|
+
|
|
|
|
|
+ with patch("adapter.cache_media_bytes", return_value=_cached("/cache/report.pdf", "application/pdf", "document")):
|
|
|
|
|
+ await adapter._dispatch_message_posted(payload)
|
|
|
|
|
+
|
|
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
|
|
+ event = adapter.handle_message.call_args.args[0]
|
|
|
|
|
+ assert event.message_type == MessageType.DOCUMENT
|
|
|
|
|
+ assert event.media_urls == ["/cache/report.pdf"]
|
|
|
|
|
+
|
|
|
|
|
+ async def test_empty_message_without_attachments_is_dropped(self, adapter):
|
|
|
|
|
+ payload = _make_posted_payload()
|
|
|
|
|
+ payload.fetch_message = AsyncMock(return_value=_make_message(body=""))
|
|
|
|
|
+ await adapter._dispatch_message_posted(payload)
|
|
|
|
|
+ adapter.handle_message.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_download_failure_still_delivers_the_text(self, adapter):
|
|
|
|
|
+ adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404"))
|
|
|
|
|
+ payload = _make_posted_payload()
|
|
|
|
|
+ payload.fetch_message = AsyncMock(return_value=_make_message(
|
|
|
|
|
+ body="see attached", attachments=[_make_attachment("a.png", "image/png")],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ await adapter._dispatch_message_posted(payload)
|
|
|
|
|
+
|
|
|
|
|
+ event = adapter.handle_message.call_args.args[0]
|
|
|
|
|
+ assert event.text == "see attached"
|
|
|
|
|
+ assert event.media_urls == []
|
|
|
|
|
+ assert event.message_type == MessageType.TEXT
|
|
|
|
|
+
|
|
|
|
|
+ async def test_attachment_without_asset_url_is_skipped(self, adapter):
|
|
|
|
|
+ """Videos are announced before transcoding finishes."""
|
|
|
|
|
+ payload = _make_posted_payload()
|
|
|
|
|
+ att = _make_attachment("clip.mp4", "video/mp4")
|
|
|
|
|
+ att.asset_url = None
|
|
|
|
|
+ payload.fetch_message = AsyncMock(return_value=_make_message(
|
|
|
|
|
+ body="clip", attachments=[att],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ await adapter._dispatch_message_posted(payload)
|
|
|
|
|
+
|
|
|
|
|
+ event = adapter.handle_message.call_args.args[0]
|
|
|
|
|
+ assert event.media_urls == []
|
|
|
|
|
+ adapter._download_attachment_bytes.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_document_wins_over_image(self, adapter):
|
|
|
|
|
+ """Mixed batches classify as DOCUMENT — that gates context injection."""
|
|
|
|
|
+ assert adapter._message_type_for_media_kinds(["image", "document"]) is MessageType.DOCUMENT
|
|
|
|
|
+ assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO
|
|
|
|
|
+ assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO
|
|
|
|
|
+ assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO
|
|
|
|
|
+ assert adapter._message_type_for_media_kinds([]) is MessageType.TEXT
|
|
|
|
|
+
|
|
|
|
|
+ async def test_oversized_attachment_is_rejected(self, adapter):
|
|
|
|
|
+ """The gateway media cap must bound what a hostile upload can buffer."""
|
|
|
|
|
+ import httpx
|
|
|
|
|
+
|
|
|
|
|
+ big = get_inbound_media_max_bytes() + 1
|
|
|
|
|
+ transport = httpx.MockTransport(lambda request: httpx.Response(
|
|
|
|
|
+ 200, headers={"content-length": str(big)}, content=b"x",
|
|
|
|
|
+ ))
|
|
|
|
|
+ real_adapter = _make_adapter()
|
|
|
|
|
+ real_client_cls = httpx.AsyncClient
|
|
|
|
|
+
|
|
|
|
|
+ with patch("httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)):
|
|
|
|
|
+ with pytest.raises(ValueError):
|
|
|
|
|
+ await real_adapter._download_attachment_bytes("https://chat.example.com/a.png")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# NOTE: there are deliberately no tests for get_user(), set_presence() or
|
|
|
|
|
+# set_custom_status() on the adapter. Those are not adapter responsibilities —
|
|
|
|
|
+# callers use the chattolib client directly, which exposes them (client.get_user,
|
|
|
|
|
+# client.update_presence, client.update_custom_status). The adapter only touches
|
|
|
|
|
+# presence in connect()/disconnect().
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- Room operations --
|
|
# -- Room operations --
|
|
@@ -419,44 +784,33 @@ class TestRoomOperations:
|
|
|
cfg = _make_config()
|
|
cfg = _make_config()
|
|
|
adapter = ChattoAdapter(cfg)
|
|
adapter = ChattoAdapter(cfg)
|
|
|
adapter._chatto_client = MagicMock()
|
|
adapter._chatto_client = MagicMock()
|
|
|
|
|
+ # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
|
|
|
|
|
+ # plain MagicMock raises TypeError, which create_room()/start_dm()
|
|
|
|
|
+ # swallow into a None return.
|
|
|
|
|
+ adapter._chatto_client.create_room = AsyncMock()
|
|
|
|
|
+ adapter._chatto_client.start_dm = AsyncMock()
|
|
|
adapter._token = "test-token"
|
|
adapter._token = "test-token"
|
|
|
adapter._room_names = {}
|
|
adapter._room_names = {}
|
|
|
adapter._room_kinds = {}
|
|
adapter._room_kinds = {}
|
|
|
return adapter
|
|
return adapter
|
|
|
|
|
|
|
|
async def test_create_room(self, adapter):
|
|
async def test_create_room(self, adapter):
|
|
|
- # Import chattolib types for testing (try vendored first)
|
|
|
|
|
- try:
|
|
|
|
|
- from chattolib_vendor.chattolib.types import Room
|
|
|
|
|
- except ImportError:
|
|
|
|
|
- try:
|
|
|
|
|
- from chattolib.types import Room
|
|
|
|
|
- except ImportError:
|
|
|
|
|
- from unittest.mock import MagicMock
|
|
|
|
|
- Room = MagicMock
|
|
|
|
|
- mock_room = Room(id="room-123", name="Test Room", kind="ROOM_KIND_GROUP",
|
|
|
|
|
- description="", archived=False, group_id="", universal=True)
|
|
|
|
|
- adapter._chatto_client.create_room.return_value = mock_room
|
|
|
|
|
|
|
+ adapter._chatto_client.create_room.return_value = _make_room(
|
|
|
|
|
+ "room-123", "Test Room", RoomKind.CHANNEL,
|
|
|
|
|
+ )
|
|
|
result = await adapter.create_room("Test Room", "A test room")
|
|
result = await adapter.create_room("Test Room", "A test room")
|
|
|
assert result == "room-123"
|
|
assert result == "room-123"
|
|
|
adapter._chatto_client.create_room.assert_called_once()
|
|
adapter._chatto_client.create_room.assert_called_once()
|
|
|
|
|
+ assert adapter._room_names["room-123"] == "Test Room"
|
|
|
|
|
|
|
|
async def test_start_dm(self, adapter):
|
|
async def test_start_dm(self, adapter):
|
|
|
- # Import chattolib types for testing (try vendored first)
|
|
|
|
|
- try:
|
|
|
|
|
- from chattolib_vendor.chattolib.types import Room
|
|
|
|
|
- except ImportError:
|
|
|
|
|
- try:
|
|
|
|
|
- from chattolib.types import Room
|
|
|
|
|
- except ImportError:
|
|
|
|
|
- from unittest.mock import MagicMock
|
|
|
|
|
- Room = MagicMock
|
|
|
|
|
- mock_room = Room(id="dm-123", name="DM with user", kind="ROOM_KIND_DM",
|
|
|
|
|
- description="", archived=False, group_id="", universal=False)
|
|
|
|
|
- adapter._chatto_client.start_dm.return_value = mock_room
|
|
|
|
|
|
|
+ adapter._chatto_client.start_dm.return_value = _make_room(
|
|
|
|
|
+ "dm-123", "DM with user", RoomKind.DM,
|
|
|
|
|
+ )
|
|
|
result = await adapter.start_dm("user-123")
|
|
result = await adapter.start_dm("user-123")
|
|
|
assert result == "dm-123"
|
|
assert result == "dm-123"
|
|
|
adapter._chatto_client.start_dm.assert_called_once()
|
|
adapter._chatto_client.start_dm.assert_called_once()
|
|
|
|
|
+ assert adapter._room_kinds["dm-123"] == RoomKind.DM
|
|
|
|
|
|
|
|
|
|
|
|
|
# -- Constants --
|
|
# -- Constants --
|