test_adapter.py 64 KB

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