test_adapter.py 57 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447
  1. """Unit tests for the Chatto platform adapter.
  2. Covers:
  3. - Emoji shortcode conversion
  4. - Adapter instantiation and properties
  5. - Registration and requirements
  6. - Basic functionality with chattolib
  7. - Message sending and reactions
  8. - User lookup (with caching)
  9. - Presence and custom status
  10. - DM room management (/join, /leave)
  11. All network calls are mocked — no real HTTP or WebSocket connections.
  12. """
  13. import asyncio
  14. import os
  15. import sys
  16. import tempfile
  17. from unittest.mock import AsyncMock, MagicMock, patch, call
  18. from collections import OrderedDict
  19. import pytest
  20. import pytest_asyncio
  21. # Import chattolib types for tests - using vendored chattolib from adapter
  22. # -- Path setup --
  23. # The Hermes agent itself is not a dependency of this plugin; point HERMES_ROOT
  24. # at a checkout to run these tests outside a deployed agent.
  25. PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
  26. sys.path.insert(0, PLUGIN_ROOT)
  27. sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
  28. sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
  29. from adapter import (
  30. ChattoAdapter,
  31. _capabilities,
  32. HermesChatType,
  33. chat_type_for_room_kind,
  34. hermes_check_fn as check_requirements,
  35. hermes_validate_config as validate_config,
  36. register,
  37. )
  38. from chattolib.realtime_types import ReactionPayload
  39. from chattolib.types import (
  40. Asset,
  41. DirectoryMember,
  42. AssetUpload,
  43. AssetUrl,
  44. Message,
  45. MessageAttachment,
  46. PresenceStatus,
  47. Room,
  48. RoomKind,
  49. RoomViewerState,
  50. RoomWithViewerState,
  51. User,
  52. )
  53. from platform_config import ChattoConstants
  54. from gateway.config import PlatformConfig
  55. from gateway.platforms.base import (
  56. BasePlatformAdapter,
  57. CachedMedia,
  58. MessageEvent,
  59. MessageType,
  60. SendResult,
  61. get_inbound_media_max_bytes,
  62. )
  63. _EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
  64. _MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
  65. _SEEN_CAP = ChattoConstants.SEEN_CAP
  66. # -- Helpers --
  67. class _MockPluginContext:
  68. """Minimal mock for the plugin registration context."""
  69. def __init__(self):
  70. self.registered_names = []
  71. self.registered_kwargs = None
  72. def register_platform(self, **kwargs):
  73. from gateway.platform_registry import platform_registry, PlatformEntry
  74. entry = PlatformEntry(
  75. name=kwargs["name"],
  76. label=kwargs.get("label", kwargs["name"]),
  77. adapter_factory=kwargs.get("adapter_factory"),
  78. check_fn=kwargs.get("check_fn"),
  79. validate_config=kwargs.get("validate_config"),
  80. is_connected=kwargs.get("is_connected"),
  81. required_env=kwargs.get("required_env", []),
  82. source="plugin",
  83. )
  84. platform_registry.register(entry)
  85. self.registered_names.append(kwargs["name"])
  86. self.registered_kwargs = kwargs
  87. def _ensure_chatto_registered():
  88. """Register the platform so Platform(PLATFORM_NAME) resolves."""
  89. from gateway.platform_registry import platform_registry
  90. if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
  91. ctx = _MockPluginContext()
  92. register(ctx)
  93. _CHATTO_ENV_KEYS = [
  94. "CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
  95. "CHATTO_HOME_CHANNEL",
  96. "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
  97. "CHATTO_ALLOW_ALL_USERS", "CHATTO_AUTO_THREAD",
  98. "CHATTO_REACTIONS",
  99. ]
  100. def _clear_chatto_env(monkeypatch=None):
  101. """Remove all CHATTO_* env vars so tests start from a clean slate."""
  102. for key in _CHATTO_ENV_KEYS:
  103. if monkeypatch is not None:
  104. monkeypatch.delenv(key, raising=False)
  105. else:
  106. os.environ.pop(key, None)
  107. def _make_config(**extra_overrides):
  108. """Create a minimal PlatformConfig for testing."""
  109. _ensure_chatto_registered()
  110. extra = {"base_url": "https://chat.example.com"}
  111. extra.update(extra_overrides)
  112. return PlatformConfig(enabled=True, extra=extra)
  113. def _make_room(room_id, name, kind):
  114. """Build a real chattolib Room, as the client would return."""
  115. return Room(id=room_id, name=name, kind=kind, description="",
  116. archived=False, group_id="", universal=kind != RoomKind.DM)
  117. def _make_user(user_id, login):
  118. """Build a real chattolib User, as the member directory would return."""
  119. return User(id=user_id, login=login, display_name=login.replace("_", " ").title())
  120. def _make_attachment(filename, content_type, url="https://cdn.example.com/a"):
  121. """Build a real MessageAttachment carrying a (pre-signed) asset URL."""
  122. return MessageAttachment(
  123. id="asset-" + filename,
  124. filename=filename,
  125. content_type=content_type,
  126. asset_url=AssetUrl(url=url),
  127. )
  128. def _make_message(body="hi", attachments=None, message_id="msg-1", room_id="room-1"):
  129. """Build a real chattolib Message, as fetch_message() would return."""
  130. return Message(
  131. id=message_id,
  132. room_id=room_id,
  133. created_at=None,
  134. actor_id="user-1",
  135. body=body,
  136. attachments=list(attachments or []),
  137. )
  138. def _make_posted_payload(room_id="room-1", message_event_id="msg-1"):
  139. """A message_posted payload whose fetch_message() the caller stubs."""
  140. payload = MagicMock()
  141. payload.room_id = room_id
  142. payload.message_event_id = message_event_id
  143. payload.thread_root_event_id = None
  144. return payload
  145. def _cached(path, media_type, kind):
  146. """The CachedMedia that cache_media_bytes() would return for an attachment."""
  147. return CachedMedia(path=path, media_type=media_type, kind=kind, display_name="f")
  148. def _make_adapter(**extra_overrides):
  149. """Create a ChattoAdapter with mocked config."""
  150. _clear_chatto_env()
  151. cfg = _make_config(**extra_overrides)
  152. adapter = ChattoAdapter(cfg)
  153. adapter._chatto_client = MagicMock()
  154. adapter._token = "test-token"
  155. adapter._user_id = "bot-user-id"
  156. adapter._user_login = "hermes_bot"
  157. adapter._user_display = "Hermes Bot"
  158. return adapter
  159. # -- Emoji shortcode conversion --
  160. class TestEmojiShortcode:
  161. """Test emoji to shortcode mapping."""
  162. def test_emoji_to_shortcode_exists(self):
  163. assert isinstance(_EMOJI_TO_SHORTCODE, dict)
  164. assert len(_EMOJI_TO_SHORTCODE) > 0
  165. def test_emoji_to_shortcode_common_emojis(self):
  166. assert _EMOJI_TO_SHORTCODE.get("👍") == "thumbsup"
  167. assert _EMOJI_TO_SHORTCODE.get("👎") == "thumbsdown"
  168. assert _EMOJI_TO_SHORTCODE.get("❤️") == "heart"
  169. assert _EMOJI_TO_SHORTCODE.get("❤") == "heart"
  170. assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark"
  171. assert _EMOJI_TO_SHORTCODE.get("❌") == "x"
  172. # Sent when a message addresses someone else — without the mapping the
  173. # raw emoji would go out as a shortcode and the server would reject it.
  174. assert _EMOJI_TO_SHORTCODE.get("🫥") == "dotted_line_face"
  175. # -- Adapter instantiation and properties --
  176. class TestAdapterInstantiation:
  177. """Test ChattoAdapter creation and basic properties."""
  178. def test_adapter_creation(self):
  179. cfg = _make_config()
  180. adapter = ChattoAdapter(cfg)
  181. assert adapter is not None
  182. # Platform members created dynamically from a plugin name carry the
  183. # name upper-cased; the registered identity is the value.
  184. assert adapter.platform.value == ChattoConstants.PLATFORM_NAME
  185. def test_adapter_max_message_length(self):
  186. """The framework chunks via max_message_length_for_chat(), which reads
  187. the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096
  188. when it is missing."""
  189. cfg = _make_config()
  190. adapter = ChattoAdapter(cfg)
  191. assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
  192. assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH
  193. def test_adapter_splits_long_messages(self):
  194. cfg = _make_config()
  195. adapter = ChattoAdapter(cfg)
  196. assert adapter.splits_long_messages is True
  197. def test_adapter_threads_enabled_by_default(self):
  198. """There is no capability flag for threads — Chatto threading is driven
  199. by the auto_thread setting, which defaults to on."""
  200. cfg = _make_config()
  201. adapter = ChattoAdapter(cfg)
  202. assert adapter.chatto_config.auto_thread.value is True
  203. # -- Registration and requirements --
  204. class TestRegistration:
  205. """Test plugin registration."""
  206. def test_register_called(self):
  207. ctx = _MockPluginContext()
  208. register(ctx)
  209. assert ChattoConstants.PLATFORM_NAME in ctx.registered_names
  210. assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME
  211. assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL
  212. assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH
  213. def test_platform_hint_advertises_media_sending(self):
  214. """This hint is the only thing telling the model the channel can carry
  215. files — without it the agent has no idea it can deliver an image."""
  216. ctx = _MockPluginContext()
  217. register(ctx)
  218. assert "MEDIA:/absolute/path/to/file" in ctx.registered_kwargs["platform_hint"]
  219. def test_platform_hint_rules_out_markdown_for_local_files(self):
  220. """gateway's extract_images only matches https?:// — markdown pointing at
  221. a local file is never extracted and lands in the chat as literal text."""
  222. ctx = _MockPluginContext()
  223. register(ctx)
  224. hint = ctx.registered_kwargs["platform_hint"]
  225. assert "Do NOT use markdown image syntax for local files" in hint
  226. def test_startup_logs_the_capabilities(self, caplog):
  227. with caplog.at_level("INFO"):
  228. register(_MockPluginContext())
  229. logged = "\n".join(caplog.messages)
  230. assert "images" in logged
  231. assert "reactions" in logged
  232. def test_capabilities_skips_methods_we_do_not_override(self):
  233. """An inherited base fallback is not a capability — claiming it would
  234. promise the user something the adapter cannot actually do."""
  235. assert "video" in _capabilities()
  236. with patch.object(ChattoAdapter, "send_video", BasePlatformAdapter.send_video):
  237. assert "video" not in _capabilities()
  238. def test_check_requirements(self):
  239. assert check_requirements() is True
  240. def test_check_requirements_missing(self):
  241. with patch("builtins.__import__", side_effect=ImportError("no chattolib")):
  242. assert check_requirements() is False
  243. def test_validate_config(self):
  244. _clear_chatto_env()
  245. os.environ["CHATTO_BASE_URL"] = "https://chat.test"
  246. os.environ["CHATTO_LOGIN"] = "user"
  247. os.environ["CHATTO_PASSWORD"] = "pass"
  248. cfg = PlatformConfig(enabled=True, extra={"base_url": "https://chat.test"})
  249. assert validate_config(cfg) is True
  250. _clear_chatto_env()
  251. # -- Send functionality --
  252. class TestSend:
  253. """Test message sending functionality."""
  254. @pytest_asyncio.fixture
  255. def adapter(self):
  256. _clear_chatto_env()
  257. cfg = _make_config()
  258. adapter = ChattoAdapter(cfg)
  259. adapter._chatto_client = MagicMock()
  260. adapter._chatto_client.post_message = AsyncMock()
  261. adapter._token = "test-token"
  262. adapter._user_id = "bot-user-id"
  263. return adapter
  264. async def test_send_calls_post_message(self, adapter):
  265. mock_msg = MagicMock()
  266. mock_msg.id = "msg-123"
  267. adapter._chatto_client.post_message.return_value = mock_msg
  268. result = await adapter.send("room-1", "Hello world")
  269. assert result.success is True
  270. assert result.message_id == "msg-123"
  271. adapter._chatto_client.post_message.assert_called_once()
  272. async def test_send_with_thread(self, adapter):
  273. mock_msg = MagicMock()
  274. mock_msg.id = "msg-456"
  275. adapter._chatto_client.post_message.return_value = mock_msg
  276. result = await adapter.send("room-1", "Hello", reply_to="thread-123")
  277. assert result.success is True
  278. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  279. assert call_kwargs["thread_root_event_id"] == "thread-123"
  280. async def test_send_raw_response_stays_dict_shaped(self, adapter):
  281. # The cron scheduler calls .get() on SendResult.raw_response when a job
  282. # targets a thread; a chattolib Message there crashes delivery
  283. # bookkeeping after the send and duplicates the message standalone.
  284. adapter._chatto_client.post_message.return_value = _make_message(
  285. body="Hello world", message_id="msg-789"
  286. )
  287. result = await adapter.send("room-1", "Hello world")
  288. assert result.success is True
  289. assert not result.raw_response or isinstance(result.raw_response, dict)
  290. # -- Reactions --
  291. class TestReactions:
  292. """Test reaction functionality."""
  293. @pytest_asyncio.fixture
  294. def adapter(self):
  295. _clear_chatto_env()
  296. cfg = _make_config()
  297. adapter = ChattoAdapter(cfg)
  298. adapter._chatto_client = MagicMock()
  299. adapter._chatto_client.add_reaction = AsyncMock()
  300. adapter._chatto_client.remove_reaction = AsyncMock()
  301. adapter._token = "test-token"
  302. return adapter
  303. async def test_send_reaction(self, adapter):
  304. await adapter.add_reaction("room-1", "msg-1", "👍")
  305. adapter._chatto_client.add_reaction.assert_called_once()
  306. async def test_remove_reaction(self, adapter):
  307. await adapter.remove_reaction("room-1", "msg-1", "👍")
  308. adapter._chatto_client.remove_reaction.assert_called_once()
  309. async def test_on_processing_start_adds_eyes_reaction(self, adapter):
  310. """on_processing_start should call add_reaction with 👀."""
  311. event = MagicMock()
  312. event.message_id = "msg-1"
  313. event.source.chat_id = "room-1"
  314. await adapter.on_processing_start(event)
  315. adapter._chatto_client.add_reaction.assert_called_once()
  316. call_kwargs = adapter._chatto_client.add_reaction.call_args.kwargs
  317. assert call_kwargs["message_event_id"] == "msg-1"
  318. assert call_kwargs["room_id"] == "room-1"
  319. assert call_kwargs["emoji"] == "eyes"
  320. async def test_on_processing_start_empty_message_id(self, adapter):
  321. """on_processing_start should skip reaction when message_id is empty."""
  322. event = MagicMock()
  323. event.message_id = None
  324. event.source.chat_id = "room-1"
  325. await adapter.on_processing_start(event)
  326. adapter._chatto_client.add_reaction.assert_not_called()
  327. async def test_on_processing_start_reactions_disabled(self, adapter):
  328. """on_processing_start should skip when reactions config is False."""
  329. adapter.chatto_config.reactions.value = False
  330. event = MagicMock()
  331. event.message_id = "msg-1"
  332. event.source.chat_id = "room-1"
  333. await adapter.on_processing_start(event)
  334. adapter._chatto_client.add_reaction.assert_not_called()
  335. # -- Edit and Delete Messages --
  336. class TestMessageEditing:
  337. """Test message editing and deletion."""
  338. @pytest_asyncio.fixture
  339. def adapter(self):
  340. _clear_chatto_env()
  341. cfg = _make_config()
  342. adapter = ChattoAdapter(cfg)
  343. adapter._chatto_client = MagicMock()
  344. adapter._chatto_client.update_message = AsyncMock()
  345. adapter._chatto_client.delete_message = AsyncMock(return_value=True)
  346. adapter._token = "test-token"
  347. return adapter
  348. async def test_edit_message(self, adapter):
  349. result = await adapter.edit_message("room-1", "msg-1", "New content")
  350. assert result.success is True
  351. adapter._chatto_client.update_message.assert_called_once()
  352. call_kwargs = adapter._chatto_client.update_message.call_args.kwargs
  353. assert call_kwargs["room_id"] == "room-1"
  354. assert call_kwargs["event_id"] == "msg-1"
  355. assert call_kwargs["body"] == "New content"
  356. async def test_edit_message_marks_own_edit_seen(self, adapter):
  357. """The edit echoes back as message_edited — it must not look inbound."""
  358. mock_msg = MagicMock()
  359. mock_msg.id = "msg-1"
  360. adapter._chatto_client.update_message.return_value = mock_msg
  361. await adapter.edit_message("room-1", "msg-1", "New content")
  362. assert adapter._is_seen("msg-1") is True
  363. async def test_edit_message_too_long_refuses(self, adapter):
  364. """Overlong content must fall back to send() (which splits), not be
  365. silently truncated into a lossy edit."""
  366. result = await adapter.edit_message(
  367. "room-1", "msg-1", "x" * (_MAX_MESSAGE_LENGTH + 1),
  368. )
  369. assert result.success is False
  370. adapter._chatto_client.update_message.assert_not_called()
  371. async def test_edit_message_empty_content(self, adapter):
  372. result = await adapter.edit_message("room-1", "msg-1", "")
  373. assert result.success is False
  374. adapter._chatto_client.update_message.assert_not_called()
  375. async def test_edit_message_error_is_retryable(self, adapter):
  376. from chattolib.exceptions import ChattoError
  377. adapter._chatto_client.update_message.side_effect = ChattoError("boom")
  378. result = await adapter.edit_message("room-1", "msg-1", "New content")
  379. assert result.success is False
  380. assert result.retryable is True
  381. async def test_delete_message(self, adapter):
  382. result = await adapter.delete_message("room-1", "msg-1")
  383. assert result is True
  384. adapter._chatto_client.delete_message.assert_called_once()
  385. call_kwargs = adapter._chatto_client.delete_message.call_args.kwargs
  386. assert call_kwargs["room_id"] == "room-1"
  387. assert call_kwargs["event_id"] == "msg-1"
  388. async def test_delete_message_missing_ids(self, adapter):
  389. assert await adapter.delete_message("", "msg-1") is False
  390. assert await adapter.delete_message("room-1", "") is False
  391. adapter._chatto_client.delete_message.assert_not_called()
  392. async def test_delete_message_error_returns_false(self, adapter):
  393. from chattolib.exceptions import ChattoError
  394. adapter._chatto_client.delete_message.side_effect = ChattoError("nope")
  395. assert await adapter.delete_message("room-1", "msg-1") is False
  396. # -- Outgoing text formatting --
  397. class TestFormatMessage:
  398. """format_message() only fixes what renders wrong in Chatto."""
  399. def test_normalises_crlf(self):
  400. adapter = _make_adapter()
  401. assert adapter.format_message("a\r\nb\rc") == "a\nb\nc"
  402. def test_collapses_excess_blank_lines(self):
  403. adapter = _make_adapter()
  404. assert adapter.format_message("a\n\n\n\n\n\nb") == "a\n\n\nb"
  405. def test_leaves_markdown_untouched(self):
  406. adapter = _make_adapter()
  407. text = "**bold** `code`\n\n```py\nx = 1\n```\n- item"
  408. assert adapter.format_message(text) == text
  409. def test_empty_content(self):
  410. adapter = _make_adapter()
  411. assert adapter.format_message("") == ""
  412. # -- Handoff threads --
  413. class TestHandoffThread:
  414. """create_handoff_thread() anchors a handoff on a seed message."""
  415. @pytest_asyncio.fixture
  416. def adapter(self):
  417. adapter = _make_adapter()
  418. adapter._chatto_client.post_message = AsyncMock()
  419. adapter._chatto_client.follow_thread = AsyncMock()
  420. return adapter
  421. async def test_returns_seed_message_id(self, adapter):
  422. mock_msg = MagicMock()
  423. mock_msg.id = "seed-1"
  424. adapter._chatto_client.post_message.return_value = mock_msg
  425. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  426. result = await adapter.create_handoff_thread("room-1", "Refactor run")
  427. assert result == "seed-1"
  428. assert adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
  429. adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1")
  430. # Our own seed must not come back in as inbound traffic.
  431. assert adapter._is_seen("seed-1") is True
  432. async def test_dm_has_no_threads(self, adapter):
  433. adapter._room_kinds["dm-1"] = RoomKind.DM
  434. assert await adapter.create_handoff_thread("dm-1", "x") is None
  435. adapter._chatto_client.post_message.assert_not_called()
  436. async def test_seed_post_failure(self, adapter):
  437. adapter._chatto_client.post_message.side_effect = RuntimeError("down")
  438. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  439. assert await adapter.create_handoff_thread("room-1", "x") is None
  440. # -- Native file / video / audio delivery --
  441. class TestUploadAsset:
  442. """Drives the real _upload_asset against real chattolib result types.
  443. The send_* tests stub _upload_asset out, so a wrong field name on the
  444. chattolib response was invisible to them until it hit a live server.
  445. """
  446. @pytest_asyncio.fixture
  447. def adapter(self, tmp_path):
  448. adapter = _make_adapter()
  449. self.path = tmp_path / "horse.jpg"
  450. self.path.write_bytes(b"\xff\xd8\xff" + b"x" * 100)
  451. upload = AssetUpload(upload_id="up-1", room_id="room-1")
  452. adapter._chatto_client.create_upload = AsyncMock(return_value=upload)
  453. adapter._chatto_client.upload_chunk = AsyncMock(return_value=upload)
  454. adapter._chatto_client.complete_upload = AsyncMock(return_value=(
  455. upload,
  456. Asset(id="asset-9", filename="horse.jpg", content_type="image/jpeg", size=103),
  457. ))
  458. return adapter
  459. async def test_returns_the_asset_id(self, adapter):
  460. assert await adapter._upload_asset("room-1", str(self.path)) == "asset-9"
  461. async def test_chunks_go_to_the_upload_id_from_create_upload(self, adapter):
  462. """AssetUpload calls it upload_id, not id — reading the wrong field made
  463. every upload fail with 'CreateUpload returned no upload ID'."""
  464. await adapter._upload_asset("room-1", str(self.path))
  465. assert adapter._chatto_client.upload_chunk.await_args.kwargs["upload_id"] == "up-1"
  466. async def test_missing_upload_id_is_reported(self, adapter):
  467. adapter._chatto_client.create_upload = AsyncMock(
  468. return_value=AssetUpload(upload_id="", room_id="room-1"))
  469. assert await adapter._upload_asset("room-1", str(self.path)) is None
  470. class TestNativeSends:
  471. """send_document/_video/_voice upload instead of apologising in text."""
  472. @pytest_asyncio.fixture
  473. def adapter(self):
  474. adapter = _make_adapter()
  475. adapter._chatto_client.post_message = AsyncMock()
  476. adapter._upload_asset = AsyncMock(return_value="asset-1")
  477. adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
  478. mock_msg = MagicMock()
  479. mock_msg.id = "msg-1"
  480. adapter._chatto_client.post_message.return_value = mock_msg
  481. return adapter
  482. @pytest.mark.parametrize(
  483. "method,arg_name",
  484. [
  485. ("send_document", "file_path"),
  486. ("send_video", "video_path"),
  487. ("send_voice", "audio_path"),
  488. ("send_image_file", "image_path"),
  489. ],
  490. )
  491. async def test_uploads_and_attaches(self, adapter, method, arg_name):
  492. result = await getattr(adapter, method)(
  493. "room-1", **{arg_name: "/tmp/thing.bin"}, caption="here you go",
  494. )
  495. assert result.success is True
  496. adapter._upload_asset.assert_called_once_with("room-1", "/tmp/thing.bin")
  497. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  498. assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
  499. assert call_kwargs["body"] == "here you go"
  500. async def test_unsafe_path_falls_back_to_notice(self, adapter):
  501. adapter.validate_media_delivery_path = MagicMock(return_value=None)
  502. adapter.send = AsyncMock(return_value=SendResult(success=True))
  503. await adapter.send_document("room-1", "/etc/shadow")
  504. adapter._upload_asset.assert_not_called()
  505. # Never echo the host path into chat.
  506. sent_text = adapter.send.call_args.args[1]
  507. assert "/etc/shadow" not in sent_text
  508. async def test_upload_failure_falls_back_to_notice(self, adapter):
  509. adapter._upload_asset = AsyncMock(return_value=None)
  510. adapter.send = AsyncMock(return_value=SendResult(success=True))
  511. await adapter.send_video("room-1", "/tmp/clip.mp4", caption="a clip")
  512. sent_text = adapter.send.call_args.args[1]
  513. assert sent_text.startswith("a clip\n")
  514. assert "/tmp/clip.mp4" not in sent_text
  515. # -- Batched image delivery --
  516. class TestSendMultipleImages:
  517. """A batch of images belongs in ONE Chatto message."""
  518. @pytest_asyncio.fixture
  519. def adapter(self):
  520. adapter = _make_adapter()
  521. adapter._chatto_client.post_message = AsyncMock()
  522. mock_msg = MagicMock()
  523. mock_msg.id = "msg-1"
  524. adapter._chatto_client.post_message.return_value = mock_msg
  525. adapter._upload_asset = AsyncMock(side_effect=["asset-1", "asset-2"])
  526. adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
  527. return adapter
  528. async def test_bundles_into_single_message(self, adapter):
  529. await adapter.send_multiple_images(
  530. "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
  531. )
  532. adapter._chatto_client.post_message.assert_called_once()
  533. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  534. assert call_kwargs["attachment_asset_ids"] == ["asset-1", "asset-2"]
  535. assert call_kwargs["body"] == "first\nsecond"
  536. async def test_single_image_uses_base_path(self, adapter):
  537. """One image is not a batch — leave it to the base implementation.
  538. Also pins the send_image_file signature: the base class calls it with
  539. ``image_path=`` as a keyword, so a renamed parameter degrades every
  540. native image send to a text notice.
  541. """
  542. adapter.send_image_file = AsyncMock(return_value=SendResult(success=True))
  543. await adapter.send_multiple_images("room-1", [("file:///tmp/a.png", "only")])
  544. adapter._upload_asset.assert_not_called()
  545. adapter.send_image_file.assert_called_once()
  546. assert adapter.send_image_file.call_args.kwargs["image_path"] == "/tmp/a.png"
  547. async def test_partial_upload_failure_still_sends_the_rest(self, adapter):
  548. adapter._upload_asset = AsyncMock(side_effect=["asset-1", None])
  549. await adapter.send_multiple_images(
  550. "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
  551. )
  552. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  553. assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
  554. async def test_file_uri_is_unquoted(self, adapter):
  555. await adapter.send_multiple_images(
  556. "room-1",
  557. [("file:///tmp/a%20b.png", ""), ("/tmp/c.png", "")],
  558. )
  559. first_path = adapter._upload_asset.call_args_list[0].args[1]
  560. assert first_path == "/tmp/a b.png"
  561. # -- Reaction event forwarding --
  562. class TestReactionForwarding:
  563. """Human reactions reach the gateway's reaction hook surface."""
  564. @pytest_asyncio.fixture
  565. def adapter(self):
  566. adapter = _make_adapter()
  567. adapter.me = _make_user("bot-user-id", "hermes_bot")
  568. return adapter
  569. def _event(self, kind, actor_id="human-1"):
  570. event = MagicMock()
  571. event.id = "evt-1"
  572. event.kind = kind
  573. event.actor_id = actor_id
  574. payload = ReactionPayload(
  575. room_id="room-1", message_event_id="msg-1", emoji="thumbsup",
  576. )
  577. # RealtimeEvent.get() only yields the payload for its own kind.
  578. event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
  579. return event
  580. async def test_forwards_added_reaction(self, adapter):
  581. handler = AsyncMock()
  582. adapter.set_reaction_handler(handler)
  583. await adapter._handle_realtime_event(self._event("reaction_added"))
  584. handler.assert_called_once()
  585. payload = handler.call_args.args[0]
  586. assert payload["event_name"] == "reaction:added"
  587. assert payload["reaction"] == "thumbsup"
  588. assert payload["channel_id"] == "room-1"
  589. assert payload["message_ts"] == "msg-1"
  590. assert payload["user_id"] == "human-1"
  591. assert payload["item_type"] == "message"
  592. async def test_forwards_removed_reaction(self, adapter):
  593. handler = AsyncMock()
  594. adapter.set_reaction_handler(handler)
  595. await adapter._handle_realtime_event(self._event("reaction_removed"))
  596. assert handler.call_args.args[0]["event_name"] == "reaction:removed"
  597. async def test_ignores_own_lifecycle_reactions(self, adapter):
  598. """👀/✅/❌ are ours — forwarding them would feed the agent its own markers."""
  599. handler = AsyncMock()
  600. adapter.set_reaction_handler(handler)
  601. await adapter._handle_realtime_event(
  602. self._event("reaction_added", actor_id="bot-user-id"),
  603. )
  604. handler.assert_not_called()
  605. async def test_no_handler_registered_is_harmless(self, adapter):
  606. await adapter._handle_realtime_event(self._event("reaction_added"))
  607. async def test_handler_exception_does_not_propagate(self, adapter):
  608. adapter.set_reaction_handler(AsyncMock(side_effect=RuntimeError("hook boom")))
  609. await adapter._handle_realtime_event(self._event("reaction_added"))
  610. # -- chat_type mapping --
  611. class TestChatTypeMapping:
  612. """RoomKind -> the gateway's chat_type vocabulary."""
  613. def test_maps_known_kinds(self):
  614. assert chat_type_for_room_kind(RoomKind.DM) is HermesChatType.DM
  615. assert chat_type_for_room_kind(RoomKind.CHANNEL) is HermesChatType.CHANNEL
  616. def test_unknown_kind_is_group_never_dm(self):
  617. """'dm' drives session isolation — never guess it for an unknown kind."""
  618. assert chat_type_for_room_kind(RoomKind.UNSPECIFIED) is HermesChatType.GROUP
  619. assert chat_type_for_room_kind(None) is HermesChatType.GROUP
  620. def test_values_match_the_gateway_vocabulary(self):
  621. """session.py:161 declares exactly these strings; SessionSource.description
  622. and the PII-redacted context prompt branch on them."""
  623. assert [t.value for t in HermesChatType] == ["dm", "group", "channel", "thread"]
  624. def test_is_a_plain_str_at_call_sites(self):
  625. assert HermesChatType.CHANNEL == "channel"
  626. assert f"{HermesChatType.DM}" == "dm"
  627. async def test_get_chat_info_reports_channel(self):
  628. adapter = _make_adapter()
  629. adapter._room_names["room-1"] = "Team"
  630. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  631. info = await adapter.get_chat_info("room-1")
  632. assert info == {"name": "Team", "type": "channel"}
  633. async def test_get_chat_info_reports_dm(self):
  634. adapter = _make_adapter()
  635. adapter._room_kinds["dm-1"] = RoomKind.DM
  636. assert (await adapter.get_chat_info("dm-1"))["type"] == "dm"
  637. async def test_dispatch_stamps_the_mapped_chat_type(self):
  638. """The value reaching build_source decides how the agent is told where
  639. it is — a raw RoomKind lands in SessionSource.description's else-branch."""
  640. adapter = _make_adapter()
  641. adapter.chatto_config.allow_all_users.value = True
  642. adapter.me = _make_user("bot-user-id", "hermes_bot")
  643. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  644. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  645. adapter.handle_message = AsyncMock()
  646. payload = _make_posted_payload()
  647. payload.fetch_message = AsyncMock(return_value=_make_message(body="hi"))
  648. await adapter._dispatch_message_posted(payload)
  649. event = adapter.handle_message.call_args.args[0]
  650. assert event.source.chat_type == "channel"
  651. # -- Presence --
  652. class TestPresence:
  653. """Presence is a server-side TTL: stop re-announcing and the bot goes offline."""
  654. def _adapter(self):
  655. adapter = _make_adapter()
  656. adapter._chatto_client.update_presence = AsyncMock()
  657. return adapter
  658. async def test_refresh_loop_keeps_reannouncing_online(self):
  659. """The bug: a single announce at connect lapses and never comes back."""
  660. adapter = self._adapter()
  661. adapter._closing = False
  662. with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
  663. task = asyncio.create_task(adapter._presence_refresh_loop())
  664. for _ in range(200):
  665. if adapter._chatto_client.update_presence.await_count >= 3:
  666. break
  667. await asyncio.sleep(0.01)
  668. adapter._closing = True
  669. task.cancel()
  670. try:
  671. await task
  672. except asyncio.CancelledError:
  673. pass
  674. assert adapter._chatto_client.update_presence.await_count >= 3
  675. for call in adapter._chatto_client.update_presence.await_args_list:
  676. assert call.kwargs["status"] == PresenceStatus.ONLINE
  677. async def test_refresh_survives_a_failing_call(self):
  678. """One bad tick must not kill the loop and strand the bot offline."""
  679. adapter = self._adapter()
  680. adapter._closing = False
  681. adapter._chatto_client.update_presence = AsyncMock(
  682. side_effect=[RuntimeError("boom"), None, None])
  683. with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
  684. task = asyncio.create_task(adapter._presence_refresh_loop())
  685. for _ in range(200):
  686. if adapter._chatto_client.update_presence.await_count >= 3:
  687. break
  688. await asyncio.sleep(0.01)
  689. adapter._closing = True
  690. task.cancel()
  691. try:
  692. await task
  693. except asyncio.CancelledError:
  694. pass
  695. assert adapter._chatto_client.update_presence.await_count >= 3
  696. async def test_announce_online_reports_failure(self):
  697. adapter = self._adapter()
  698. adapter._chatto_client.update_presence = AsyncMock(side_effect=RuntimeError("nope"))
  699. assert await adapter._announce_online() is False
  700. async def test_disconnect_does_not_broadcast_offline(self):
  701. """chattolib raises ValueError on OFFLINE — going offline means stopping."""
  702. adapter = self._adapter()
  703. adapter._chatto_client.close = AsyncMock()
  704. client = adapter._chatto_client # disconnect() drops the reference
  705. await adapter.disconnect()
  706. client.update_presence.assert_not_called()
  707. # -- Mentions of other people --
  708. class TestForeignMention:
  709. """With require_mention off the bot reads everything, so a message aimed at
  710. a named colleague would otherwise get an unsolicited answer. Acknowledge it
  711. with 🫥 and stay out of the conversation."""
  712. def _adapter(self, **overrides):
  713. adapter = _make_adapter()
  714. adapter.chatto_config.allow_all_users.value = True
  715. adapter.chatto_config.require_mention.value = False
  716. adapter.chatto_config.reactions.value = True
  717. for key, value in overrides.items():
  718. getattr(adapter.chatto_config, key).value = value
  719. adapter.me = _make_user("bot-user-id", "hermes_bot")
  720. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  721. adapter.handle_message = AsyncMock()
  722. adapter.add_reaction = AsyncMock(return_value=True)
  723. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  724. adapter._room_kinds["dm-1"] = RoomKind.DM
  725. # The directory knows bob and nobody else.
  726. adapter._chatto_client.get_user = AsyncMock(side_effect=lambda **kw: (
  727. DirectoryMember(user=_make_user("user-2", "bob"))
  728. if kw.get("login") == "bob" else None
  729. ))
  730. return adapter
  731. async def _dispatch(self, adapter, body, room_id="room-1"):
  732. payload = _make_posted_payload(room_id=room_id)
  733. payload.fetch_message = AsyncMock(
  734. return_value=_make_message(body=body, room_id=room_id))
  735. await adapter._dispatch_message_posted(payload)
  736. async def test_message_for_someone_else_is_only_acknowledged(self):
  737. adapter = self._adapter()
  738. await self._dispatch(adapter, "@bob can you take a look?")
  739. adapter.handle_message.assert_not_called()
  740. adapter.add_reaction.assert_awaited_once()
  741. assert adapter.add_reaction.await_args.args[2] == "🫥"
  742. async def test_being_mentioned_alongside_someone_else_still_answers(self):
  743. adapter = self._adapter()
  744. await self._dispatch(adapter, "@bob and @hermes_bot, thoughts?")
  745. adapter.handle_message.assert_called_once()
  746. adapter.add_reaction.assert_not_awaited()
  747. async def test_several_people_addressed_and_none_of_them_us(self):
  748. adapter = self._adapter()
  749. adapter._chatto_client.get_user = AsyncMock(side_effect=lambda **kw: (
  750. DirectoryMember(user=_make_user("u", kw["login"]))
  751. if kw.get("login") in {"bob", "carol"} else None
  752. ))
  753. await self._dispatch(adapter, "@bob @carol schaut mal drüber")
  754. adapter.handle_message.assert_not_called()
  755. adapter.add_reaction.assert_awaited_once()
  756. async def test_a_real_handle_after_an_unknown_one_still_counts(self):
  757. """The scan must not stop at the first token it cannot resolve."""
  758. adapter = self._adapter()
  759. await self._dispatch(adapter, "@nonexistent @bob schaut mal drüber")
  760. adapter.handle_message.assert_not_called()
  761. adapter.add_reaction.assert_awaited_once()
  762. async def test_being_named_among_several_others_still_answers(self):
  763. adapter = self._adapter()
  764. await self._dispatch(adapter, "@bob @hermes_bot @carol — was meint ihr?")
  765. adapter.handle_message.assert_called_once()
  766. adapter.add_reaction.assert_not_awaited()
  767. async def test_broadcast_alongside_a_named_colleague_still_answers(self):
  768. """@here keeps the bot in the audience; naming bob as well does not
  769. remove it."""
  770. adapter = self._adapter()
  771. await self._dispatch(adapter, "@here @bob schaut mal drüber")
  772. adapter.handle_message.assert_called_once()
  773. adapter.add_reaction.assert_not_awaited()
  774. async def test_broadcast_mentions_address_the_bot_too(self):
  775. adapter = self._adapter()
  776. for body in ("@here standup in 5", "@channel heads up", "@everyone hi"):
  777. adapter.handle_message.reset_mock()
  778. await self._dispatch(adapter, body)
  779. adapter.handle_message.assert_called_once()
  780. async def test_talking_about_mentions_is_not_a_mention(self):
  781. """Verbatim from the field: the instruction to send a mention later must
  782. not read as a mention now. '@-mention' is not a handle anyone holds."""
  783. adapter = self._adapter()
  784. await self._dispatch(
  785. adapter,
  786. 'Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den '
  787. 'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
  788. )
  789. adapter.handle_message.assert_called_once()
  790. adapter.add_reaction.assert_not_awaited()
  791. async def test_handle_nobody_holds_is_not_a_mention(self):
  792. """A plausible-looking @token that resolves to no user is not someone
  793. else — answering a false positive beats falling silent on one."""
  794. adapter = self._adapter()
  795. adapter._chatto_client.get_user = AsyncMock(return_value=None)
  796. await self._dispatch(adapter, "gilt das auch für @nonexistent_person?")
  797. adapter.handle_message.assert_called_once()
  798. adapter.add_reaction.assert_not_awaited()
  799. async def test_a_resolvable_handle_is_looked_up_once(self):
  800. adapter = self._adapter()
  801. await self._dispatch(adapter, "@bob ping")
  802. await self._dispatch(adapter, "@bob again")
  803. assert adapter._chatto_client.get_user.await_count == 1
  804. assert adapter.handle_message.await_count == 0
  805. async def test_an_email_address_is_not_a_mention(self):
  806. adapter = self._adapter()
  807. await self._dispatch(adapter, "schreib an bob@example.com")
  808. adapter.handle_message.assert_called_once()
  809. adapter._chatto_client.get_user.assert_not_awaited()
  810. async def test_plain_message_is_unaffected(self):
  811. adapter = self._adapter()
  812. await self._dispatch(adapter, "how do I reset the cache?")
  813. adapter.handle_message.assert_called_once()
  814. async def test_dms_are_answered_even_when_they_name_someone_else(self):
  815. adapter = self._adapter()
  816. await self._dispatch(adapter, "@bob said the build is red", room_id="dm-1")
  817. adapter.handle_message.assert_called_once()
  818. async def test_require_mention_keeps_discarding_without_a_reaction(self):
  819. """The older gate wins: it drops the message before we get here, and it
  820. deliberately says nothing at all."""
  821. adapter = self._adapter(require_mention=True)
  822. await self._dispatch(adapter, "@bob can you take a look?")
  823. adapter.handle_message.assert_not_called()
  824. adapter.add_reaction.assert_not_awaited()
  825. async def test_silence_holds_when_reactions_are_disabled(self):
  826. adapter = self._adapter(reactions=False)
  827. await self._dispatch(adapter, "@bob can you take a look?")
  828. adapter.handle_message.assert_not_called()
  829. adapter.add_reaction.assert_not_awaited()
  830. # -- require_mention --
  831. class TestRequireMention:
  832. """require_mention gates channels only — a DM is already addressed at the bot."""
  833. def _adapter(self):
  834. adapter = _make_adapter()
  835. adapter.chatto_config.allow_all_users.value = True
  836. adapter.chatto_config.require_mention.value = True
  837. adapter.me = _make_user("bot-user-id", "hermes_bot")
  838. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  839. adapter.handle_message = AsyncMock()
  840. return adapter
  841. async def _dispatch(self, adapter, room_id, body):
  842. payload = _make_posted_payload(room_id=room_id)
  843. payload.fetch_message = AsyncMock(
  844. return_value=_make_message(body=body, room_id=room_id))
  845. await adapter._dispatch_message_posted(payload)
  846. async def test_channel_without_mention_is_discarded(self):
  847. adapter = self._adapter()
  848. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  849. await self._dispatch(adapter, "room-1", "hi there")
  850. adapter.handle_message.assert_not_called()
  851. async def test_channel_with_mention_is_answered(self):
  852. adapter = self._adapter()
  853. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  854. await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
  855. adapter.handle_message.assert_called_once()
  856. async def test_dm_is_answered_without_a_mention(self):
  857. """The point of the room_kind check: require_mention must not mute DMs."""
  858. adapter = self._adapter()
  859. adapter._room_kinds["dm-1"] = RoomKind.DM
  860. await self._dispatch(adapter, "dm-1", "hi there")
  861. adapter.handle_message.assert_called_once()
  862. # -- Inbound attachments --
  863. class TestInboundAttachments:
  864. """Messages carrying files must reach the agent, body or not."""
  865. @pytest_asyncio.fixture
  866. def adapter(self):
  867. adapter = _make_adapter()
  868. adapter.chatto_config.allow_all_users.value = True
  869. adapter.me = _make_user("bot-user-id", "hermes_bot")
  870. adapter._room_kinds["room-1"] = RoomKind.DM
  871. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  872. adapter.handle_message = AsyncMock()
  873. adapter._download_attachment_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nrest")
  874. return adapter
  875. async def test_image_attachment_becomes_media_url(self, adapter):
  876. payload = _make_posted_payload()
  877. adapter._chatto_client.get_room = AsyncMock()
  878. message = _make_message(
  879. body="look at this",
  880. attachments=[_make_attachment("shot.png", "image/png")],
  881. )
  882. payload.fetch_message = AsyncMock(return_value=message)
  883. with patch("adapter.cache_media_bytes", return_value=_cached("/cache/shot.png", "image/png", "image")):
  884. await adapter._dispatch_message_posted(payload)
  885. event = adapter.handle_message.call_args.args[0]
  886. assert event.media_urls == ["/cache/shot.png"]
  887. assert event.media_types == ["image/png"]
  888. assert event.message_type == MessageType.PHOTO
  889. async def test_attachment_only_message_is_not_dropped(self, adapter):
  890. """The empty-body early return is what silently ate file uploads."""
  891. payload = _make_posted_payload()
  892. message = _make_message(
  893. body="", attachments=[_make_attachment("report.pdf", "application/pdf")],
  894. )
  895. payload.fetch_message = AsyncMock(return_value=message)
  896. with patch("adapter.cache_media_bytes", return_value=_cached("/cache/report.pdf", "application/pdf", "document")):
  897. await adapter._dispatch_message_posted(payload)
  898. adapter.handle_message.assert_called_once()
  899. event = adapter.handle_message.call_args.args[0]
  900. assert event.message_type == MessageType.DOCUMENT
  901. assert event.media_urls == ["/cache/report.pdf"]
  902. async def test_empty_message_without_attachments_is_dropped(self, adapter):
  903. payload = _make_posted_payload()
  904. payload.fetch_message = AsyncMock(return_value=_make_message(body=""))
  905. await adapter._dispatch_message_posted(payload)
  906. adapter.handle_message.assert_not_called()
  907. async def test_download_failure_still_delivers_the_text(self, adapter):
  908. adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404"))
  909. payload = _make_posted_payload()
  910. payload.fetch_message = AsyncMock(return_value=_make_message(
  911. body="see attached", attachments=[_make_attachment("a.png", "image/png")],
  912. ))
  913. await adapter._dispatch_message_posted(payload)
  914. event = adapter.handle_message.call_args.args[0]
  915. assert event.text == "see attached"
  916. assert event.media_urls == []
  917. assert event.message_type == MessageType.TEXT
  918. async def test_attachment_without_asset_url_is_skipped(self, adapter):
  919. """Videos are announced before transcoding finishes."""
  920. payload = _make_posted_payload()
  921. att = _make_attachment("clip.mp4", "video/mp4")
  922. att.asset_url = None
  923. payload.fetch_message = AsyncMock(return_value=_make_message(
  924. body="clip", attachments=[att],
  925. ))
  926. await adapter._dispatch_message_posted(payload)
  927. event = adapter.handle_message.call_args.args[0]
  928. assert event.media_urls == []
  929. adapter._download_attachment_bytes.assert_not_called()
  930. async def test_document_wins_over_image(self, adapter):
  931. """Mixed batches classify as DOCUMENT — that gates context injection."""
  932. assert adapter._message_type_for_media_kinds(["image", "document"]) is MessageType.DOCUMENT
  933. assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO
  934. assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO
  935. assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO
  936. assert adapter._message_type_for_media_kinds([]) is MessageType.TEXT
  937. async def test_oversized_attachment_is_rejected(self, adapter):
  938. """The gateway media cap must bound what a hostile upload can buffer."""
  939. import httpx
  940. big = get_inbound_media_max_bytes() + 1
  941. transport = httpx.MockTransport(lambda request: httpx.Response(
  942. 200, headers={"content-length": str(big)}, content=b"x",
  943. ))
  944. real_adapter = _make_adapter()
  945. real_client_cls = httpx.AsyncClient
  946. with patch("httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)):
  947. with pytest.raises(ValueError):
  948. await real_adapter._download_attachment_bytes("https://chat.example.com/a.png")
  949. # NOTE: there are deliberately no tests for get_user(), set_presence() or
  950. # set_custom_status() on the adapter. Those are not adapter responsibilities —
  951. # callers use the chattolib client directly, which exposes them (client.get_user,
  952. # client.update_presence, client.update_custom_status). The adapter only touches
  953. # presence in connect()/disconnect().
  954. # -- Room operations --
  955. class TestRoomOperations:
  956. """Test room creation and DM initiation."""
  957. @pytest_asyncio.fixture
  958. def adapter(self):
  959. _clear_chatto_env()
  960. cfg = _make_config()
  961. adapter = ChattoAdapter(cfg)
  962. adapter._chatto_client = MagicMock()
  963. # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
  964. # plain MagicMock raises TypeError, which create_room()/start_dm()
  965. # swallow into a None return.
  966. adapter._chatto_client.create_room = AsyncMock()
  967. adapter._chatto_client.start_dm = AsyncMock()
  968. adapter._token = "test-token"
  969. adapter._room_names = {}
  970. adapter._room_kinds = {}
  971. return adapter
  972. async def test_create_room(self, adapter):
  973. adapter._chatto_client.create_room.return_value = _make_room(
  974. "room-123", "Test Room", RoomKind.CHANNEL,
  975. )
  976. result = await adapter.create_room("Test Room", "A test room")
  977. assert result == "room-123"
  978. adapter._chatto_client.create_room.assert_called_once()
  979. assert adapter._room_names["room-123"] == "Test Room"
  980. async def test_start_dm(self, adapter):
  981. adapter._chatto_client.start_dm.return_value = _make_room(
  982. "dm-123", "DM with user", RoomKind.DM,
  983. )
  984. result = await adapter.start_dm("user-123")
  985. assert result == "dm-123"
  986. adapter._chatto_client.start_dm.assert_called_once()
  987. assert adapter._room_kinds["dm-123"] == RoomKind.DM
  988. # -- DM room management (/join, /leave) --
  989. def _make_room_state(room, is_member):
  990. """Build a RoomWithViewerState the way list_rooms()/get_room() return it."""
  991. return RoomWithViewerState(
  992. room=room, viewer_state=RoomViewerState(is_member=is_member),
  993. )
  994. class TestDmRoomCommands:
  995. """/join and /leave arrive over DMs, change server-side membership and
  996. must never reach the agent pipeline."""
  997. def _adapter(self):
  998. adapter = _make_adapter()
  999. adapter.chatto_config.allow_all_users.value = True
  1000. adapter.me = _make_user("bot-user-id", "hermes_bot")
  1001. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  1002. client = adapter._chatto_client
  1003. client.list_rooms = AsyncMock(return_value=[])
  1004. client.get_room_events = AsyncMock(return_value=MagicMock(events=[]))
  1005. client.join_room = AsyncMock()
  1006. client.leave_room = AsyncMock(return_value=True)
  1007. client.get_room = AsyncMock()
  1008. adapter._room_kinds["dm-1"] = RoomKind.DM
  1009. adapter.handle_message = AsyncMock()
  1010. adapter.send = AsyncMock()
  1011. return adapter
  1012. async def _dispatch(self, adapter, body, room_id="dm-1"):
  1013. payload = _make_posted_payload(room_id=room_id)
  1014. payload.fetch_message = AsyncMock(
  1015. return_value=_make_message(body=body, room_id=room_id))
  1016. await adapter._dispatch_message_posted(payload)
  1017. def _reply(self, adapter):
  1018. assert adapter.send.await_count == 1
  1019. return adapter.send.await_args.kwargs["content"]
  1020. async def test_join_by_name_joins_and_watches(self):
  1021. adapter = self._adapter()
  1022. state = _make_room_state(
  1023. _make_room("room-9", "Deploy", RoomKind.CHANNEL), False)
  1024. adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
  1025. adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
  1026. await self._dispatch(adapter, "/join #deploy")
  1027. adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
  1028. assert adapter._watch_room_ids == ["room-9"]
  1029. assert "Joined 'Deploy' (room-9)" in self._reply(adapter)
  1030. async def test_join_skips_rpc_when_already_a_member(self):
  1031. adapter = self._adapter()
  1032. """Natively invited accounts hold membership already — they only need
  1033. seeding into the watch list."""
  1034. state = _make_room_state(
  1035. _make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
  1036. adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
  1037. await self._dispatch(adapter, "/join #deploy")
  1038. adapter._chatto_client.join_room.assert_not_awaited()
  1039. assert adapter._watch_room_ids == ["room-9"]
  1040. assert "Already a member" in self._reply(adapter)
  1041. async def test_join_unknown_name_reports_without_joining(self):
  1042. adapter = self._adapter()
  1043. adapter._chatto_client.list_rooms = AsyncMock(return_value=[])
  1044. await self._dispatch(adapter, "/join #nope")
  1045. adapter._chatto_client.join_room.assert_not_awaited()
  1046. assert "No room named '#nope'" in self._reply(adapter)
  1047. assert adapter._watch_room_ids == []
  1048. async def test_ambiguous_name_offers_the_candidate_ids(self):
  1049. adapter = self._adapter()
  1050. matches = [
  1051. _make_room_state(_make_room(f"r-{i}", "General", RoomKind.CHANNEL), False)
  1052. for i in range(2)
  1053. ]
  1054. adapter._chatto_client.list_rooms = AsyncMock(return_value=matches)
  1055. adapter._chatto_client.join_room = AsyncMock()
  1056. await self._dispatch(adapter, "/join #general")
  1057. adapter._chatto_client.join_room.assert_not_awaited()
  1058. reply = self._reply(adapter)
  1059. assert "r-0" in reply and "r-1" in reply
  1060. async def test_join_by_room_id_verifies_via_get_room(self):
  1061. adapter = self._adapter()
  1062. state = _make_room_state(
  1063. _make_room("room-9", "Deploy", RoomKind.CHANNEL), False)
  1064. adapter._chatto_client.get_room = AsyncMock(return_value=state)
  1065. adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
  1066. await self._dispatch(adapter, "/join room-9")
  1067. adapter._chatto_client.get_room.assert_awaited_once_with("room-9")
  1068. adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
  1069. assert adapter._watch_room_ids == ["room-9"]
  1070. async def test_leave_stops_watching_the_room(self):
  1071. adapter = self._adapter()
  1072. adapter._watch_room_ids = ["room-7"]
  1073. state = _make_room_state(
  1074. _make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
  1075. adapter._chatto_client.get_room = AsyncMock(return_value=state)
  1076. await self._dispatch(adapter, "/leave room-7")
  1077. adapter._chatto_client.leave_room.assert_awaited_once_with("room-7")
  1078. assert adapter._watch_room_ids == []
  1079. assert "Left 'Deploy' (room-7)" in self._reply(adapter)
  1080. async def test_leave_refuses_direct_messages(self):
  1081. adapter = self._adapter()
  1082. state = _make_room_state(
  1083. _make_room("dm-2", "", RoomKind.DM), True)
  1084. adapter._chatto_client.get_room = AsyncMock(return_value=state)
  1085. await self._dispatch(adapter, "/leave dm-2")
  1086. adapter._chatto_client.leave_room.assert_not_awaited()
  1087. assert "Direct messages cannot be left" in self._reply(adapter)
  1088. async def test_leave_refuses_home_channel(self):
  1089. """Leaving CHATTO_HOME_CHANNEL would break cron/notification delivery."""
  1090. adapter = self._adapter()
  1091. adapter.chatto_config.home_channel.value = "room-7"
  1092. adapter._watch_room_ids = ["room-7"]
  1093. state = _make_room_state(
  1094. _make_room("room-7", "Home", RoomKind.CHANNEL), True)
  1095. adapter._chatto_client.get_room = AsyncMock(return_value=state)
  1096. await self._dispatch(adapter, "/leave room-7")
  1097. adapter._chatto_client.leave_room.assert_not_awaited()
  1098. assert "home channel" in self._reply(adapter)
  1099. async def test_commands_outside_dms_are_ignored(self):
  1100. adapter = self._adapter()
  1101. """In a channel the text is just a message — mention gating applies,
  1102. no command runs, nothing is sent."""
  1103. adapter.chatto_config.require_mention.value = True
  1104. adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
  1105. await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
  1106. adapter._chatto_client.leave_room.assert_not_awaited()
  1107. adapter.handle_message.assert_not_called()
  1108. adapter.send.assert_not_called()
  1109. async def test_non_command_dm_falls_through_to_pipeline(self):
  1110. adapter = self._adapter()
  1111. await self._dispatch(adapter, "/status all good")
  1112. adapter.handle_message.assert_awaited_once()
  1113. adapter.send.assert_not_called()
  1114. async def test_missing_argument_gets_usage_reply(self):
  1115. adapter = self._adapter()
  1116. for body in ("/join", "/leave"):
  1117. adapter.send.reset_mock()
  1118. await self._dispatch(adapter, body)
  1119. assert self._reply(adapter).startswith("Usage:")
  1120. class TestRoomWatchRefresh:
  1121. """_refresh_rooms mirrors watch-list membership against the server."""
  1122. def _adapter(self):
  1123. adapter = _make_adapter()
  1124. client = adapter._chatto_client
  1125. client.list_rooms = AsyncMock(return_value=[])
  1126. return adapter
  1127. async def test_unwatches_rooms_no_longer_joined(self):
  1128. adapter = self._adapter()
  1129. adapter._watch_room_ids = ["gone-1", "kept"]
  1130. kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
  1131. adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
  1132. await adapter._refresh_rooms()
  1133. assert adapter._watch_room_ids == ["kept"]
  1134. async def test_warns_once_when_home_channel_is_not_joined(self):
  1135. adapter = self._adapter()
  1136. adapter.chatto_config.home_channel.value = "home-x"
  1137. other = _make_room_state(_make_room("other", "Other", RoomKind.CHANNEL), True)
  1138. adapter._chatto_client.list_rooms = AsyncMock(return_value=[other])
  1139. await adapter._refresh_rooms()
  1140. assert adapter._home_warning_logged
  1141. await adapter._refresh_rooms()
  1142. assert adapter._home_warning_logged
  1143. async def test_no_warning_while_home_channel_is_member(self):
  1144. adapter = self._adapter()
  1145. adapter.chatto_config.home_channel.value = "home-x"
  1146. home = _make_room_state(_make_room("home-x", "Home", RoomKind.CHANNEL), True)
  1147. adapter._chatto_client.list_rooms = AsyncMock(return_value=[home])
  1148. await adapter._refresh_rooms()
  1149. assert not adapter._home_warning_logged
  1150. # -- Constants --
  1151. class TestConstants:
  1152. """Test that constants are properly defined."""
  1153. def test_max_message_length(self):
  1154. assert _MAX_MESSAGE_LENGTH == 10000
  1155. def test_seen_cap(self):
  1156. assert _SEEN_CAP == 500