test_adapter.py 64 KB

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