|
@@ -38,10 +38,24 @@ from adapter import (
|
|
|
hermes_validate_config as validate_config,
|
|
hermes_validate_config as validate_config,
|
|
|
register,
|
|
register,
|
|
|
)
|
|
)
|
|
|
-from chattolib.types import Room, RoomKind
|
|
|
|
|
|
|
+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
|
|
@@ -114,6 +128,47 @@ def _make_room(room_id, name, kind):
|
|
|
archived=False, group_id="", universal=kind != RoomKind.DM)
|
|
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()
|
|
@@ -339,6 +394,114 @@ class TestMessageEditing:
|
|
|
adapter._chatto_client.delete_message.assert_called_once()
|
|
adapter._chatto_client.delete_message.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# -- 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
|
|
# NOTE: there are deliberately no tests for get_user(), set_presence() or
|
|
|
# set_custom_status() on the adapter. Those are not adapter responsibilities —
|
|
# set_custom_status() on the adapter. Those are not adapter responsibilities —
|
|
|
# callers use the chattolib client directly, which exposes them (client.get_user,
|
|
# callers use the chattolib client directly, which exposes them (client.get_user,
|