test_adapter.py 59 KB

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