test_adapter.py 90 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293
  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 datetime import UTC, datetime, timedelta
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import pytest
  19. import pytest_asyncio
  20. # Import chattolib types for tests - using vendored chattolib from adapter
  21. # -- Path setup --
  22. # The Hermes agent itself is not a dependency of this plugin; point HERMES_ROOT
  23. # at a checkout to run these tests outside a deployed agent.
  24. PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
  25. sys.path.insert(0, PLUGIN_ROOT)
  26. sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
  27. # Importing ``adapter`` puts the vendored dependencies on sys.path as a side
  28. # effect, but import sorting may legally move that import after the chattolib
  29. # and gateway ones — so bootstrap the vendor paths explicitly instead.
  30. from vendor_path import setup_vendor_path
  31. setup_vendor_path()
  32. from chattolib.client import ChattoClient
  33. from chattolib.realtime_types import ReactionPayload
  34. from chattolib.types import (
  35. Asset,
  36. AssetUpload,
  37. AssetUrl,
  38. DirectoryMember,
  39. Message,
  40. MessageAttachment,
  41. PresenceStatus,
  42. Room,
  43. RoomKind,
  44. RoomViewerState,
  45. RoomWithViewerState,
  46. User,
  47. )
  48. from gateway.config import PlatformConfig
  49. from gateway.platforms.base import (
  50. BasePlatformAdapter,
  51. CachedMedia,
  52. MessageType,
  53. ProcessingOutcome,
  54. SendResult,
  55. get_inbound_media_max_bytes,
  56. )
  57. from adapter import (
  58. ChattoAdapter,
  59. HermesChatType,
  60. RoomPolicy,
  61. _capabilities,
  62. chat_type_for_room_kind,
  63. hermes_env_enablement_fn,
  64. hermes_standalone_sender_fn,
  65. register,
  66. )
  67. from adapter import (
  68. hermes_check_fn as check_requirements,
  69. )
  70. from adapter import (
  71. hermes_validate_config as validate_config,
  72. )
  73. from platform_config import ChattoConstants
  74. _EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
  75. _MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
  76. _SEEN_CAP = ChattoConstants.SEEN_CAP
  77. # -- Helpers --
  78. class _MockPluginContext:
  79. """Minimal mock for the plugin registration context."""
  80. def __init__(self):
  81. self.registered_names = []
  82. self.registered_kwargs = None
  83. def register_platform(self, **kwargs):
  84. from gateway.platform_registry import PlatformEntry, platform_registry
  85. entry = PlatformEntry(
  86. name=kwargs["name"],
  87. label=kwargs.get("label", kwargs["name"]),
  88. adapter_factory=kwargs.get("adapter_factory"),
  89. check_fn=kwargs.get("check_fn"),
  90. validate_config=kwargs.get("validate_config"),
  91. is_connected=kwargs.get("is_connected"),
  92. required_env=kwargs.get("required_env", []),
  93. source="plugin",
  94. )
  95. platform_registry.register(entry)
  96. self.registered_names.append(kwargs["name"])
  97. self.registered_kwargs = kwargs
  98. def _ensure_chatto_registered():
  99. """Register the platform so Platform(PLATFORM_NAME) resolves."""
  100. from gateway.platform_registry import platform_registry
  101. if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
  102. ctx = _MockPluginContext()
  103. register(ctx)
  104. _CHATTO_ENV_KEYS = [
  105. "CHATTO_BASE_URL",
  106. "CHATTO_LOGIN",
  107. "CHATTO_PASSWORD",
  108. "CHATTO_TOKEN",
  109. "CHATTO_HOME_CHANNEL",
  110. "CHATTO_REQUIRE_MENTION_ROOMS",
  111. "CHATTO_OPTIONAL_MENTION_ROOMS",
  112. "CHATTO_ALLOWED_USERS",
  113. "CHATTO_ALLOW_ALL_USERS",
  114. "CHATTO_AUTO_THREAD",
  115. "CHATTO_REACTIONS",
  116. ]
  117. def _clear_chatto_env(monkeypatch=None):
  118. """Remove all CHATTO_* env vars so tests start from a clean slate."""
  119. for key in _CHATTO_ENV_KEYS:
  120. if monkeypatch is not None:
  121. monkeypatch.delenv(key, raising=False)
  122. else:
  123. os.environ.pop(key, None)
  124. def _make_config(**extra_overrides):
  125. """Create a minimal PlatformConfig for testing."""
  126. _ensure_chatto_registered()
  127. extra = {"base_url": "https://chat.example.com"}
  128. extra.update(extra_overrides)
  129. return PlatformConfig(enabled=True, extra=extra)
  130. def _make_room(room_id, name, kind):
  131. """Build a real chattolib Room, as the client would return."""
  132. return Room(
  133. id=room_id,
  134. name=name,
  135. kind=kind,
  136. description="",
  137. archived=False,
  138. group_id="",
  139. universal=kind != RoomKind.DM,
  140. )
  141. def _make_user(user_id, login):
  142. """Build a real chattolib User, as the member directory would return."""
  143. return User(id=user_id, login=login, display_name=login.replace("_", " ").title())
  144. def _make_attachment(filename, content_type, url="https://cdn.example.com/a"):
  145. """Build a real MessageAttachment carrying a (pre-signed) asset URL."""
  146. return MessageAttachment(
  147. id="asset-" + filename,
  148. filename=filename,
  149. content_type=content_type,
  150. asset_url=AssetUrl(url=url),
  151. )
  152. def _make_message(
  153. body="hi",
  154. attachments=None,
  155. message_id="msg-1",
  156. room_id="room-1",
  157. created_at=None,
  158. updated_at=None,
  159. ):
  160. """Build a real chattolib Message, as fetch_message() would return."""
  161. return Message(
  162. id=message_id,
  163. room_id=room_id,
  164. created_at=created_at,
  165. actor_id="user-1",
  166. body=body,
  167. attachments=list(attachments or []),
  168. updated_at=updated_at,
  169. )
  170. def _make_posted_payload(room_id="room-1", message_event_id="msg-1"):
  171. """A message_posted payload whose fetch_message() the caller stubs."""
  172. payload = MagicMock()
  173. payload.room_id = room_id
  174. payload.message_event_id = message_event_id
  175. payload.thread_root_event_id = None
  176. return payload
  177. def _cached(path, media_type, kind):
  178. """The CachedMedia that cache_media_bytes() would return for an attachment."""
  179. return CachedMedia(path=path, media_type=media_type, kind=kind, display_name="f")
  180. def _make_adapter(**extra_overrides):
  181. """Create a ChattoAdapter with mocked config."""
  182. _clear_chatto_env()
  183. cfg = _make_config(**extra_overrides)
  184. adapter = ChattoAdapter(cfg)
  185. adapter._chatto_client = MagicMock()
  186. return adapter
  187. # -- Emoji shortcode conversion --
  188. class TestEmojiShortcode:
  189. """Test emoji to shortcode mapping."""
  190. def test_emoji_to_shortcode_exists(self):
  191. assert isinstance(_EMOJI_TO_SHORTCODE, dict)
  192. assert len(_EMOJI_TO_SHORTCODE) > 0
  193. def test_emoji_to_shortcode_common_emojis(self):
  194. assert _EMOJI_TO_SHORTCODE.get("👍") == "thumbsup"
  195. assert _EMOJI_TO_SHORTCODE.get("👎") == "thumbsdown"
  196. assert _EMOJI_TO_SHORTCODE.get("❤️") == "heart"
  197. assert _EMOJI_TO_SHORTCODE.get("❤") == "heart"
  198. assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark"
  199. assert _EMOJI_TO_SHORTCODE.get("❌") == "x"
  200. # Sent when a message addresses someone else — without the mapping the
  201. # raw emoji would go out as a shortcode and the server would reject it.
  202. assert _EMOJI_TO_SHORTCODE.get("🫥") == "dotted_line_face"
  203. # -- Adapter instantiation and properties --
  204. class TestAdapterInstantiation:
  205. """Test ChattoAdapter creation and basic properties."""
  206. def test_adapter_creation(self):
  207. cfg = _make_config()
  208. adapter = ChattoAdapter(cfg)
  209. assert adapter is not None
  210. # Platform members created dynamically from a plugin name carry the
  211. # name upper-cased; the registered identity is the value.
  212. assert adapter.platform.value == ChattoConstants.PLATFORM_NAME
  213. def test_adapter_max_message_length(self):
  214. """The framework chunks via max_message_length_for_chat(), which reads
  215. the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096
  216. when it is missing."""
  217. cfg = _make_config()
  218. adapter = ChattoAdapter(cfg)
  219. assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
  220. assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH
  221. def test_adapter_splits_long_messages(self):
  222. cfg = _make_config()
  223. adapter = ChattoAdapter(cfg)
  224. assert adapter.splits_long_messages is True
  225. def test_adapter_threads_enabled_by_default(self):
  226. """There is no capability flag for threads — Chatto threading is driven
  227. by the auto_thread setting, which defaults to on."""
  228. cfg = _make_config()
  229. adapter = ChattoAdapter(cfg)
  230. assert adapter.chatto_config.auto_thread.value is True
  231. # -- Registration and requirements --
  232. class TestRegistration:
  233. """Test plugin registration."""
  234. def test_register_called(self):
  235. ctx = _MockPluginContext()
  236. register(ctx)
  237. assert ChattoConstants.PLATFORM_NAME in ctx.registered_names
  238. assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME
  239. assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL
  240. assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH
  241. def test_platform_hint_advertises_media_sending(self):
  242. """This hint is the only thing telling the model the channel can carry
  243. files — without it the agent has no idea it can deliver an image."""
  244. ctx = _MockPluginContext()
  245. register(ctx)
  246. assert "MEDIA:/absolute/path/to/file" in ctx.registered_kwargs["platform_hint"]
  247. def test_platform_hint_rules_out_markdown_for_local_files(self):
  248. """gateway's extract_images only matches https?:// — markdown pointing at
  249. a local file is never extracted and lands in the chat as literal text."""
  250. ctx = _MockPluginContext()
  251. register(ctx)
  252. hint = ctx.registered_kwargs["platform_hint"]
  253. assert "Do NOT use markdown image syntax for local files" in hint
  254. def test_startup_logs_the_capabilities(self, caplog):
  255. with caplog.at_level("INFO"):
  256. register(_MockPluginContext())
  257. logged = "\n".join(caplog.messages)
  258. assert "images" in logged
  259. assert "reactions" in logged
  260. def test_capabilities_skips_methods_we_do_not_override(self):
  261. """An inherited base fallback is not a capability — claiming it would
  262. promise the user something the adapter cannot actually do."""
  263. assert "video" in _capabilities()
  264. with patch.object(ChattoAdapter, "send_video", BasePlatformAdapter.send_video):
  265. assert "video" not in _capabilities()
  266. def test_check_requirements(self):
  267. assert check_requirements() is True
  268. def test_check_requirements_missing(self):
  269. with patch("builtins.__import__", side_effect=ImportError("no chattolib")):
  270. assert check_requirements() is False
  271. def test_validate_config(self):
  272. _clear_chatto_env()
  273. os.environ["CHATTO_BASE_URL"] = "https://chat.test"
  274. os.environ["CHATTO_LOGIN"] = "user"
  275. os.environ["CHATTO_PASSWORD"] = "pass"
  276. cfg = PlatformConfig(enabled=True, extra={"base_url": "https://chat.test"})
  277. assert validate_config(cfg) is True
  278. _clear_chatto_env()
  279. # -- Config validation: mention-list conflicts and unknown keys --
  280. class TestValidateConfigGates:
  281. """hermes_validate_config rejects contradictory mention lists and warns
  282. about extra keys that look like misspelled config fields."""
  283. def _cfg(self, **extra):
  284. _clear_chatto_env()
  285. base = {
  286. "base_url": "https://chat.test",
  287. "login": "user",
  288. "password": "pass",
  289. }
  290. base.update(extra)
  291. return PlatformConfig(enabled=True, extra=base)
  292. def test_room_on_both_mention_lists_is_rejected(self):
  293. cfg = self._cfg(
  294. require_mention_rooms=["room-1", "room-2"],
  295. optional_mention_rooms=["room-2", "room-3"],
  296. )
  297. assert validate_config(cfg) is False
  298. def test_disjoint_mention_lists_are_accepted(self):
  299. cfg = self._cfg(
  300. require_mention_rooms=["room-1"],
  301. optional_mention_rooms=["room-3"],
  302. )
  303. assert validate_config(cfg) is True
  304. @pytest.mark.parametrize("key", ["require_mention_rooms", "optional_mention_rooms"])
  305. def test_known_keys_do_not_warn(self, key, caplog):
  306. cfg = self._cfg(**{key: ["room-1"]})
  307. with caplog.at_level("WARNING"):
  308. validate_config(cfg)
  309. assert not [m for m in caplog.messages if "matches no config field" in m]
  310. def test_a_typo_extra_key_warns_with_a_suggestion(self, caplog):
  311. """A typo'd key silently resolves to its default otherwise — the
  312. warning is the only thing telling the user it never reached us."""
  313. cfg = self._cfg(require_mention_channles=["room-1"])
  314. with caplog.at_level("WARNING"):
  315. validate_config(cfg)
  316. warnings = [m for m in caplog.messages if "matches no config field" in m]
  317. assert len(warnings) == 1
  318. assert "require_mention_channles" in warnings[0]
  319. assert "did you mean 'require_mention_rooms'" in warnings[0]
  320. def test_a_legacy_renamed_key_suggests_its_successor(self, caplog):
  321. """Removed fields read as near-misses of their successors."""
  322. cfg = self._cfg(require_mention=True)
  323. with caplog.at_level("WARNING"):
  324. validate_config(cfg)
  325. warnings = [m for m in caplog.messages if "matches no config field" in m]
  326. assert len(warnings) == 1
  327. assert "did you mean 'require_mention_rooms'" in warnings[0]
  328. @pytest.mark.parametrize(
  329. "key",
  330. [
  331. "_enabled_explicit",
  332. "group_sessions_per_user",
  333. "reply_in_thread",
  334. "gateway_restart_notification",
  335. ],
  336. )
  337. def test_gateway_keys_do_not_warn(self, key, caplog):
  338. """The gateway places shared keys into every platform's extra — they
  339. resemble nothing of ours, so near-miss matching leaves them alone."""
  340. cfg = self._cfg(**{key: True})
  341. with caplog.at_level("WARNING"):
  342. validate_config(cfg)
  343. assert not [m for m in caplog.messages if "matches no config field" in m]
  344. # -- Send functionality --
  345. class TestSend:
  346. """Test message sending functionality."""
  347. @pytest_asyncio.fixture
  348. def adapter(self):
  349. _clear_chatto_env()
  350. cfg = _make_config()
  351. adapter = ChattoAdapter(cfg)
  352. adapter._chatto_client = MagicMock()
  353. adapter._chatto_client.post_message = AsyncMock()
  354. return adapter
  355. async def test_send_calls_post_message(self, adapter):
  356. mock_msg = MagicMock()
  357. mock_msg.id = "msg-123"
  358. adapter._chatto_client.post_message.return_value = mock_msg
  359. result = await adapter.send("room-1", "Hello world")
  360. assert result.success is True
  361. assert result.message_id == "msg-123"
  362. adapter._chatto_client.post_message.assert_called_once()
  363. async def test_send_with_thread(self, adapter):
  364. mock_msg = MagicMock()
  365. mock_msg.id = "msg-456"
  366. adapter._chatto_client.post_message.return_value = mock_msg
  367. result = await adapter.send("room-1", "Hello", reply_to="thread-123")
  368. assert result.success is True
  369. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  370. assert call_kwargs["thread_root_event_id"] == "thread-123"
  371. async def test_send_raw_response_stays_dict_shaped(self, adapter):
  372. # The cron scheduler calls .get() on SendResult.raw_response when a job
  373. # targets a thread; a chattolib Message there crashes delivery
  374. # bookkeeping after the send and duplicates the message standalone.
  375. adapter._chatto_client.post_message.return_value = _make_message(
  376. body="Hello world", message_id="msg-789"
  377. )
  378. result = await adapter.send("room-1", "Hello world")
  379. assert result.success is True
  380. assert not result.raw_response or isinstance(result.raw_response, dict)
  381. # -- Reactions --
  382. class TestReactions:
  383. """Test reaction functionality."""
  384. @pytest_asyncio.fixture
  385. def adapter(self):
  386. _clear_chatto_env()
  387. cfg = _make_config()
  388. adapter = ChattoAdapter(cfg)
  389. adapter._chatto_client = MagicMock()
  390. adapter._chatto_client.add_reaction = AsyncMock()
  391. adapter._chatto_client.remove_reaction = AsyncMock()
  392. return adapter
  393. async def test_send_reaction(self, adapter):
  394. await adapter.add_reaction("room-1", "msg-1", "👍")
  395. adapter._chatto_client.add_reaction.assert_called_once()
  396. async def test_remove_reaction(self, adapter):
  397. await adapter.remove_reaction("room-1", "msg-1", "👍")
  398. adapter._chatto_client.remove_reaction.assert_called_once()
  399. async def test_on_processing_start_adds_eyes_reaction(self, adapter):
  400. """on_processing_start should call add_reaction with 👀."""
  401. event = MagicMock()
  402. event.message_id = "msg-1"
  403. event.source.chat_id = "room-1"
  404. await adapter.on_processing_start(event)
  405. adapter._chatto_client.add_reaction.assert_called_once()
  406. call_kwargs = adapter._chatto_client.add_reaction.call_args.kwargs
  407. assert call_kwargs["message_event_id"] == "msg-1"
  408. assert call_kwargs["room_id"] == "room-1"
  409. assert call_kwargs["emoji"] == "eyes"
  410. async def test_on_processing_start_empty_message_id(self, adapter):
  411. """on_processing_start should skip reaction when message_id is empty."""
  412. event = MagicMock()
  413. event.message_id = None
  414. event.source.chat_id = "room-1"
  415. await adapter.on_processing_start(event)
  416. adapter._chatto_client.add_reaction.assert_not_called()
  417. async def test_on_processing_start_reactions_disabled(self, adapter):
  418. """on_processing_start should skip when reactions config is False."""
  419. adapter.chatto_config.reactions.value = False
  420. event = MagicMock()
  421. event.message_id = "msg-1"
  422. event.source.chat_id = "room-1"
  423. await adapter.on_processing_start(event)
  424. adapter._chatto_client.add_reaction.assert_not_called()
  425. # -- Edit and Delete Messages --
  426. class TestMessageEditing:
  427. """Test message editing and deletion."""
  428. @pytest_asyncio.fixture
  429. def adapter(self):
  430. _clear_chatto_env()
  431. cfg = _make_config()
  432. adapter = ChattoAdapter(cfg)
  433. adapter._chatto_client = MagicMock()
  434. adapter._chatto_client.update_message = AsyncMock()
  435. adapter._chatto_client.delete_message = AsyncMock(return_value=True)
  436. adapter._token = "test-token"
  437. return adapter
  438. async def test_edit_message(self, adapter):
  439. result = await adapter.edit_message("room-1", "msg-1", "New content")
  440. assert result.success is True
  441. adapter._chatto_client.update_message.assert_called_once()
  442. call_kwargs = adapter._chatto_client.update_message.call_args.kwargs
  443. assert call_kwargs["room_id"] == "room-1"
  444. assert call_kwargs["event_id"] == "msg-1"
  445. assert call_kwargs["body"] == "New content"
  446. async def test_edit_message_marks_own_edit_seen(self, adapter):
  447. """The edit echoes back as message_edited — it must not look inbound."""
  448. mock_msg = MagicMock()
  449. mock_msg.id = "msg-1"
  450. adapter._chatto_client.update_message.return_value = mock_msg
  451. await adapter.edit_message("room-1", "msg-1", "New content")
  452. assert adapter._is_seen("msg-1") is True
  453. async def test_edit_message_too_long_refuses(self, adapter):
  454. """Overlong content must fall back to send() (which splits), not be
  455. silently truncated into a lossy edit."""
  456. result = await adapter.edit_message(
  457. "room-1",
  458. "msg-1",
  459. "x" * (_MAX_MESSAGE_LENGTH + 1),
  460. )
  461. assert result.success is False
  462. adapter._chatto_client.update_message.assert_not_called()
  463. async def test_edit_message_empty_content(self, adapter):
  464. result = await adapter.edit_message("room-1", "msg-1", "")
  465. assert result.success is False
  466. adapter._chatto_client.update_message.assert_not_called()
  467. async def test_edit_message_error_is_retryable(self, adapter):
  468. from chattolib.exceptions import ChattoError
  469. adapter._chatto_client.update_message.side_effect = ChattoError("boom")
  470. result = await adapter.edit_message("room-1", "msg-1", "New content")
  471. assert result.success is False
  472. assert result.retryable is True
  473. async def test_delete_message(self, adapter):
  474. result = await adapter.delete_message("room-1", "msg-1")
  475. assert result is True
  476. adapter._chatto_client.delete_message.assert_called_once()
  477. call_kwargs = adapter._chatto_client.delete_message.call_args.kwargs
  478. assert call_kwargs["room_id"] == "room-1"
  479. assert call_kwargs["event_id"] == "msg-1"
  480. async def test_delete_message_missing_ids(self, adapter):
  481. assert await adapter.delete_message("", "msg-1") is False
  482. assert await adapter.delete_message("room-1", "") is False
  483. adapter._chatto_client.delete_message.assert_not_called()
  484. async def test_delete_message_error_returns_false(self, adapter):
  485. from chattolib.exceptions import ChattoError
  486. adapter._chatto_client.delete_message.side_effect = ChattoError("nope")
  487. assert await adapter.delete_message("room-1", "msg-1") is False
  488. # -- Outgoing text formatting --
  489. class TestFormatMessage:
  490. """format_message() only fixes what renders wrong in Chatto."""
  491. def test_normalises_crlf(self):
  492. adapter = _make_adapter()
  493. assert adapter.format_message("a\r\nb\rc") == "a\nb\nc"
  494. def test_collapses_excess_blank_lines(self):
  495. adapter = _make_adapter()
  496. assert adapter.format_message("a\n\n\n\n\n\nb") == "a\n\n\nb"
  497. def test_leaves_markdown_untouched(self):
  498. adapter = _make_adapter()
  499. text = "**bold** `code`\n\n```py\nx = 1\n```\n- item"
  500. assert adapter.format_message(text) == text
  501. def test_empty_content(self):
  502. adapter = _make_adapter()
  503. assert adapter.format_message("") == ""
  504. # -- Handoff threads --
  505. class TestHandoffThread:
  506. """create_handoff_thread() anchors a handoff on a seed message."""
  507. @pytest_asyncio.fixture
  508. def adapter(self):
  509. adapter = _make_adapter()
  510. adapter._chatto_client.post_message = AsyncMock()
  511. adapter._chatto_client.follow_thread = AsyncMock()
  512. return adapter
  513. async def test_returns_seed_message_id(self, adapter):
  514. mock_msg = MagicMock()
  515. mock_msg.id = "seed-1"
  516. adapter._chatto_client.post_message.return_value = mock_msg
  517. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  518. result = await adapter.create_handoff_thread("room-1", "Refactor run")
  519. assert result == "seed-1"
  520. assert (
  521. adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
  522. )
  523. adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1")
  524. # Our own seed must not come back in as inbound traffic.
  525. assert adapter._is_seen("seed-1") is True
  526. async def test_dm_has_no_threads(self, adapter):
  527. adapter._room_kinds["dm-1"] = RoomKind.DM
  528. assert await adapter.create_handoff_thread("dm-1", "x") is None
  529. adapter._chatto_client.post_message.assert_not_called()
  530. async def test_seed_post_failure(self, adapter):
  531. adapter._chatto_client.post_message.side_effect = RuntimeError("down")
  532. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  533. assert await adapter.create_handoff_thread("room-1", "x") is None
  534. # -- Native file / video / audio delivery --
  535. class TestUploadAsset:
  536. """Drives the real _upload_asset against real chattolib result types.
  537. The send_* tests stub _upload_asset out, so a wrong field name on the
  538. chattolib response was invisible to them until it hit a live server.
  539. """
  540. @pytest_asyncio.fixture
  541. def adapter(self, tmp_path):
  542. adapter = _make_adapter()
  543. self.path = tmp_path / "horse.jpg"
  544. self.path.write_bytes(b"\xff\xd8\xff" + b"x" * 100)
  545. upload = AssetUpload(upload_id="up-1", room_id="room-1")
  546. adapter._chatto_client.create_upload = AsyncMock(return_value=upload)
  547. adapter._chatto_client.upload_chunk = AsyncMock(return_value=upload)
  548. adapter._chatto_client.complete_upload = AsyncMock(
  549. return_value=(
  550. upload,
  551. Asset(
  552. id="asset-9",
  553. filename="horse.jpg",
  554. content_type="image/jpeg",
  555. size=103,
  556. ),
  557. )
  558. )
  559. return adapter
  560. async def test_returns_the_asset_id(self, adapter):
  561. assert await adapter._upload_asset("room-1", str(self.path)) == "asset-9"
  562. async def test_chunks_go_to_the_upload_id_from_create_upload(self, adapter):
  563. """AssetUpload calls it upload_id, not id — reading the wrong field made
  564. every upload fail with 'CreateUpload returned no upload ID'."""
  565. await adapter._upload_asset("room-1", str(self.path))
  566. assert (
  567. adapter._chatto_client.upload_chunk.await_args.kwargs["upload_id"] == "up-1"
  568. )
  569. async def test_missing_upload_id_is_reported(self, adapter):
  570. adapter._chatto_client.create_upload = AsyncMock(
  571. return_value=AssetUpload(upload_id="", room_id="room-1")
  572. )
  573. assert await adapter._upload_asset("room-1", str(self.path)) is None
  574. class TestNativeSends:
  575. """send_document/_video/_voice upload instead of apologising in text."""
  576. @pytest_asyncio.fixture
  577. def adapter(self):
  578. adapter = _make_adapter()
  579. adapter._chatto_client.post_message = AsyncMock()
  580. adapter._upload_asset = AsyncMock(return_value="asset-1")
  581. adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
  582. mock_msg = MagicMock()
  583. mock_msg.id = "msg-1"
  584. adapter._chatto_client.post_message.return_value = mock_msg
  585. return adapter
  586. @pytest.mark.parametrize(
  587. "method,arg_name",
  588. [
  589. ("send_document", "file_path"),
  590. ("send_video", "video_path"),
  591. ("send_voice", "audio_path"),
  592. ("send_image_file", "image_path"),
  593. ],
  594. )
  595. async def test_uploads_and_attaches(self, adapter, method, arg_name):
  596. result = await getattr(adapter, method)(
  597. "room-1",
  598. **{arg_name: "/tmp/thing.bin"},
  599. caption="here you go",
  600. )
  601. assert result.success is True
  602. adapter._upload_asset.assert_called_once_with("room-1", "/tmp/thing.bin")
  603. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  604. assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
  605. assert call_kwargs["body"] == "here you go"
  606. async def test_unsafe_path_falls_back_to_notice(self, adapter):
  607. adapter.validate_media_delivery_path = MagicMock(return_value=None)
  608. adapter.send = AsyncMock(return_value=SendResult(success=True))
  609. await adapter.send_document("room-1", "/etc/shadow")
  610. adapter._upload_asset.assert_not_called()
  611. # Never echo the host path into chat.
  612. sent_text = adapter.send.call_args.args[1]
  613. assert "/etc/shadow" not in sent_text
  614. async def test_upload_failure_falls_back_to_notice(self, adapter):
  615. adapter._upload_asset = AsyncMock(return_value=None)
  616. adapter.send = AsyncMock(return_value=SendResult(success=True))
  617. await adapter.send_video("room-1", "/tmp/clip.mp4", caption="a clip")
  618. sent_text = adapter.send.call_args.args[1]
  619. assert sent_text.startswith("a clip\n")
  620. assert "/tmp/clip.mp4" not in sent_text
  621. async def test_attachment_reply_threads_only_with_auto_thread(self, adapter):
  622. """auto_thread=false must keep attachment replies in the room, exactly
  623. like text replies — no quiet thread under the incoming message."""
  624. adapter.chatto_config.auto_thread.value = False
  625. await adapter.send_document("room-1", "/tmp/thing.bin", reply_to="incoming-1")
  626. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  627. assert call_kwargs["thread_root_event_id"] == ""
  628. async def test_attachment_reply_threads_under_the_incoming_message(self, adapter):
  629. await adapter.send_document("room-1", "/tmp/thing.bin", reply_to="incoming-1")
  630. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  631. assert call_kwargs["thread_root_event_id"] == "incoming-1"
  632. async def test_attachment_metadata_thread_id_wins_over_reply_to(self, adapter):
  633. await adapter.send_document(
  634. "room-1",
  635. "/tmp/thing.bin",
  636. reply_to="incoming-1",
  637. metadata={"thread_id": "root-9"},
  638. )
  639. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  640. assert call_kwargs["thread_root_event_id"] == "root-9"
  641. async def test_attachment_replies_to_dms_never_thread(self, adapter):
  642. """DMs don't support threads — not even with auto_thread enabled."""
  643. adapter._room_kinds["dm-1"] = RoomKind.DM
  644. await adapter.send_document("dm-1", "/tmp/thing.bin", reply_to="incoming-1")
  645. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  646. assert call_kwargs["thread_root_event_id"] == ""
  647. # -- Batched image delivery --
  648. class TestSendMultipleImages:
  649. """A batch of images belongs in ONE Chatto message."""
  650. @pytest_asyncio.fixture
  651. def adapter(self):
  652. adapter = _make_adapter()
  653. adapter._chatto_client.post_message = AsyncMock()
  654. mock_msg = MagicMock()
  655. mock_msg.id = "msg-1"
  656. adapter._chatto_client.post_message.return_value = mock_msg
  657. adapter._upload_asset = AsyncMock(side_effect=["asset-1", "asset-2"])
  658. adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
  659. return adapter
  660. async def test_bundles_into_single_message(self, adapter):
  661. await adapter.send_multiple_images(
  662. "room-1",
  663. [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
  664. )
  665. adapter._chatto_client.post_message.assert_called_once()
  666. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  667. assert call_kwargs["attachment_asset_ids"] == ["asset-1", "asset-2"]
  668. assert call_kwargs["body"] == "first\nsecond"
  669. async def test_single_image_uses_base_path(self, adapter):
  670. """One image is not a batch — leave it to the base implementation.
  671. Also pins the send_image_file signature: the base class calls it with
  672. ``image_path=`` as a keyword, so a renamed parameter degrades every
  673. native image send to a text notice.
  674. """
  675. adapter.send_image_file = AsyncMock(return_value=SendResult(success=True))
  676. await adapter.send_multiple_images("room-1", [("file:///tmp/a.png", "only")])
  677. adapter._upload_asset.assert_not_called()
  678. adapter.send_image_file.assert_called_once()
  679. assert adapter.send_image_file.call_args.kwargs["image_path"] == "/tmp/a.png"
  680. async def test_partial_upload_failure_still_sends_the_rest(self, adapter):
  681. adapter._upload_asset = AsyncMock(side_effect=["asset-1", None])
  682. await adapter.send_multiple_images(
  683. "room-1",
  684. [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
  685. )
  686. call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
  687. assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
  688. async def test_file_uri_is_unquoted(self, adapter):
  689. await adapter.send_multiple_images(
  690. "room-1",
  691. [("file:///tmp/a%20b.png", ""), ("/tmp/c.png", "")],
  692. )
  693. first_path = adapter._upload_asset.call_args_list[0].args[1]
  694. assert first_path == "/tmp/a b.png"
  695. # -- Reaction event forwarding --
  696. class TestReactionForwarding:
  697. """Human reactions reach the gateway's reaction hook surface."""
  698. @pytest_asyncio.fixture
  699. def adapter(self):
  700. adapter = _make_adapter()
  701. adapter.me = _make_user("bot-user-id", "hermes_bot")
  702. return adapter
  703. def _event(self, kind, actor_id="human-1"):
  704. event = MagicMock()
  705. event.id = "evt-1"
  706. event.kind = kind
  707. event.actor_id = actor_id
  708. payload = ReactionPayload(
  709. room_id="room-1",
  710. message_event_id="msg-1",
  711. emoji="thumbsup",
  712. )
  713. # RealtimeEvent.get() only yields the payload for its own kind.
  714. event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
  715. return event
  716. async def test_forwards_added_reaction(self, adapter):
  717. handler = AsyncMock()
  718. adapter.set_reaction_handler(handler)
  719. await adapter._handle_realtime_event(self._event("reaction_added"))
  720. handler.assert_called_once()
  721. payload = handler.call_args.args[0]
  722. assert payload["event_name"] == "reaction:added"
  723. assert payload["reaction"] == "thumbsup"
  724. assert payload["channel_id"] == "room-1"
  725. assert payload["message_ts"] == "msg-1"
  726. assert payload["user_id"] == "human-1"
  727. assert payload["item_type"] == "message"
  728. async def test_forwards_removed_reaction(self, adapter):
  729. handler = AsyncMock()
  730. adapter.set_reaction_handler(handler)
  731. await adapter._handle_realtime_event(self._event("reaction_removed"))
  732. assert handler.call_args.args[0]["event_name"] == "reaction:removed"
  733. async def test_ignores_own_lifecycle_reactions(self, adapter):
  734. """👀/✅/❌ are ours — forwarding them would feed the agent its own markers."""
  735. handler = AsyncMock()
  736. adapter.set_reaction_handler(handler)
  737. await adapter._handle_realtime_event(
  738. self._event("reaction_added", actor_id="bot-user-id"),
  739. )
  740. handler.assert_not_called()
  741. async def test_no_handler_registered_is_harmless(self, adapter):
  742. await adapter._handle_realtime_event(self._event("reaction_added"))
  743. async def test_handler_exception_does_not_propagate(self, adapter):
  744. adapter.set_reaction_handler(AsyncMock(side_effect=RuntimeError("hook boom")))
  745. await adapter._handle_realtime_event(self._event("reaction_added"))
  746. # -- chat_type mapping --
  747. class TestChatTypeMapping:
  748. """RoomKind -> the gateway's chat_type vocabulary."""
  749. def test_maps_known_kinds(self):
  750. assert chat_type_for_room_kind(RoomKind.DM) is HermesChatType.DM
  751. assert chat_type_for_room_kind(RoomKind.CHANNEL) is HermesChatType.CHANNEL
  752. def test_unknown_kind_is_group_never_dm(self):
  753. """'dm' drives session isolation — never guess it for an unknown kind."""
  754. assert chat_type_for_room_kind(RoomKind.UNSPECIFIED) is HermesChatType.GROUP
  755. assert chat_type_for_room_kind(None) is HermesChatType.GROUP
  756. def test_values_match_the_gateway_vocabulary(self):
  757. """session.py:161 declares exactly these strings; SessionSource.description
  758. and the PII-redacted context prompt branch on them."""
  759. assert [t.value for t in HermesChatType] == ["dm", "group", "channel", "thread"]
  760. def test_is_a_plain_str_at_call_sites(self):
  761. assert HermesChatType.CHANNEL == "channel"
  762. assert f"{HermesChatType.DM}" == "dm"
  763. async def test_get_chat_info_reports_channel(self):
  764. adapter = _make_adapter()
  765. adapter._room_names["room-1"] = "Team"
  766. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  767. info = await adapter.get_chat_info("room-1")
  768. assert info == {"name": "Team", "type": "channel"}
  769. async def test_get_chat_info_reports_dm(self):
  770. adapter = _make_adapter()
  771. adapter._room_kinds["dm-1"] = RoomKind.DM
  772. assert (await adapter.get_chat_info("dm-1"))["type"] == "dm"
  773. async def test_dispatch_stamps_the_mapped_chat_type(self):
  774. """The value reaching build_source decides how the agent is told where
  775. it is — a raw RoomKind lands in SessionSource.description's else-branch."""
  776. adapter = _make_adapter()
  777. adapter.chatto_config.allow_all_users.value = True
  778. adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
  779. adapter.me = _make_user("bot-user-id", "hermes_bot")
  780. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  781. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  782. adapter.handle_message = AsyncMock()
  783. payload = _make_posted_payload()
  784. payload.fetch_message = AsyncMock(return_value=_make_message(body="hi"))
  785. await adapter._dispatch_message_posted(payload)
  786. event = adapter.handle_message.call_args.args[0]
  787. assert event.source.chat_type == "channel"
  788. # -- Presence --
  789. class TestPresence:
  790. """Presence is a server-side TTL: stop re-announcing and the bot goes offline."""
  791. def _adapter(self):
  792. adapter = _make_adapter()
  793. adapter._chatto_client.update_presence = AsyncMock()
  794. return adapter
  795. async def test_refresh_loop_keeps_reannouncing_online(self):
  796. """The bug: a single announce at connect lapses and never comes back."""
  797. adapter = self._adapter()
  798. adapter._closing = False
  799. with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
  800. task = asyncio.create_task(adapter._presence_refresh_loop())
  801. for _ in range(200):
  802. if adapter._chatto_client.update_presence.await_count >= 3:
  803. break
  804. await asyncio.sleep(0.01)
  805. adapter._closing = True
  806. task.cancel()
  807. try:
  808. await task
  809. except asyncio.CancelledError:
  810. pass
  811. assert adapter._chatto_client.update_presence.await_count >= 3
  812. for call in adapter._chatto_client.update_presence.await_args_list:
  813. assert call.kwargs["status"] == PresenceStatus.ONLINE
  814. async def test_refresh_survives_a_failing_call(self):
  815. """One bad tick must not kill the loop and strand the bot offline."""
  816. adapter = self._adapter()
  817. adapter._closing = False
  818. adapter._chatto_client.update_presence = AsyncMock(
  819. side_effect=[RuntimeError("boom"), None, None]
  820. )
  821. with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
  822. task = asyncio.create_task(adapter._presence_refresh_loop())
  823. for _ in range(200):
  824. if adapter._chatto_client.update_presence.await_count >= 3:
  825. break
  826. await asyncio.sleep(0.01)
  827. adapter._closing = True
  828. task.cancel()
  829. try:
  830. await task
  831. except asyncio.CancelledError:
  832. pass
  833. assert adapter._chatto_client.update_presence.await_count >= 3
  834. async def test_announce_online_reports_failure(self):
  835. adapter = self._adapter()
  836. adapter._chatto_client.update_presence = AsyncMock(
  837. side_effect=RuntimeError("nope")
  838. )
  839. assert await adapter._announce_online() is False
  840. async def test_disconnect_does_not_broadcast_offline(self):
  841. """chattolib raises ValueError on OFFLINE — going offline means stopping."""
  842. adapter = self._adapter()
  843. adapter._chatto_client.close = AsyncMock()
  844. client = adapter._chatto_client # disconnect() drops the reference
  845. await adapter.disconnect()
  846. client.update_presence.assert_not_called()
  847. # -- Mentions of other people --
  848. class TestForeignMention:
  849. """In an open channel (CHATTO_OPTIONAL_MENTION_ROOMS) the bot reads
  850. everything, so a message aimed at a named colleague would otherwise get an
  851. unsolicited answer. Acknowledge it with 🫥 and stay out of the conversation."""
  852. def _adapter(self, **overrides):
  853. adapter = _make_adapter()
  854. adapter.chatto_config.allow_all_users.value = True
  855. adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
  856. adapter.chatto_config.reactions.value = True
  857. for key, value in overrides.items():
  858. getattr(adapter.chatto_config, key).value = value
  859. adapter.me = _make_user("bot-user-id", "hermes_bot")
  860. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  861. adapter.handle_message = AsyncMock()
  862. adapter.add_reaction = AsyncMock(return_value=True)
  863. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  864. adapter._room_kinds["dm-1"] = RoomKind.DM
  865. # The directory knows bob and nobody else. Resolution is
  866. # case-insensitive, as Chatto's single mention namespace guarantees
  867. # (FDR-006) — the fake models that at its own boundary.
  868. adapter._chatto_client.get_user = AsyncMock(
  869. side_effect=lambda **kw: (
  870. DirectoryMember(user=_make_user("user-2", "bob"))
  871. if str(kw.get("login", "")).lower() == "bob"
  872. else None
  873. )
  874. )
  875. return adapter
  876. async def _dispatch(self, adapter, body, room_id="room-1"):
  877. payload = _make_posted_payload(room_id=room_id)
  878. payload.fetch_message = AsyncMock(
  879. return_value=_make_message(body=body, room_id=room_id)
  880. )
  881. await adapter._dispatch_message_posted(payload)
  882. async def test_message_for_someone_else_is_only_acknowledged(self):
  883. adapter = self._adapter()
  884. await self._dispatch(adapter, "@bob can you take a look?")
  885. adapter.handle_message.assert_not_called()
  886. adapter.add_reaction.assert_awaited_once()
  887. assert adapter.add_reaction.await_args.args[2] == "🫥"
  888. async def test_being_mentioned_alongside_someone_else_still_answers(self):
  889. adapter = self._adapter()
  890. await self._dispatch(adapter, "@bob and @hermes_bot, thoughts?")
  891. adapter.handle_message.assert_called_once()
  892. adapter.add_reaction.assert_not_awaited()
  893. async def test_several_people_addressed_and_none_of_them_us(self):
  894. adapter = self._adapter()
  895. adapter._chatto_client.get_user = AsyncMock(
  896. side_effect=lambda **kw: (
  897. DirectoryMember(user=_make_user("u", kw["login"]))
  898. if kw.get("login") in {"bob", "carol"}
  899. else None
  900. )
  901. )
  902. await self._dispatch(adapter, "@bob @carol schaut mal drüber")
  903. adapter.handle_message.assert_not_called()
  904. adapter.add_reaction.assert_awaited_once()
  905. async def test_a_real_handle_after_an_unknown_one_still_counts(self):
  906. """The scan must not stop at the first token it cannot resolve."""
  907. adapter = self._adapter()
  908. await self._dispatch(adapter, "@nonexistent @bob schaut mal drüber")
  909. adapter.handle_message.assert_not_called()
  910. adapter.add_reaction.assert_awaited_once()
  911. async def test_being_named_among_several_others_still_answers(self):
  912. adapter = self._adapter()
  913. await self._dispatch(adapter, "@bob @hermes_bot @carol — was meint ihr?")
  914. adapter.handle_message.assert_called_once()
  915. adapter.add_reaction.assert_not_awaited()
  916. async def test_broadcast_alongside_a_named_colleague_still_answers(self):
  917. """@here keeps the bot in the audience; naming bob as well does not
  918. remove it."""
  919. adapter = self._adapter()
  920. await self._dispatch(adapter, "@here @bob schaut mal drüber")
  921. adapter.handle_message.assert_called_once()
  922. adapter.add_reaction.assert_not_awaited()
  923. async def test_broadcast_mentions_address_the_bot_too(self):
  924. adapter = self._adapter()
  925. # @here/@all are Chatto's virtual handles (FDR-006) — the bot is in
  926. # their audience. @channel/@everyone do not exist as handles; they are
  927. # unknown candidates, and an unknown handle is not someone else either,
  928. # so the message still gets answered rather than acknowledged away.
  929. for body in ("@here standup in 5", "@all heads up", "@everyone hi"):
  930. adapter.handle_message.reset_mock()
  931. await self._dispatch(adapter, body)
  932. adapter.handle_message.assert_called_once()
  933. async def test_broadcast_mention_alone_is_not_someone_else(self):
  934. """A pure @here must not be read as a message aimed at another person."""
  935. adapter = self._adapter()
  936. await self._dispatch(adapter, "@here standup in 5")
  937. adapter.handle_message.assert_called_once()
  938. adapter.add_reaction.assert_not_awaited()
  939. async def test_trailing_punctuation_still_resolves_the_handle(self):
  940. """'frag @bob.' must resolve bob — a sentence-ending dot is not part of
  941. the handle, mirroring the web frontend's extraction."""
  942. adapter = self._adapter()
  943. await self._dispatch(adapter, "frag mal @bob.")
  944. adapter.handle_message.assert_not_called()
  945. adapter.add_reaction.assert_awaited_once()
  946. async def test_foreign_mention_matching_is_case_insensitive(self):
  947. adapter = self._adapter()
  948. await self._dispatch(adapter, "@BOB can you take a look?")
  949. adapter.handle_message.assert_not_called()
  950. adapter.add_reaction.assert_awaited_once()
  951. async def test_own_mention_matching_is_case_insensitive(self):
  952. adapter = self._adapter()
  953. await self._dispatch(adapter, "@HERMES_BOT ping")
  954. adapter.handle_message.assert_called_once()
  955. async def test_mentions_inside_a_fenced_block_are_ignored(self):
  956. """Upstream does not resolve mentions in code blocks; quoting '@bob'
  957. there must not silence us."""
  958. adapter = self._adapter()
  959. await self._dispatch(adapter, "so sieht ein ping aus:\n```\n@bob\n```")
  960. adapter.handle_message.assert_called_once()
  961. async def test_mentions_inside_an_inline_span_are_ignored(self):
  962. adapter = self._adapter()
  963. await self._dispatch(adapter, "schreib `@bob` mal direkt an")
  964. adapter.handle_message.assert_called_once()
  965. async def test_an_unterminated_fence_shields_the_rest(self):
  966. """An unclosed fence runs to the end of the text, like upstream."""
  967. adapter = self._adapter()
  968. await self._dispatch(adapter, "beispiel:\n``` bash\n@bob schaut")
  969. adapter.handle_message.assert_called_once()
  970. async def test_broadcast_handles_are_shared_across_cases_in_the_cache(self):
  971. """The directory cache keys on the lowercased handle: one lookup
  972. serves '@bob' and '@BOB' alike."""
  973. adapter = self._adapter()
  974. await self._dispatch(adapter, "@BOB ping")
  975. await self._dispatch(adapter, "@bob again")
  976. assert adapter._chatto_client.get_user.await_count == 1
  977. async def test_talking_about_mentions_is_not_a_mention(self):
  978. """Verbatim from the field: the instruction to send a mention later must
  979. not read as a mention now. '@-mention' is not a handle anyone holds."""
  980. adapter = self._adapter()
  981. await self._dispatch(
  982. adapter,
  983. "Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den "
  984. 'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
  985. )
  986. adapter.handle_message.assert_called_once()
  987. adapter.add_reaction.assert_not_awaited()
  988. async def test_handle_nobody_holds_is_not_a_mention(self):
  989. """A plausible-looking @token that resolves to no user is not someone
  990. else — answering a false positive beats falling silent on one."""
  991. adapter = self._adapter()
  992. adapter._chatto_client.get_user = AsyncMock(return_value=None)
  993. await self._dispatch(adapter, "gilt das auch für @nonexistent_person?")
  994. adapter.handle_message.assert_called_once()
  995. adapter.add_reaction.assert_not_awaited()
  996. async def test_a_resolvable_handle_is_looked_up_once(self):
  997. adapter = self._adapter()
  998. await self._dispatch(adapter, "@bob ping")
  999. await self._dispatch(adapter, "@bob again")
  1000. assert adapter._chatto_client.get_user.await_count == 1
  1001. assert adapter.handle_message.await_count == 0
  1002. async def test_an_email_address_is_not_a_mention(self):
  1003. adapter = self._adapter()
  1004. await self._dispatch(adapter, "schreib an bob@example.com")
  1005. adapter.handle_message.assert_called_once()
  1006. adapter._chatto_client.get_user.assert_not_awaited()
  1007. async def test_plain_message_is_unaffected(self):
  1008. adapter = self._adapter()
  1009. await self._dispatch(adapter, "how do I reset the cache?")
  1010. adapter.handle_message.assert_called_once()
  1011. async def test_dms_are_answered_even_when_they_name_someone_else(self):
  1012. adapter = self._adapter()
  1013. await self._dispatch(adapter, "@bob said the build is red", room_id="dm-1")
  1014. adapter.handle_message.assert_called_once()
  1015. async def test_require_list_keeps_discarding_without_a_reaction(self):
  1016. """The require gate wins: it drops the message before we get here, and
  1017. it deliberately says nothing at all."""
  1018. adapter = self._adapter(
  1019. optional_mention_rooms=[],
  1020. require_mention_rooms=["room-1"],
  1021. )
  1022. await self._dispatch(adapter, "@bob can you take a look?")
  1023. adapter.handle_message.assert_not_called()
  1024. adapter.add_reaction.assert_not_awaited()
  1025. async def test_silence_holds_when_reactions_are_disabled(self):
  1026. adapter = self._adapter(reactions=False)
  1027. await self._dispatch(adapter, "@bob can you take a look?")
  1028. adapter.handle_message.assert_not_called()
  1029. adapter.add_reaction.assert_not_awaited()
  1030. # -- Room mention policies --
  1031. class TestRoomPolicies:
  1032. """Every non-DM room is opt-in via the two mention lists — Chatto has no
  1033. group rooms, and an unknown kind counts as a channel too. A DM is already
  1034. addressed at the bot."""
  1035. def _adapter(self):
  1036. adapter = _make_adapter()
  1037. adapter.chatto_config.allow_all_users.value = True
  1038. adapter.me = _make_user("bot-user-id", "hermes_bot")
  1039. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  1040. adapter.handle_message = AsyncMock()
  1041. return adapter
  1042. async def _dispatch(self, adapter, room_id, body):
  1043. payload = _make_posted_payload(room_id=room_id)
  1044. payload.fetch_message = AsyncMock(
  1045. return_value=_make_message(body=body, room_id=room_id)
  1046. )
  1047. await adapter._dispatch_message_posted(payload)
  1048. async def test_unlisted_room_is_silent(self):
  1049. """Opt-in by default: an unlisted room is not dispatched at all."""
  1050. adapter = self._adapter()
  1051. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  1052. await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
  1053. adapter.handle_message.assert_not_called()
  1054. async def test_unlisted_room_is_dropped_before_any_api_call(self):
  1055. """Silent rooms must not even fetch the message."""
  1056. adapter = self._adapter()
  1057. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  1058. payload = _make_posted_payload(room_id="room-1")
  1059. payload.fetch_message = AsyncMock()
  1060. await adapter._dispatch_message_posted(payload)
  1061. payload.fetch_message.assert_not_awaited()
  1062. adapter.handle_message.assert_not_called()
  1063. async def test_unknown_room_kind_follows_the_mention_lists(self):
  1064. """A server that never sets kind shows up as UNSPECIFIED; Chatto has
  1065. no group rooms, so it is treated as a channel: silent when unlisted,
  1066. gated when listed."""
  1067. adapter = self._adapter()
  1068. adapter._room_kinds["mystery-room"] = RoomKind.UNSPECIFIED
  1069. payload = _make_posted_payload(room_id="mystery-room")
  1070. payload.fetch_message = AsyncMock()
  1071. await adapter._dispatch_message_posted(payload)
  1072. payload.fetch_message.assert_not_awaited()
  1073. adapter.chatto_config.optional_mention_rooms.value = ["mystery-room"]
  1074. await self._dispatch(adapter, "mystery-room", "hi there")
  1075. adapter.handle_message.assert_called_once()
  1076. async def test_require_list_room_without_mention_is_discarded(self):
  1077. adapter = self._adapter()
  1078. adapter.chatto_config.require_mention_rooms.value = ["room-1"]
  1079. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  1080. await self._dispatch(adapter, "room-1", "hi there")
  1081. adapter.handle_message.assert_not_called()
  1082. async def test_require_list_room_with_mention_is_answered(self):
  1083. adapter = self._adapter()
  1084. adapter.chatto_config.require_mention_rooms.value = ["room-1"]
  1085. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  1086. await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
  1087. adapter.handle_message.assert_called_once()
  1088. async def test_broadcast_mention_counts_as_addressed(self):
  1089. """@here/@all address the bot too — one definition of 'addressed'
  1090. serves this gate and the someone-else check alike (FDR-006)."""
  1091. adapter = self._adapter()
  1092. adapter.chatto_config.require_mention_rooms.value = ["room-1"]
  1093. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  1094. await self._dispatch(adapter, "room-1", "@here standup in 5")
  1095. adapter.handle_message.assert_called_once()
  1096. async def test_optional_list_room_answers_without_a_mention(self):
  1097. adapter = self._adapter()
  1098. adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
  1099. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  1100. await self._dispatch(adapter, "room-1", "hi there")
  1101. adapter.handle_message.assert_called_once()
  1102. async def test_optional_list_beats_the_require_list_at_runtime(self):
  1103. """validate_config rejects the overlap, but if contradictory config
  1104. reaches a running adapter anyway, answering is safer than silence."""
  1105. adapter = self._adapter()
  1106. adapter.chatto_config.require_mention_rooms.value = ["room-1"]
  1107. adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
  1108. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  1109. assert adapter._room_policy("room-1") is RoomPolicy.OPEN
  1110. async def test_dm_is_answered_without_a_mention(self):
  1111. """The point of the room_kind check: mention gating must not mute DMs."""
  1112. adapter = self._adapter()
  1113. adapter._room_kinds["dm-1"] = RoomKind.DM
  1114. await self._dispatch(adapter, "dm-1", "hi there")
  1115. adapter.handle_message.assert_called_once()
  1116. # -- Inbound edits (edit-dispatch) --
  1117. def _make_edited_event(room_id="room-1", message_event_id="msg-1", actor_id="human-1"):
  1118. """A message_edited envelope whose payload the caller stubs."""
  1119. event = MagicMock()
  1120. event.id = "evt-edit-1"
  1121. event.kind = "message_edited"
  1122. event.actor_id = actor_id
  1123. payload = MagicMock()
  1124. payload.room_id = room_id
  1125. payload.message_event_id = message_event_id
  1126. # RealtimeEvent.get() only yields the payload for its own kind.
  1127. event.get = MagicMock(
  1128. side_effect=lambda kind: payload if kind == "message_edited" else None
  1129. )
  1130. return event, payload
  1131. class TestEditDispatch:
  1132. """message_edited events become corrections, late mentions or nothing."""
  1133. def _adapter(self):
  1134. adapter = _make_adapter()
  1135. adapter.chatto_config.allow_all_users.value = True
  1136. adapter.me = _make_user("bot-user-id", "hermes_bot")
  1137. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  1138. adapter.handle_message = AsyncMock()
  1139. return adapter
  1140. async def _edit(
  1141. self,
  1142. adapter,
  1143. body,
  1144. *,
  1145. message_id="msg-1",
  1146. room_id="room-1",
  1147. created_at=None,
  1148. updated_at=None,
  1149. ):
  1150. event, payload = _make_edited_event(
  1151. room_id=room_id, message_event_id=message_id
  1152. )
  1153. payload.fetch_message = AsyncMock(
  1154. return_value=_make_message(
  1155. body=body,
  1156. message_id=message_id,
  1157. room_id=room_id,
  1158. created_at=created_at,
  1159. updated_at=updated_at,
  1160. )
  1161. )
  1162. await adapter._handle_realtime_event(event)
  1163. return payload
  1164. async def _post_original(self, adapter, body, *, message_id, room_id):
  1165. """Dispatch the original posted message so its turn exists."""
  1166. payload = _make_posted_payload(room_id=room_id, message_event_id=message_id)
  1167. payload.fetch_message = AsyncMock(
  1168. return_value=_make_message(
  1169. body=body, message_id=message_id, room_id=room_id
  1170. )
  1171. )
  1172. await adapter._dispatch_message_posted(payload)
  1173. def test_edit_dispatch_defaults_on_with_five_minute_window(self):
  1174. adapter = self._adapter()
  1175. assert adapter.chatto_config.edit_dispatch.value is True
  1176. assert adapter.chatto_config.edit_window.value == 300
  1177. async def test_own_edit_echo_is_ignored(self):
  1178. """Our streaming edits echo back as message_edited — never inbound traffic."""
  1179. adapter = self._adapter()
  1180. event, payload = _make_edited_event(actor_id="bot-user-id")
  1181. payload.fetch_message = AsyncMock()
  1182. await adapter._handle_realtime_event(event)
  1183. payload.fetch_message.assert_not_awaited()
  1184. adapter.handle_message.assert_not_called()
  1185. async def test_disabled_feature_drops_edits(self):
  1186. adapter = self._adapter()
  1187. adapter.chatto_config.edit_dispatch.value = False
  1188. await self._edit(adapter, "corrected")
  1189. adapter.handle_message.assert_not_called()
  1190. async def test_read_only_room_costs_no_api_call(self):
  1191. """Silent-room gating sits before fetch_message, like for posts."""
  1192. adapter = self._adapter()
  1193. fetch = await self._edit(adapter, "corrected")
  1194. fetch.fetch_message.assert_not_awaited() # type: ignore[union-attr]
  1195. adapter.handle_message.assert_not_called()
  1196. async def test_mention_added_by_edit_starts_a_turn(self):
  1197. adapter = self._adapter()
  1198. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  1199. adapter.chatto_config.require_mention_rooms.value = ["room-1"]
  1200. await self._edit(adapter, "@hermes_bot corrected text")
  1201. adapter.handle_message.assert_called_once()
  1202. assert (
  1203. adapter.handle_message.call_args.args[0].text
  1204. == "@hermes_bot corrected text"
  1205. )
  1206. assert "msg-1" in adapter._dispatched_ids
  1207. async def test_edit_without_the_mention_stays_silent(self):
  1208. """Gates run against the new body — no mention in, no answer out."""
  1209. adapter = self._adapter()
  1210. adapter._room_kinds["room-1"] = RoomKind.CHANNEL
  1211. adapter.chatto_config.require_mention_rooms.value = ["room-1"]
  1212. await self._edit(adapter, "still no mention")
  1213. adapter.handle_message.assert_not_called()
  1214. async def test_already_answered_message_stays_answered(self):
  1215. """The retro-lock: editing an old answered message must not re-animate it."""
  1216. adapter = self._adapter()
  1217. adapter._dispatched_ids.append("msg-1")
  1218. await self._edit(adapter, "late fix")
  1219. adapter.handle_message.assert_not_called()
  1220. async def test_deleted_message_is_ignored(self):
  1221. adapter = self._adapter()
  1222. now = datetime.now(UTC)
  1223. event, payload = _make_edited_event()
  1224. payload.fetch_message = AsyncMock(
  1225. return_value=_make_message(body="gone", created_at=now, updated_at=now)
  1226. )
  1227. payload.fetch_message.return_value.deleted_at = now
  1228. await adapter._handle_realtime_event(event)
  1229. adapter.handle_message.assert_not_called()
  1230. async def test_stale_edit_outside_the_window_is_ignored(self):
  1231. adapter = self._adapter()
  1232. now = datetime.now(UTC)
  1233. await self._edit(
  1234. adapter,
  1235. "very late fix",
  1236. created_at=now,
  1237. updated_at=now + timedelta(seconds=400),
  1238. )
  1239. adapter.handle_message.assert_not_called()
  1240. async def test_fresh_edit_within_the_window_passes_gates(self):
  1241. adapter = self._adapter()
  1242. adapter._room_kinds["dm-room"] = RoomKind.DM
  1243. now = datetime.now(UTC)
  1244. await self._edit(
  1245. adapter,
  1246. "typo fixed",
  1247. room_id="dm-room",
  1248. created_at=now,
  1249. updated_at=now + timedelta(seconds=5),
  1250. )
  1251. adapter.handle_message.assert_called_once()
  1252. async def test_midrun_edit_restarts_the_turn(self):
  1253. adapter = self._adapter()
  1254. adapter._room_kinds["dm-room"] = RoomKind.DM
  1255. await self._post_original(
  1256. adapter, "orignal text", message_id="msg-run", room_id="dm-room"
  1257. )
  1258. first_event = adapter.handle_message.call_args.args[0]
  1259. await adapter.on_processing_start(first_event)
  1260. adapter.cancel_session_processing = AsyncMock()
  1261. await self._edit(
  1262. adapter, "original text", message_id="msg-run", room_id="dm-room"
  1263. )
  1264. adapter.cancel_session_processing.assert_awaited_once()
  1265. assert adapter.handle_message.await_count == 2
  1266. assert adapter.handle_message.await_args.args[0].text == "original text"
  1267. async def test_completion_hook_rearms_the_processing_map(self):
  1268. """After any outcome (incl. CANCELLED) the session accepts new turns."""
  1269. adapter = self._adapter()
  1270. adapter._room_kinds["dm-room"] = RoomKind.DM
  1271. await self._post_original(
  1272. adapter, "hello", message_id="msg-run", room_id="dm-room"
  1273. )
  1274. event = adapter.handle_message.call_args.args[0]
  1275. await adapter.on_processing_start(event)
  1276. session_key = adapter._session_key_for(event.source)
  1277. assert adapter._processing.get(session_key) == "msg-run"
  1278. await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
  1279. assert session_key not in adapter._processing
  1280. async def test_queued_followup_gets_corrected_in_place(self):
  1281. """A still-queued message answers later with its edited wording."""
  1282. from types import SimpleNamespace
  1283. adapter = self._adapter()
  1284. adapter._room_kinds["dm-room"] = RoomKind.DM
  1285. await self._post_original(
  1286. adapter, "queue me", message_id="msg-other", room_id="dm-room"
  1287. )
  1288. other_event = adapter.handle_message.call_args.args[0]
  1289. session_key = adapter._session_key_for(other_event.source)
  1290. pending = SimpleNamespace(message_id="msg-run", text="stale wording")
  1291. adapter._pending_messages[session_key] = pending
  1292. adapter._dispatched_ids.extend(["msg-other", "msg-run"])
  1293. adapter.cancel_session_processing = AsyncMock()
  1294. await self._edit(
  1295. adapter, "fixed wording", message_id="msg-run", room_id="dm-room"
  1296. )
  1297. assert pending.text == "fixed wording"
  1298. adapter.cancel_session_processing.assert_not_awaited()
  1299. # No second dispatch: the queued original carries the correction.
  1300. assert adapter.handle_message.await_count == 1
  1301. async def test_dm_command_edit_neither_reruns_nor_dispatches(self):
  1302. """/join ran when posted; its edited copy must not run twice or leak."""
  1303. adapter = self._adapter()
  1304. adapter._room_kinds["dm-room"] = RoomKind.DM
  1305. adapter._handle_dm_command = AsyncMock(return_value=True)
  1306. await self._edit(adapter, "/leave", room_id="dm-room")
  1307. adapter._handle_dm_command.assert_not_called()
  1308. adapter.handle_message.assert_not_called()
  1309. # -- Inbound attachments --
  1310. class TestInboundAttachments:
  1311. """Messages carrying files must reach the agent, body or not."""
  1312. @pytest_asyncio.fixture
  1313. def adapter(self):
  1314. adapter = _make_adapter()
  1315. adapter.chatto_config.allow_all_users.value = True
  1316. adapter.me = _make_user("bot-user-id", "hermes_bot")
  1317. adapter._room_kinds["room-1"] = RoomKind.DM
  1318. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  1319. adapter.handle_message = AsyncMock()
  1320. adapter._download_attachment_bytes = AsyncMock(
  1321. return_value=b"\x89PNG\r\n\x1a\nrest"
  1322. )
  1323. return adapter
  1324. async def test_image_attachment_becomes_media_url(self, adapter):
  1325. payload = _make_posted_payload()
  1326. adapter._chatto_client.get_room = AsyncMock()
  1327. message = _make_message(
  1328. body="look at this",
  1329. attachments=[_make_attachment("shot.png", "image/png")],
  1330. )
  1331. payload.fetch_message = AsyncMock(return_value=message)
  1332. with patch(
  1333. "adapter.cache_media_bytes",
  1334. return_value=_cached("/cache/shot.png", "image/png", "image"),
  1335. ):
  1336. await adapter._dispatch_message_posted(payload)
  1337. event = adapter.handle_message.call_args.args[0]
  1338. assert event.media_urls == ["/cache/shot.png"]
  1339. assert event.media_types == ["image/png"]
  1340. assert event.message_type == MessageType.PHOTO
  1341. async def test_attachment_only_message_is_not_dropped(self, adapter):
  1342. """The empty-body early return is what silently ate file uploads."""
  1343. payload = _make_posted_payload()
  1344. message = _make_message(
  1345. body="",
  1346. attachments=[_make_attachment("report.pdf", "application/pdf")],
  1347. )
  1348. payload.fetch_message = AsyncMock(return_value=message)
  1349. with patch(
  1350. "adapter.cache_media_bytes",
  1351. return_value=_cached("/cache/report.pdf", "application/pdf", "document"),
  1352. ):
  1353. await adapter._dispatch_message_posted(payload)
  1354. adapter.handle_message.assert_called_once()
  1355. event = adapter.handle_message.call_args.args[0]
  1356. assert event.message_type == MessageType.DOCUMENT
  1357. assert event.media_urls == ["/cache/report.pdf"]
  1358. async def test_empty_message_without_attachments_is_dropped(self, adapter):
  1359. payload = _make_posted_payload()
  1360. payload.fetch_message = AsyncMock(return_value=_make_message(body=""))
  1361. await adapter._dispatch_message_posted(payload)
  1362. adapter.handle_message.assert_not_called()
  1363. async def test_download_failure_still_delivers_the_text(self, adapter):
  1364. adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404"))
  1365. payload = _make_posted_payload()
  1366. payload.fetch_message = AsyncMock(
  1367. return_value=_make_message(
  1368. body="see attached",
  1369. attachments=[_make_attachment("a.png", "image/png")],
  1370. )
  1371. )
  1372. await adapter._dispatch_message_posted(payload)
  1373. event = adapter.handle_message.call_args.args[0]
  1374. assert event.text == "see attached"
  1375. assert event.media_urls == []
  1376. assert event.message_type == MessageType.TEXT
  1377. async def test_attachment_without_asset_url_is_skipped(self, adapter):
  1378. """Videos are announced before transcoding finishes."""
  1379. payload = _make_posted_payload()
  1380. att = _make_attachment("clip.mp4", "video/mp4")
  1381. att.asset_url = None
  1382. payload.fetch_message = AsyncMock(
  1383. return_value=_make_message(
  1384. body="clip",
  1385. attachments=[att],
  1386. )
  1387. )
  1388. await adapter._dispatch_message_posted(payload)
  1389. event = adapter.handle_message.call_args.args[0]
  1390. assert event.media_urls == []
  1391. adapter._download_attachment_bytes.assert_not_called()
  1392. async def test_document_wins_over_image(self, adapter):
  1393. """Mixed batches classify as DOCUMENT — that gates context injection."""
  1394. assert (
  1395. adapter._message_type_for_media_kinds(["image", "document"])
  1396. is MessageType.DOCUMENT
  1397. )
  1398. assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO
  1399. assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO
  1400. assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO
  1401. assert adapter._message_type_for_media_kinds([]) is MessageType.TEXT
  1402. async def test_oversized_attachment_is_rejected(self, adapter):
  1403. """The gateway media cap must bound what a hostile upload can buffer."""
  1404. import httpx
  1405. big = get_inbound_media_max_bytes() + 1
  1406. transport = httpx.MockTransport(
  1407. lambda request: httpx.Response(
  1408. 200,
  1409. headers={"content-length": str(big)},
  1410. content=b"x",
  1411. )
  1412. )
  1413. real_adapter = _make_adapter()
  1414. real_client_cls = httpx.AsyncClient
  1415. with (
  1416. patch(
  1417. "httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)
  1418. ),
  1419. pytest.raises(ValueError),
  1420. ):
  1421. await real_adapter._download_attachment_bytes(
  1422. "https://chat.example.com/a.png"
  1423. )
  1424. # NOTE: there are deliberately no tests for get_user(), set_presence() or
  1425. # set_custom_status() on the adapter. Those are not adapter responsibilities —
  1426. # callers use the chattolib client directly, which exposes them (client.get_user,
  1427. # client.update_presence, client.update_custom_status). The adapter only touches
  1428. # presence in connect()/disconnect().
  1429. # -- Room operations --
  1430. class TestRoomOperations:
  1431. """Test room creation and DM initiation."""
  1432. @pytest_asyncio.fixture
  1433. def adapter(self):
  1434. _clear_chatto_env()
  1435. cfg = _make_config()
  1436. adapter = ChattoAdapter(cfg)
  1437. adapter._chatto_client = MagicMock()
  1438. # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
  1439. # plain MagicMock raises TypeError, which create_room()/start_dm()
  1440. # swallow into a None return.
  1441. adapter._chatto_client.create_room = AsyncMock()
  1442. adapter._chatto_client.start_dm = AsyncMock()
  1443. adapter._token = "test-token"
  1444. adapter._room_names = {}
  1445. adapter._room_kinds = {}
  1446. return adapter
  1447. async def test_create_room(self, adapter):
  1448. adapter._chatto_client.create_room.return_value = _make_room(
  1449. "room-123",
  1450. "Test Room",
  1451. RoomKind.CHANNEL,
  1452. )
  1453. result = await adapter.create_room("Test Room", "A test room")
  1454. assert result == "room-123"
  1455. adapter._chatto_client.create_room.assert_called_once()
  1456. assert adapter._room_names["room-123"] == "Test Room"
  1457. async def test_start_dm(self, adapter):
  1458. adapter._chatto_client.start_dm.return_value = _make_room(
  1459. "dm-123",
  1460. "DM with user",
  1461. RoomKind.DM,
  1462. )
  1463. result = await adapter.start_dm("user-123")
  1464. assert result == "dm-123"
  1465. adapter._chatto_client.start_dm.assert_called_once()
  1466. assert adapter._room_kinds["dm-123"] == RoomKind.DM
  1467. # -- DM room management (/join, /leave) --
  1468. def _make_room_state(room, is_member):
  1469. """Build a RoomWithViewerState the way list_rooms()/get_room() return it."""
  1470. return RoomWithViewerState(
  1471. room=room,
  1472. viewer_state=RoomViewerState(is_member=is_member),
  1473. )
  1474. class TestDmRoomCommands:
  1475. """/join and /leave arrive over DMs, change server-side membership and
  1476. must never reach the agent pipeline."""
  1477. def _adapter(self):
  1478. adapter = _make_adapter()
  1479. adapter.chatto_config.allow_all_users.value = True
  1480. adapter.me = _make_user("bot-user-id", "hermes_bot")
  1481. adapter._user_cache["user-1"] = _make_user("user-1", "alice")
  1482. client = adapter._chatto_client
  1483. client.list_rooms = AsyncMock(return_value=[])
  1484. client.get_room_events = AsyncMock(return_value=MagicMock(events=[]))
  1485. client.join_room = AsyncMock()
  1486. client.leave_room = AsyncMock(return_value=True)
  1487. client.get_room = AsyncMock()
  1488. adapter._room_kinds["dm-1"] = RoomKind.DM
  1489. adapter.handle_message = AsyncMock()
  1490. adapter.send = AsyncMock()
  1491. return adapter
  1492. async def _dispatch(self, adapter, body, room_id="dm-1"):
  1493. payload = _make_posted_payload(room_id=room_id)
  1494. payload.fetch_message = AsyncMock(
  1495. return_value=_make_message(body=body, room_id=room_id)
  1496. )
  1497. await adapter._dispatch_message_posted(payload)
  1498. def _reply(self, adapter):
  1499. assert adapter.send.await_count == 1
  1500. return adapter.send.await_args.kwargs["content"]
  1501. async def test_join_silent_channel_names_the_env_lines(self):
  1502. """Users copy room IDs from this reply — an unlisted channel hands
  1503. them both mention-list lines verbatim instead of staying quiet."""
  1504. adapter = self._adapter()
  1505. adapter._seed_room = AsyncMock()
  1506. state = _make_room_state(
  1507. _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
  1508. )
  1509. adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
  1510. adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
  1511. await self._dispatch(adapter, "/join #deploy")
  1512. adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
  1513. assert adapter._joined_room_ids == ["room-9"]
  1514. reply = self._reply(adapter)
  1515. assert "Joined 'Deploy' (room-9)" in reply
  1516. assert "CHATTO_REQUIRE_MENTION_ROOMS=room-9" in reply
  1517. assert "CHATTO_OPTIONAL_MENTION_ROOMS=room-9" in reply
  1518. async def test_join_silent_channel_does_not_seed(self):
  1519. """Same rule as _refresh_rooms: history nothing will ever answer is
  1520. not pushed into context."""
  1521. adapter = self._adapter()
  1522. adapter._seed_room = AsyncMock()
  1523. state = _make_room_state(
  1524. _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
  1525. )
  1526. adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
  1527. adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
  1528. await self._dispatch(adapter, "/join #deploy")
  1529. adapter._seed_room.assert_not_awaited()
  1530. async def test_join_listed_channel_reports_policy_and_seeds(self):
  1531. adapter = self._adapter()
  1532. adapter.chatto_config.require_mention_rooms.value = ["room-9"]
  1533. adapter._seed_room = AsyncMock()
  1534. state = _make_room_state(
  1535. _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
  1536. )
  1537. adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
  1538. adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
  1539. await self._dispatch(adapter, "/join #deploy")
  1540. adapter._seed_room.assert_awaited_once_with("room-9")
  1541. reply = self._reply(adapter)
  1542. assert "answers only @mentions" in reply
  1543. assert "CHATTO_OPTIONAL_MENTION_ROOMS=" not in reply
  1544. async def test_join_room_with_unknown_kind_gets_the_channel_hint(self):
  1545. """A server that never sets kind counts as a channel too — the reply
  1546. carries the mention-list hint and nothing is seeded."""
  1547. adapter = self._adapter()
  1548. adapter._seed_room = AsyncMock()
  1549. state = _make_room_state(
  1550. _make_room("grp-9", "Deploy", RoomKind.UNSPECIFIED), False
  1551. )
  1552. adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
  1553. adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
  1554. await self._dispatch(adapter, "/join #deploy")
  1555. adapter._chatto_client.join_room.assert_awaited_once_with("grp-9")
  1556. assert adapter._joined_room_ids == ["grp-9"]
  1557. adapter._seed_room.assert_not_awaited()
  1558. reply = self._reply(adapter)
  1559. assert "Joined 'Deploy' (grp-9)" in reply
  1560. assert "CHATTO_REQUIRE_MENTION_ROOMS=grp-9" in reply
  1561. async def test_join_skips_rpc_when_already_a_member(self):
  1562. """Natively invited accounts hold membership already — they only need
  1563. seeding into the joined list (which silent channels skip)."""
  1564. adapter = self._adapter()
  1565. adapter._seed_room = AsyncMock()
  1566. state = _make_room_state(_make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
  1567. adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
  1568. await self._dispatch(adapter, "/join #deploy")
  1569. adapter._chatto_client.join_room.assert_not_awaited()
  1570. assert adapter._joined_room_ids == ["room-9"]
  1571. assert "Already a member" in self._reply(adapter)
  1572. async def test_join_unknown_name_reports_without_joining(self):
  1573. adapter = self._adapter()
  1574. adapter._chatto_client.list_rooms = AsyncMock(return_value=[])
  1575. await self._dispatch(adapter, "/join #nope")
  1576. adapter._chatto_client.join_room.assert_not_awaited()
  1577. assert "No room named '#nope'" in self._reply(adapter)
  1578. assert adapter._joined_room_ids == []
  1579. async def test_ambiguous_name_offers_the_candidate_ids(self):
  1580. adapter = self._adapter()
  1581. matches = [
  1582. _make_room_state(_make_room(f"r-{i}", "General", RoomKind.CHANNEL), False)
  1583. for i in range(2)
  1584. ]
  1585. adapter._chatto_client.list_rooms = AsyncMock(return_value=matches)
  1586. adapter._chatto_client.join_room = AsyncMock()
  1587. await self._dispatch(adapter, "/join #general")
  1588. adapter._chatto_client.join_room.assert_not_awaited()
  1589. reply = self._reply(adapter)
  1590. assert "r-0" in reply and "r-1" in reply
  1591. async def test_join_by_room_id_verifies_via_get_room(self):
  1592. adapter = self._adapter()
  1593. state = _make_room_state(
  1594. _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
  1595. )
  1596. adapter._chatto_client.get_room = AsyncMock(return_value=state)
  1597. adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
  1598. await self._dispatch(adapter, "/join room-9")
  1599. adapter._chatto_client.get_room.assert_awaited_once_with("room-9")
  1600. adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
  1601. assert adapter._joined_room_ids == ["room-9"]
  1602. async def test_leave_drops_the_room_from_the_joined_list(self):
  1603. adapter = self._adapter()
  1604. adapter._joined_room_ids = ["room-7"]
  1605. state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
  1606. adapter._chatto_client.get_room = AsyncMock(return_value=state)
  1607. await self._dispatch(adapter, "/leave room-7")
  1608. adapter._chatto_client.leave_room.assert_awaited_once_with("room-7")
  1609. assert adapter._joined_room_ids == []
  1610. assert "Left 'Deploy' (room-7)" in self._reply(adapter)
  1611. async def test_leave_refuses_direct_messages(self):
  1612. adapter = self._adapter()
  1613. state = _make_room_state(_make_room("dm-2", "", RoomKind.DM), True)
  1614. adapter._chatto_client.get_room = AsyncMock(return_value=state)
  1615. await self._dispatch(adapter, "/leave dm-2")
  1616. adapter._chatto_client.leave_room.assert_not_awaited()
  1617. assert "Direct messages cannot be left" in self._reply(adapter)
  1618. async def test_leave_refuses_home_channel(self):
  1619. """Leaving CHATTO_HOME_CHANNEL would break cron/notification delivery."""
  1620. adapter = self._adapter()
  1621. adapter.chatto_config.home_channel.value = "room-7"
  1622. adapter._joined_room_ids = ["room-7"]
  1623. state = _make_room_state(_make_room("room-7", "Home", RoomKind.CHANNEL), True)
  1624. adapter._chatto_client.get_room = AsyncMock(return_value=state)
  1625. await self._dispatch(adapter, "/leave room-7")
  1626. adapter._chatto_client.leave_room.assert_not_awaited()
  1627. assert "home channel" in self._reply(adapter)
  1628. async def test_commands_outside_dms_are_ignored(self):
  1629. adapter = self._adapter()
  1630. """In a channel the text is just a message — mention gating applies,
  1631. no command runs, nothing is sent."""
  1632. adapter.chatto_config.require_mention_rooms.value = ["chan-1"]
  1633. adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
  1634. await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
  1635. adapter._chatto_client.leave_room.assert_not_awaited()
  1636. adapter.handle_message.assert_not_called()
  1637. adapter.send.assert_not_called()
  1638. async def test_non_command_dm_falls_through_to_pipeline(self):
  1639. adapter = self._adapter()
  1640. await self._dispatch(adapter, "/status all good")
  1641. adapter.handle_message.assert_awaited_once()
  1642. adapter.send.assert_not_called()
  1643. async def test_missing_argument_gets_usage_reply(self):
  1644. adapter = self._adapter()
  1645. for body in ("/join", "/leave"):
  1646. adapter.send.reset_mock()
  1647. await self._dispatch(adapter, body)
  1648. assert self._reply(adapter).startswith("Usage:")
  1649. class TestJoinedRoomsRefresh:
  1650. """_refresh_rooms mirrors the joined list against server-side membership."""
  1651. def _adapter(self):
  1652. adapter = _make_adapter()
  1653. client = adapter._chatto_client
  1654. client.list_rooms = AsyncMock(return_value=[])
  1655. return adapter
  1656. async def test_drops_rooms_no_longer_joined(self):
  1657. adapter = self._adapter()
  1658. adapter._joined_room_ids = ["gone-1", "kept"]
  1659. kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
  1660. adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
  1661. await adapter._refresh_rooms()
  1662. assert adapter._joined_room_ids == ["kept"]
  1663. async def test_warns_once_when_home_channel_is_not_joined(self):
  1664. adapter = self._adapter()
  1665. adapter.chatto_config.home_channel.value = "home-x"
  1666. other = _make_room_state(_make_room("other", "Other", RoomKind.CHANNEL), True)
  1667. adapter._chatto_client.list_rooms = AsyncMock(return_value=[other])
  1668. await adapter._refresh_rooms()
  1669. assert adapter._home_warning_logged
  1670. await adapter._refresh_rooms()
  1671. assert adapter._home_warning_logged
  1672. async def test_no_warning_while_home_channel_is_member(self):
  1673. adapter = self._adapter()
  1674. adapter.chatto_config.home_channel.value = "home-x"
  1675. home = _make_room_state(_make_room("home-x", "Home", RoomKind.CHANNEL), True)
  1676. adapter._chatto_client.list_rooms = AsyncMock(return_value=[home])
  1677. await adapter._refresh_rooms()
  1678. assert not adapter._home_warning_logged
  1679. class TestSilentRoomRefresh:
  1680. """_refresh_rooms keeps silent rooms joined but skips seeding them."""
  1681. def _adapter(self, optional_mention_rooms):
  1682. adapter = _make_adapter()
  1683. adapter.chatto_config.optional_mention_rooms.value = optional_mention_rooms
  1684. client = adapter._chatto_client
  1685. client.list_rooms = AsyncMock(return_value=[])
  1686. client.join_room = AsyncMock()
  1687. adapter._seed_room = AsyncMock()
  1688. return adapter
  1689. async def test_silent_rooms_are_joined_but_not_seeded(self):
  1690. adapter = self._adapter(["team-1"])
  1691. news = _make_room_state(_make_room("news-1", "News", RoomKind.CHANNEL), True)
  1692. team = _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True)
  1693. adapter._chatto_client.list_rooms = AsyncMock(return_value=[news, team])
  1694. await adapter._refresh_rooms()
  1695. assert sorted(adapter._joined_room_ids) == ["news-1", "team-1"]
  1696. adapter._seed_room.assert_awaited_once_with("team-1")
  1697. async def test_join_log_tags_policies_kinds_and_universal(self, caplog):
  1698. adapter = self._adapter(["team-1"])
  1699. adapter.chatto_config.require_mention_rooms.value = ["dep-1"]
  1700. news = Room(id="news-1", name="News", kind=RoomKind.CHANNEL, universal=True)
  1701. states = [
  1702. _make_room_state(news, True),
  1703. _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True),
  1704. _make_room_state(_make_room("dep-1", "Deploy", RoomKind.CHANNEL), True),
  1705. _make_room_state(_make_room("dm-1", "Paula", RoomKind.DM), True),
  1706. ]
  1707. adapter._chatto_client.list_rooms = AsyncMock(return_value=states)
  1708. with caplog.at_level("INFO"):
  1709. await adapter._refresh_rooms()
  1710. joined = [m for m in caplog.messages if "currently joined" in m]
  1711. assert joined, "expected the joined-rooms summary log line"
  1712. summary = joined[-1]
  1713. assert "News (news-1) [read-only] [universal]" in summary
  1714. assert "Team (team-1)" in summary and "[every-message]" in summary
  1715. assert "Deploy (dep-1) [on-mention]" in summary
  1716. assert "Paula (dm-1) [dm]" in summary
  1717. async def test_join_log_appears_on_every_refresh(self, caplog):
  1718. adapter = self._adapter(["team-1"])
  1719. team = _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True)
  1720. adapter._chatto_client.list_rooms = AsyncMock(return_value=[team])
  1721. with caplog.at_level("INFO"):
  1722. await adapter._refresh_rooms()
  1723. await adapter._refresh_rooms()
  1724. joined = [m for m in caplog.messages if "currently joined" in m]
  1725. assert len(joined) == 2
  1726. # -- Constants --
  1727. class TestConstants:
  1728. """Test that constants are properly defined."""
  1729. def test_max_message_length(self):
  1730. assert _MAX_MESSAGE_LENGTH == 10000
  1731. def test_seen_cap(self):
  1732. assert _SEEN_CAP == 500
  1733. # -- Client creation credentials --
  1734. class TestEnvEnablement:
  1735. """hermes_env_enablement_fn seeds extra verbatim from set env vars only.
  1736. The gateway commits this dict onto the platform's extra unconditionally,
  1737. so a fabricated default would overwrite what the user set in config.yaml.
  1738. """
  1739. def _seed(self, monkeypatch, **env):
  1740. for key in _CHATTO_ENV_KEYS:
  1741. monkeypatch.delenv(key, raising=False)
  1742. for key, value in env.items():
  1743. monkeypatch.setenv(key, value)
  1744. return hermes_env_enablement_fn()
  1745. def test_no_env_vars_yield_an_empty_seed(self, monkeypatch):
  1746. assert self._seed(monkeypatch) == {}
  1747. def test_set_base_url_is_seeded(self, monkeypatch):
  1748. seed = self._seed(monkeypatch, CHATTO_BASE_URL=" https://chat.example.com ")
  1749. assert seed == {"base_url": "https://chat.example.com"}
  1750. def test_unset_base_url_is_not_fabricated(self, monkeypatch):
  1751. """The ChattoHQ default is the ConfigField's business, not a seed
  1752. entry that would clobber a YAML-configured base_url."""
  1753. assert "base_url" not in self._seed(monkeypatch)
  1754. def test_values_are_seeded_under_their_config_keys(self, monkeypatch):
  1755. seed = self._seed(
  1756. monkeypatch,
  1757. CHATTO_HOME_CHANNEL="room-9",
  1758. CHATTO_REQUIRE_MENTION_ROOMS="a,b",
  1759. CHATTO_AUTO_THREAD="false",
  1760. )
  1761. assert seed == {
  1762. "home_channel": "room-9",
  1763. "require_mention_rooms": "a,b",
  1764. "auto_thread": "false",
  1765. }
  1766. class TestOpenClientCredentials:
  1767. """Token wins; nothing configured raises as a normal creation failure."""
  1768. def _adapter(self):
  1769. return _make_adapter()
  1770. async def test_token_only_connects_without_login(self):
  1771. """The documented CHATTO_TOKEN path must not demand a login."""
  1772. adapter = self._adapter()
  1773. client = await adapter._open_client(
  1774. base_url="https://chat.example.com", login="", password="", token="t"
  1775. )
  1776. assert isinstance(client, ChattoClient)
  1777. async def test_no_credentials_raises_value_error(self):
  1778. """Misconfiguration reads as a logged creation failure, not a crash."""
  1779. adapter = self._adapter()
  1780. with pytest.raises(ValueError, match="neither token nor login"):
  1781. await adapter._open_client(
  1782. base_url="https://chat.example.com", login="", password=""
  1783. )
  1784. async def test_login_password_path_is_used_without_token(self):
  1785. adapter = self._adapter()
  1786. with patch("adapter.ChattoClient") as client_cls:
  1787. client_cls.login = AsyncMock(return_value=MagicMock())
  1788. await adapter._open_client(
  1789. base_url="https://chat.example.com", login="u", password="p"
  1790. )
  1791. client_cls.login.assert_awaited_once_with(
  1792. "u", "p", base_url="https://chat.example.com"
  1793. )
  1794. # -- Cron / standalone delivery --
  1795. class TestStandaloneSender:
  1796. """hermes_standalone_sender_fn normalises and splits like send() does —
  1797. cron output must not die on the server's per-message limit."""
  1798. def _posted(self, count):
  1799. """A client whose posts succeed; returns (client, sent_ids)."""
  1800. sent_ids = []
  1801. async def post(chat_id, body, **kwargs):
  1802. msg = MagicMock()
  1803. msg.id = f"msg-{len(sent_ids)}"
  1804. sent_ids.append(msg.id)
  1805. return msg
  1806. client = MagicMock()
  1807. client.post_message = AsyncMock(side_effect=post)
  1808. client.close = AsyncMock()
  1809. return client, sent_ids
  1810. async def _send(self, message, **kwargs):
  1811. cfg = _make_config(token="test-token")
  1812. client = MagicMock()
  1813. client.post_message = AsyncMock(return_value=MagicMock(id="msg-only"))
  1814. client.close = AsyncMock()
  1815. with patch("adapter.ChattoClient") as client_cls:
  1816. client_cls.return_value = client
  1817. result = await hermes_standalone_sender_fn(cfg, "room-1", message, **kwargs)
  1818. return result, client
  1819. async def test_short_message_posts_once(self):
  1820. result, client = await self._send("hello")
  1821. assert result.success is True
  1822. assert result.message_id == "msg-only"
  1823. client.post_message.assert_awaited_once()
  1824. async def test_long_message_is_split(self):
  1825. """A 12k-char cron job becomes several posts, first ID reported."""
  1826. cfg = _make_config(token="test-token")
  1827. client, sent_ids = self._posted(5)
  1828. with patch("adapter.ChattoClient") as client_cls:
  1829. client_cls.return_value = client
  1830. result = await hermes_standalone_sender_fn(cfg, "room-1", "x" * 12000)
  1831. assert result.success is True
  1832. assert result.message_id == "msg-0"
  1833. assert len(sent_ids) >= 2
  1834. for call in client.post_message.await_args_list:
  1835. assert len(call.args[1]) <= ChattoConstants.MAX_MESSAGE_LENGTH
  1836. async def test_failure_in_a_later_chunk_reports_failure(self):
  1837. cfg = _make_config(token="test-token")
  1838. client = MagicMock()
  1839. first = MagicMock()
  1840. first.id = "msg-1"
  1841. client.post_message = AsyncMock(side_effect=[first, RuntimeError("boom")])
  1842. client.close = AsyncMock()
  1843. with patch("adapter.ChattoClient") as client_cls:
  1844. client_cls.return_value = client
  1845. result = await hermes_standalone_sender_fn(cfg, "room-1", "y" * 12000)
  1846. assert result.success is False
  1847. assert "boom" in (result.error or "")
  1848. # The short-lived client is closed even on failure.
  1849. client.close.assert_awaited_once()
  1850. async def test_thread_id_reaches_every_chunk(self):
  1851. cfg = _make_config(token="test-token")
  1852. client, _ids = self._posted(5)
  1853. with patch("adapter.ChattoClient") as client_cls:
  1854. client_cls.return_value = client
  1855. result = await hermes_standalone_sender_fn(
  1856. cfg, "room-1", "z" * 12000, thread_id="root-9"
  1857. )
  1858. assert result.success is True
  1859. for call in client.post_message.await_args_list:
  1860. assert call.kwargs["thread_root_event_id"] == "root-9"