| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150 |
- """Unit tests for the Chatto platform adapter.
- Covers:
- - Emoji shortcode conversion
- - Adapter instantiation and properties
- - Registration and requirements
- - Basic functionality with chattolib
- - Message sending and reactions
- - User lookup (with caching)
- - Presence and custom status
- - DM room management (/join, /leave)
- All network calls are mocked — no real HTTP or WebSocket connections.
- """
- import asyncio
- import os
- import sys
- from datetime import UTC, datetime, timedelta
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- import pytest_asyncio
- # Import chattolib types for tests - using vendored chattolib from adapter
- # -- Path setup --
- # The Hermes agent itself is not a dependency of this plugin; point HERMES_ROOT
- # at a checkout to run these tests outside a deployed agent.
- PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
- sys.path.insert(0, PLUGIN_ROOT)
- sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
- # Importing ``adapter`` puts the vendored dependencies on sys.path as a side
- # effect, but import sorting may legally move that import after the chattolib
- # and gateway ones — so bootstrap the vendor paths explicitly instead.
- from vendor_path import setup_vendor_path
- setup_vendor_path()
- from chattolib.client import ChattoClient
- from chattolib.realtime_types import ReactionPayload
- from chattolib.types import (
- Asset,
- AssetUpload,
- AssetUrl,
- DirectoryMember,
- Message,
- MessageAttachment,
- PresenceStatus,
- Room,
- RoomKind,
- RoomViewerState,
- RoomWithViewerState,
- User,
- )
- from gateway.config import PlatformConfig
- from gateway.platforms.base import (
- BasePlatformAdapter,
- CachedMedia,
- MessageType,
- ProcessingOutcome,
- SendResult,
- get_inbound_media_max_bytes,
- )
- from adapter import (
- ChannelPolicy,
- ChattoAdapter,
- HermesChatType,
- _capabilities,
- chat_type_for_room_kind,
- register,
- )
- from adapter import (
- hermes_check_fn as check_requirements,
- )
- from adapter import (
- hermes_validate_config as validate_config,
- )
- from platform_config import ChattoConstants
- _EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
- _MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
- _SEEN_CAP = ChattoConstants.SEEN_CAP
- # -- Helpers --
- class _MockPluginContext:
- """Minimal mock for the plugin registration context."""
- def __init__(self):
- self.registered_names = []
- self.registered_kwargs = None
- def register_platform(self, **kwargs):
- from gateway.platform_registry import PlatformEntry, platform_registry
- entry = PlatformEntry(
- name=kwargs["name"],
- label=kwargs.get("label", kwargs["name"]),
- adapter_factory=kwargs.get("adapter_factory"),
- check_fn=kwargs.get("check_fn"),
- validate_config=kwargs.get("validate_config"),
- is_connected=kwargs.get("is_connected"),
- required_env=kwargs.get("required_env", []),
- source="plugin",
- )
- platform_registry.register(entry)
- self.registered_names.append(kwargs["name"])
- self.registered_kwargs = kwargs
- def _ensure_chatto_registered():
- """Register the platform so Platform(PLATFORM_NAME) resolves."""
- from gateway.platform_registry import platform_registry
- if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
- ctx = _MockPluginContext()
- register(ctx)
- _CHATTO_ENV_KEYS = [
- "CHATTO_BASE_URL",
- "CHATTO_LOGIN",
- "CHATTO_PASSWORD",
- "CHATTO_TOKEN",
- "CHATTO_HOME_CHANNEL",
- "CHATTO_REQUIRE_MENTION_ROOMS",
- "CHATTO_OPTIONAL_MENTION_ROOMS",
- "CHATTO_ALLOWED_USERS",
- "CHATTO_ALLOW_ALL_USERS",
- "CHATTO_AUTO_THREAD",
- "CHATTO_REACTIONS",
- ]
- def _clear_chatto_env(monkeypatch=None):
- """Remove all CHATTO_* env vars so tests start from a clean slate."""
- for key in _CHATTO_ENV_KEYS:
- if monkeypatch is not None:
- monkeypatch.delenv(key, raising=False)
- else:
- os.environ.pop(key, None)
- def _make_config(**extra_overrides):
- """Create a minimal PlatformConfig for testing."""
- _ensure_chatto_registered()
- extra = {"base_url": "https://chat.example.com"}
- extra.update(extra_overrides)
- return PlatformConfig(enabled=True, extra=extra)
- def _make_room(room_id, name, kind):
- """Build a real chattolib Room, as the client would return."""
- return Room(
- id=room_id,
- name=name,
- kind=kind,
- description="",
- archived=False,
- group_id="",
- universal=kind != RoomKind.DM,
- )
- def _make_user(user_id, login):
- """Build a real chattolib User, as the member directory would return."""
- return User(id=user_id, login=login, display_name=login.replace("_", " ").title())
- def _make_attachment(filename, content_type, url="https://cdn.example.com/a"):
- """Build a real MessageAttachment carrying a (pre-signed) asset URL."""
- return MessageAttachment(
- id="asset-" + filename,
- filename=filename,
- content_type=content_type,
- asset_url=AssetUrl(url=url),
- )
- def _make_message(
- body="hi",
- attachments=None,
- message_id="msg-1",
- room_id="room-1",
- created_at=None,
- updated_at=None,
- ):
- """Build a real chattolib Message, as fetch_message() would return."""
- return Message(
- id=message_id,
- room_id=room_id,
- created_at=created_at,
- actor_id="user-1",
- body=body,
- attachments=list(attachments or []),
- updated_at=updated_at,
- )
- def _make_posted_payload(room_id="room-1", message_event_id="msg-1"):
- """A message_posted payload whose fetch_message() the caller stubs."""
- payload = MagicMock()
- payload.room_id = room_id
- payload.message_event_id = message_event_id
- payload.thread_root_event_id = None
- return payload
- def _cached(path, media_type, kind):
- """The CachedMedia that cache_media_bytes() would return for an attachment."""
- return CachedMedia(path=path, media_type=media_type, kind=kind, display_name="f")
- def _make_adapter(**extra_overrides):
- """Create a ChattoAdapter with mocked config."""
- _clear_chatto_env()
- cfg = _make_config(**extra_overrides)
- adapter = ChattoAdapter(cfg)
- adapter._chatto_client = MagicMock()
- return adapter
- # -- Emoji shortcode conversion --
- class TestEmojiShortcode:
- """Test emoji to shortcode mapping."""
- def test_emoji_to_shortcode_exists(self):
- assert isinstance(_EMOJI_TO_SHORTCODE, dict)
- assert len(_EMOJI_TO_SHORTCODE) > 0
- def test_emoji_to_shortcode_common_emojis(self):
- assert _EMOJI_TO_SHORTCODE.get("👍") == "thumbsup"
- assert _EMOJI_TO_SHORTCODE.get("👎") == "thumbsdown"
- assert _EMOJI_TO_SHORTCODE.get("❤️") == "heart"
- assert _EMOJI_TO_SHORTCODE.get("❤") == "heart"
- assert _EMOJI_TO_SHORTCODE.get("✅") == "white_check_mark"
- assert _EMOJI_TO_SHORTCODE.get("❌") == "x"
- # Sent when a message addresses someone else — without the mapping the
- # raw emoji would go out as a shortcode and the server would reject it.
- assert _EMOJI_TO_SHORTCODE.get("🫥") == "dotted_line_face"
- # -- Adapter instantiation and properties --
- class TestAdapterInstantiation:
- """Test ChattoAdapter creation and basic properties."""
- def test_adapter_creation(self):
- cfg = _make_config()
- adapter = ChattoAdapter(cfg)
- assert adapter is not None
- # Platform members created dynamically from a plugin name carry the
- # name upper-cased; the registered identity is the value.
- assert adapter.platform.value == ChattoConstants.PLATFORM_NAME
- def test_adapter_max_message_length(self):
- """The framework chunks via max_message_length_for_chat(), which reads
- the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096
- when it is missing."""
- cfg = _make_config()
- adapter = ChattoAdapter(cfg)
- assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
- assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH
- def test_adapter_splits_long_messages(self):
- cfg = _make_config()
- adapter = ChattoAdapter(cfg)
- assert adapter.splits_long_messages is True
- def test_adapter_threads_enabled_by_default(self):
- """There is no capability flag for threads — Chatto threading is driven
- by the auto_thread setting, which defaults to on."""
- cfg = _make_config()
- adapter = ChattoAdapter(cfg)
- assert adapter.chatto_config.auto_thread.value is True
- # -- Registration and requirements --
- class TestRegistration:
- """Test plugin registration."""
- def test_register_called(self):
- ctx = _MockPluginContext()
- register(ctx)
- assert ChattoConstants.PLATFORM_NAME in ctx.registered_names
- assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME
- assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL
- assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH
- def test_platform_hint_advertises_media_sending(self):
- """This hint is the only thing telling the model the channel can carry
- files — without it the agent has no idea it can deliver an image."""
- ctx = _MockPluginContext()
- register(ctx)
- assert "MEDIA:/absolute/path/to/file" in ctx.registered_kwargs["platform_hint"]
- def test_platform_hint_rules_out_markdown_for_local_files(self):
- """gateway's extract_images only matches https?:// — markdown pointing at
- a local file is never extracted and lands in the chat as literal text."""
- ctx = _MockPluginContext()
- register(ctx)
- hint = ctx.registered_kwargs["platform_hint"]
- assert "Do NOT use markdown image syntax for local files" in hint
- def test_startup_logs_the_capabilities(self, caplog):
- with caplog.at_level("INFO"):
- register(_MockPluginContext())
- logged = "\n".join(caplog.messages)
- assert "images" in logged
- assert "reactions" in logged
- def test_capabilities_skips_methods_we_do_not_override(self):
- """An inherited base fallback is not a capability — claiming it would
- promise the user something the adapter cannot actually do."""
- assert "video" in _capabilities()
- with patch.object(ChattoAdapter, "send_video", BasePlatformAdapter.send_video):
- assert "video" not in _capabilities()
- def test_check_requirements(self):
- assert check_requirements() is True
- def test_check_requirements_missing(self):
- with patch("builtins.__import__", side_effect=ImportError("no chattolib")):
- assert check_requirements() is False
- def test_validate_config(self):
- _clear_chatto_env()
- os.environ["CHATTO_BASE_URL"] = "https://chat.test"
- os.environ["CHATTO_LOGIN"] = "user"
- os.environ["CHATTO_PASSWORD"] = "pass"
- cfg = PlatformConfig(enabled=True, extra={"base_url": "https://chat.test"})
- assert validate_config(cfg) is True
- _clear_chatto_env()
- # -- Config validation: mention-list conflicts and unknown keys --
- class TestValidateConfigGates:
- """hermes_validate_config rejects contradictory mention lists and warns
- about extra keys that look like misspelled config fields."""
- def _cfg(self, **extra):
- _clear_chatto_env()
- base = {
- "base_url": "https://chat.test",
- "login": "user",
- "password": "pass",
- }
- base.update(extra)
- return PlatformConfig(enabled=True, extra=base)
- def test_room_on_both_mention_lists_is_rejected(self):
- cfg = self._cfg(
- require_mention_rooms=["room-1", "room-2"],
- optional_mention_rooms=["room-2", "room-3"],
- )
- assert validate_config(cfg) is False
- def test_disjoint_mention_lists_are_accepted(self):
- cfg = self._cfg(
- require_mention_rooms=["room-1"],
- optional_mention_rooms=["room-3"],
- )
- assert validate_config(cfg) is True
- @pytest.mark.parametrize("key", ["require_mention_rooms", "optional_mention_rooms"])
- def test_known_keys_do_not_warn(self, key, caplog):
- cfg = self._cfg(**{key: ["room-1"]})
- with caplog.at_level("WARNING"):
- validate_config(cfg)
- assert not [m for m in caplog.messages if "matches no config field" in m]
- def test_a_typo_extra_key_warns_with_a_suggestion(self, caplog):
- """A typo'd key silently resolves to its default otherwise — the
- warning is the only thing telling the user it never reached us."""
- cfg = self._cfg(require_mention_channles=["room-1"])
- with caplog.at_level("WARNING"):
- validate_config(cfg)
- warnings = [m for m in caplog.messages if "matches no config field" in m]
- assert len(warnings) == 1
- assert "require_mention_channles" in warnings[0]
- assert "did you mean 'require_mention_rooms'" in warnings[0]
- def test_a_legacy_renamed_key_suggests_its_successor(self, caplog):
- """Removed fields read as near-misses of their successors."""
- cfg = self._cfg(require_mention=True)
- with caplog.at_level("WARNING"):
- validate_config(cfg)
- warnings = [m for m in caplog.messages if "matches no config field" in m]
- assert len(warnings) == 1
- assert "did you mean 'require_mention_rooms'" in warnings[0]
- @pytest.mark.parametrize(
- "key",
- [
- "_enabled_explicit",
- "group_sessions_per_user",
- "reply_in_thread",
- "gateway_restart_notification",
- ],
- )
- def test_gateway_keys_do_not_warn(self, key, caplog):
- """The gateway places shared keys into every platform's extra — they
- resemble nothing of ours, so near-miss matching leaves them alone."""
- cfg = self._cfg(**{key: True})
- with caplog.at_level("WARNING"):
- validate_config(cfg)
- assert not [m for m in caplog.messages if "matches no config field" in m]
- # -- Send functionality --
- class TestSend:
- """Test message sending functionality."""
- @pytest_asyncio.fixture
- def adapter(self):
- _clear_chatto_env()
- cfg = _make_config()
- adapter = ChattoAdapter(cfg)
- adapter._chatto_client = MagicMock()
- adapter._chatto_client.post_message = AsyncMock()
- return adapter
- async def test_send_calls_post_message(self, adapter):
- mock_msg = MagicMock()
- mock_msg.id = "msg-123"
- adapter._chatto_client.post_message.return_value = mock_msg
- result = await adapter.send("room-1", "Hello world")
- assert result.success is True
- assert result.message_id == "msg-123"
- adapter._chatto_client.post_message.assert_called_once()
- async def test_send_with_thread(self, adapter):
- mock_msg = MagicMock()
- mock_msg.id = "msg-456"
- adapter._chatto_client.post_message.return_value = mock_msg
- result = await adapter.send("room-1", "Hello", reply_to="thread-123")
- assert result.success is True
- call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
- assert call_kwargs["thread_root_event_id"] == "thread-123"
- async def test_send_raw_response_stays_dict_shaped(self, adapter):
- # The cron scheduler calls .get() on SendResult.raw_response when a job
- # targets a thread; a chattolib Message there crashes delivery
- # bookkeeping after the send and duplicates the message standalone.
- adapter._chatto_client.post_message.return_value = _make_message(
- body="Hello world", message_id="msg-789"
- )
- result = await adapter.send("room-1", "Hello world")
- assert result.success is True
- assert not result.raw_response or isinstance(result.raw_response, dict)
- # -- Reactions --
- class TestReactions:
- """Test reaction functionality."""
- @pytest_asyncio.fixture
- def adapter(self):
- _clear_chatto_env()
- cfg = _make_config()
- adapter = ChattoAdapter(cfg)
- adapter._chatto_client = MagicMock()
- adapter._chatto_client.add_reaction = AsyncMock()
- adapter._chatto_client.remove_reaction = AsyncMock()
- return adapter
- async def test_send_reaction(self, adapter):
- await adapter.add_reaction("room-1", "msg-1", "👍")
- adapter._chatto_client.add_reaction.assert_called_once()
- async def test_remove_reaction(self, adapter):
- await adapter.remove_reaction("room-1", "msg-1", "👍")
- adapter._chatto_client.remove_reaction.assert_called_once()
- async def test_on_processing_start_adds_eyes_reaction(self, adapter):
- """on_processing_start should call add_reaction with 👀."""
- event = MagicMock()
- event.message_id = "msg-1"
- event.source.chat_id = "room-1"
- await adapter.on_processing_start(event)
- adapter._chatto_client.add_reaction.assert_called_once()
- call_kwargs = adapter._chatto_client.add_reaction.call_args.kwargs
- assert call_kwargs["message_event_id"] == "msg-1"
- assert call_kwargs["room_id"] == "room-1"
- assert call_kwargs["emoji"] == "eyes"
- async def test_on_processing_start_empty_message_id(self, adapter):
- """on_processing_start should skip reaction when message_id is empty."""
- event = MagicMock()
- event.message_id = None
- event.source.chat_id = "room-1"
- await adapter.on_processing_start(event)
- adapter._chatto_client.add_reaction.assert_not_called()
- async def test_on_processing_start_reactions_disabled(self, adapter):
- """on_processing_start should skip when reactions config is False."""
- adapter.chatto_config.reactions.value = False
- event = MagicMock()
- event.message_id = "msg-1"
- event.source.chat_id = "room-1"
- await adapter.on_processing_start(event)
- adapter._chatto_client.add_reaction.assert_not_called()
- # -- Edit and Delete Messages --
- class TestMessageEditing:
- """Test message editing and deletion."""
- @pytest_asyncio.fixture
- def adapter(self):
- _clear_chatto_env()
- cfg = _make_config()
- adapter = ChattoAdapter(cfg)
- adapter._chatto_client = MagicMock()
- adapter._chatto_client.update_message = AsyncMock()
- adapter._chatto_client.delete_message = AsyncMock(return_value=True)
- adapter._token = "test-token"
- return adapter
- async def test_edit_message(self, adapter):
- result = await adapter.edit_message("room-1", "msg-1", "New content")
- assert result.success is True
- adapter._chatto_client.update_message.assert_called_once()
- call_kwargs = adapter._chatto_client.update_message.call_args.kwargs
- assert call_kwargs["room_id"] == "room-1"
- assert call_kwargs["event_id"] == "msg-1"
- assert call_kwargs["body"] == "New content"
- async def test_edit_message_marks_own_edit_seen(self, adapter):
- """The edit echoes back as message_edited — it must not look inbound."""
- mock_msg = MagicMock()
- mock_msg.id = "msg-1"
- adapter._chatto_client.update_message.return_value = mock_msg
- await adapter.edit_message("room-1", "msg-1", "New content")
- assert adapter._is_seen("msg-1") is True
- async def test_edit_message_too_long_refuses(self, adapter):
- """Overlong content must fall back to send() (which splits), not be
- silently truncated into a lossy edit."""
- result = await adapter.edit_message(
- "room-1",
- "msg-1",
- "x" * (_MAX_MESSAGE_LENGTH + 1),
- )
- assert result.success is False
- adapter._chatto_client.update_message.assert_not_called()
- async def test_edit_message_empty_content(self, adapter):
- result = await adapter.edit_message("room-1", "msg-1", "")
- assert result.success is False
- adapter._chatto_client.update_message.assert_not_called()
- async def test_edit_message_error_is_retryable(self, adapter):
- from chattolib.exceptions import ChattoError
- adapter._chatto_client.update_message.side_effect = ChattoError("boom")
- result = await adapter.edit_message("room-1", "msg-1", "New content")
- assert result.success is False
- assert result.retryable is True
- async def test_delete_message(self, adapter):
- result = await adapter.delete_message("room-1", "msg-1")
- assert result is True
- adapter._chatto_client.delete_message.assert_called_once()
- call_kwargs = adapter._chatto_client.delete_message.call_args.kwargs
- assert call_kwargs["room_id"] == "room-1"
- assert call_kwargs["event_id"] == "msg-1"
- async def test_delete_message_missing_ids(self, adapter):
- assert await adapter.delete_message("", "msg-1") is False
- assert await adapter.delete_message("room-1", "") is False
- adapter._chatto_client.delete_message.assert_not_called()
- async def test_delete_message_error_returns_false(self, adapter):
- from chattolib.exceptions import ChattoError
- adapter._chatto_client.delete_message.side_effect = ChattoError("nope")
- assert await adapter.delete_message("room-1", "msg-1") is False
- # -- Outgoing text formatting --
- class TestFormatMessage:
- """format_message() only fixes what renders wrong in Chatto."""
- def test_normalises_crlf(self):
- adapter = _make_adapter()
- assert adapter.format_message("a\r\nb\rc") == "a\nb\nc"
- def test_collapses_excess_blank_lines(self):
- adapter = _make_adapter()
- assert adapter.format_message("a\n\n\n\n\n\nb") == "a\n\n\nb"
- def test_leaves_markdown_untouched(self):
- adapter = _make_adapter()
- text = "**bold** `code`\n\n```py\nx = 1\n```\n- item"
- assert adapter.format_message(text) == text
- def test_empty_content(self):
- adapter = _make_adapter()
- assert adapter.format_message("") == ""
- # -- Handoff threads --
- class TestHandoffThread:
- """create_handoff_thread() anchors a handoff on a seed message."""
- @pytest_asyncio.fixture
- def adapter(self):
- adapter = _make_adapter()
- adapter._chatto_client.post_message = AsyncMock()
- adapter._chatto_client.follow_thread = AsyncMock()
- return adapter
- async def test_returns_seed_message_id(self, adapter):
- mock_msg = MagicMock()
- mock_msg.id = "seed-1"
- adapter._chatto_client.post_message.return_value = mock_msg
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- result = await adapter.create_handoff_thread("room-1", "Refactor run")
- assert result == "seed-1"
- assert (
- adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
- )
- adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1")
- # Our own seed must not come back in as inbound traffic.
- assert adapter._is_seen("seed-1") is True
- async def test_dm_has_no_threads(self, adapter):
- adapter._room_kinds["dm-1"] = RoomKind.DM
- assert await adapter.create_handoff_thread("dm-1", "x") is None
- adapter._chatto_client.post_message.assert_not_called()
- async def test_seed_post_failure(self, adapter):
- adapter._chatto_client.post_message.side_effect = RuntimeError("down")
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- assert await adapter.create_handoff_thread("room-1", "x") is None
- # -- Native file / video / audio delivery --
- class TestUploadAsset:
- """Drives the real _upload_asset against real chattolib result types.
- The send_* tests stub _upload_asset out, so a wrong field name on the
- chattolib response was invisible to them until it hit a live server.
- """
- @pytest_asyncio.fixture
- def adapter(self, tmp_path):
- adapter = _make_adapter()
- self.path = tmp_path / "horse.jpg"
- self.path.write_bytes(b"\xff\xd8\xff" + b"x" * 100)
- upload = AssetUpload(upload_id="up-1", room_id="room-1")
- adapter._chatto_client.create_upload = AsyncMock(return_value=upload)
- adapter._chatto_client.upload_chunk = AsyncMock(return_value=upload)
- adapter._chatto_client.complete_upload = AsyncMock(
- return_value=(
- upload,
- Asset(
- id="asset-9",
- filename="horse.jpg",
- content_type="image/jpeg",
- size=103,
- ),
- )
- )
- return adapter
- async def test_returns_the_asset_id(self, adapter):
- assert await adapter._upload_asset("room-1", str(self.path)) == "asset-9"
- async def test_chunks_go_to_the_upload_id_from_create_upload(self, adapter):
- """AssetUpload calls it upload_id, not id — reading the wrong field made
- every upload fail with 'CreateUpload returned no upload ID'."""
- await adapter._upload_asset("room-1", str(self.path))
- assert (
- adapter._chatto_client.upload_chunk.await_args.kwargs["upload_id"] == "up-1"
- )
- async def test_missing_upload_id_is_reported(self, adapter):
- adapter._chatto_client.create_upload = AsyncMock(
- return_value=AssetUpload(upload_id="", room_id="room-1")
- )
- assert await adapter._upload_asset("room-1", str(self.path)) is None
- class TestNativeSends:
- """send_document/_video/_voice upload instead of apologising in text."""
- @pytest_asyncio.fixture
- def adapter(self):
- adapter = _make_adapter()
- adapter._chatto_client.post_message = AsyncMock()
- adapter._upload_asset = AsyncMock(return_value="asset-1")
- adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
- mock_msg = MagicMock()
- mock_msg.id = "msg-1"
- adapter._chatto_client.post_message.return_value = mock_msg
- return adapter
- @pytest.mark.parametrize(
- "method,arg_name",
- [
- ("send_document", "file_path"),
- ("send_video", "video_path"),
- ("send_voice", "audio_path"),
- ("send_image_file", "image_path"),
- ],
- )
- async def test_uploads_and_attaches(self, adapter, method, arg_name):
- result = await getattr(adapter, method)(
- "room-1",
- **{arg_name: "/tmp/thing.bin"},
- caption="here you go",
- )
- assert result.success is True
- adapter._upload_asset.assert_called_once_with("room-1", "/tmp/thing.bin")
- call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
- assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
- assert call_kwargs["body"] == "here you go"
- async def test_unsafe_path_falls_back_to_notice(self, adapter):
- adapter.validate_media_delivery_path = MagicMock(return_value=None)
- adapter.send = AsyncMock(return_value=SendResult(success=True))
- await adapter.send_document("room-1", "/etc/shadow")
- adapter._upload_asset.assert_not_called()
- # Never echo the host path into chat.
- sent_text = adapter.send.call_args.args[1]
- assert "/etc/shadow" not in sent_text
- async def test_upload_failure_falls_back_to_notice(self, adapter):
- adapter._upload_asset = AsyncMock(return_value=None)
- adapter.send = AsyncMock(return_value=SendResult(success=True))
- await adapter.send_video("room-1", "/tmp/clip.mp4", caption="a clip")
- sent_text = adapter.send.call_args.args[1]
- assert sent_text.startswith("a clip\n")
- assert "/tmp/clip.mp4" not in sent_text
- async def test_attachment_reply_threads_only_with_auto_thread(self, adapter):
- """auto_thread=false must keep attachment replies in the room, exactly
- like text replies — no quiet thread under the incoming message."""
- adapter.chatto_config.auto_thread.value = False
- await adapter.send_document("room-1", "/tmp/thing.bin", reply_to="incoming-1")
- call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
- assert call_kwargs["thread_root_event_id"] == ""
- async def test_attachment_reply_threads_under_the_incoming_message(self, adapter):
- await adapter.send_document("room-1", "/tmp/thing.bin", reply_to="incoming-1")
- call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
- assert call_kwargs["thread_root_event_id"] == "incoming-1"
- async def test_attachment_metadata_thread_id_wins_over_reply_to(self, adapter):
- await adapter.send_document(
- "room-1",
- "/tmp/thing.bin",
- reply_to="incoming-1",
- metadata={"thread_id": "root-9"},
- )
- call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
- assert call_kwargs["thread_root_event_id"] == "root-9"
- async def test_attachment_replies_to_dms_never_thread(self, adapter):
- """DMs don't support threads — not even with auto_thread enabled."""
- adapter._room_kinds["dm-1"] = RoomKind.DM
- await adapter.send_document("dm-1", "/tmp/thing.bin", reply_to="incoming-1")
- call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
- assert call_kwargs["thread_root_event_id"] == ""
- # -- Batched image delivery --
- class TestSendMultipleImages:
- """A batch of images belongs in ONE Chatto message."""
- @pytest_asyncio.fixture
- def adapter(self):
- adapter = _make_adapter()
- adapter._chatto_client.post_message = AsyncMock()
- mock_msg = MagicMock()
- mock_msg.id = "msg-1"
- adapter._chatto_client.post_message.return_value = mock_msg
- adapter._upload_asset = AsyncMock(side_effect=["asset-1", "asset-2"])
- adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
- return adapter
- async def test_bundles_into_single_message(self, adapter):
- await adapter.send_multiple_images(
- "room-1",
- [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
- )
- adapter._chatto_client.post_message.assert_called_once()
- call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
- assert call_kwargs["attachment_asset_ids"] == ["asset-1", "asset-2"]
- assert call_kwargs["body"] == "first\nsecond"
- async def test_single_image_uses_base_path(self, adapter):
- """One image is not a batch — leave it to the base implementation.
- Also pins the send_image_file signature: the base class calls it with
- ``image_path=`` as a keyword, so a renamed parameter degrades every
- native image send to a text notice.
- """
- adapter.send_image_file = AsyncMock(return_value=SendResult(success=True))
- await adapter.send_multiple_images("room-1", [("file:///tmp/a.png", "only")])
- adapter._upload_asset.assert_not_called()
- adapter.send_image_file.assert_called_once()
- assert adapter.send_image_file.call_args.kwargs["image_path"] == "/tmp/a.png"
- async def test_partial_upload_failure_still_sends_the_rest(self, adapter):
- adapter._upload_asset = AsyncMock(side_effect=["asset-1", None])
- await adapter.send_multiple_images(
- "room-1",
- [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
- )
- call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
- assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
- async def test_file_uri_is_unquoted(self, adapter):
- await adapter.send_multiple_images(
- "room-1",
- [("file:///tmp/a%20b.png", ""), ("/tmp/c.png", "")],
- )
- first_path = adapter._upload_asset.call_args_list[0].args[1]
- assert first_path == "/tmp/a b.png"
- # -- Reaction event forwarding --
- class TestReactionForwarding:
- """Human reactions reach the gateway's reaction hook surface."""
- @pytest_asyncio.fixture
- def adapter(self):
- adapter = _make_adapter()
- adapter.me = _make_user("bot-user-id", "hermes_bot")
- return adapter
- def _event(self, kind, actor_id="human-1"):
- event = MagicMock()
- event.id = "evt-1"
- event.kind = kind
- event.actor_id = actor_id
- payload = ReactionPayload(
- room_id="room-1",
- message_event_id="msg-1",
- emoji="thumbsup",
- )
- # RealtimeEvent.get() only yields the payload for its own kind.
- event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
- return event
- async def test_forwards_added_reaction(self, adapter):
- handler = AsyncMock()
- adapter.set_reaction_handler(handler)
- await adapter._handle_realtime_event(self._event("reaction_added"))
- handler.assert_called_once()
- payload = handler.call_args.args[0]
- assert payload["event_name"] == "reaction:added"
- assert payload["reaction"] == "thumbsup"
- assert payload["channel_id"] == "room-1"
- assert payload["message_ts"] == "msg-1"
- assert payload["user_id"] == "human-1"
- assert payload["item_type"] == "message"
- async def test_forwards_removed_reaction(self, adapter):
- handler = AsyncMock()
- adapter.set_reaction_handler(handler)
- await adapter._handle_realtime_event(self._event("reaction_removed"))
- assert handler.call_args.args[0]["event_name"] == "reaction:removed"
- async def test_ignores_own_lifecycle_reactions(self, adapter):
- """👀/✅/❌ are ours — forwarding them would feed the agent its own markers."""
- handler = AsyncMock()
- adapter.set_reaction_handler(handler)
- await adapter._handle_realtime_event(
- self._event("reaction_added", actor_id="bot-user-id"),
- )
- handler.assert_not_called()
- async def test_no_handler_registered_is_harmless(self, adapter):
- await adapter._handle_realtime_event(self._event("reaction_added"))
- async def test_handler_exception_does_not_propagate(self, adapter):
- adapter.set_reaction_handler(AsyncMock(side_effect=RuntimeError("hook boom")))
- await adapter._handle_realtime_event(self._event("reaction_added"))
- # -- chat_type mapping --
- class TestChatTypeMapping:
- """RoomKind -> the gateway's chat_type vocabulary."""
- def test_maps_known_kinds(self):
- assert chat_type_for_room_kind(RoomKind.DM) is HermesChatType.DM
- assert chat_type_for_room_kind(RoomKind.CHANNEL) is HermesChatType.CHANNEL
- def test_unknown_kind_is_group_never_dm(self):
- """'dm' drives session isolation — never guess it for an unknown kind."""
- assert chat_type_for_room_kind(RoomKind.UNSPECIFIED) is HermesChatType.GROUP
- assert chat_type_for_room_kind(None) is HermesChatType.GROUP
- def test_values_match_the_gateway_vocabulary(self):
- """session.py:161 declares exactly these strings; SessionSource.description
- and the PII-redacted context prompt branch on them."""
- assert [t.value for t in HermesChatType] == ["dm", "group", "channel", "thread"]
- def test_is_a_plain_str_at_call_sites(self):
- assert HermesChatType.CHANNEL == "channel"
- assert f"{HermesChatType.DM}" == "dm"
- async def test_get_chat_info_reports_channel(self):
- adapter = _make_adapter()
- adapter._room_names["room-1"] = "Team"
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- info = await adapter.get_chat_info("room-1")
- assert info == {"name": "Team", "type": "channel"}
- async def test_get_chat_info_reports_dm(self):
- adapter = _make_adapter()
- adapter._room_kinds["dm-1"] = RoomKind.DM
- assert (await adapter.get_chat_info("dm-1"))["type"] == "dm"
- async def test_dispatch_stamps_the_mapped_chat_type(self):
- """The value reaching build_source decides how the agent is told where
- it is — a raw RoomKind lands in SessionSource.description's else-branch."""
- adapter = _make_adapter()
- adapter.chatto_config.allow_all_users.value = True
- adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
- adapter.me = _make_user("bot-user-id", "hermes_bot")
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- adapter._user_cache["user-1"] = _make_user("user-1", "alice")
- adapter.handle_message = AsyncMock()
- payload = _make_posted_payload()
- payload.fetch_message = AsyncMock(return_value=_make_message(body="hi"))
- await adapter._dispatch_message_posted(payload)
- event = adapter.handle_message.call_args.args[0]
- assert event.source.chat_type == "channel"
- # -- Presence --
- class TestPresence:
- """Presence is a server-side TTL: stop re-announcing and the bot goes offline."""
- def _adapter(self):
- adapter = _make_adapter()
- adapter._chatto_client.update_presence = AsyncMock()
- return adapter
- async def test_refresh_loop_keeps_reannouncing_online(self):
- """The bug: a single announce at connect lapses and never comes back."""
- adapter = self._adapter()
- adapter._closing = False
- with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
- task = asyncio.create_task(adapter._presence_refresh_loop())
- for _ in range(200):
- if adapter._chatto_client.update_presence.await_count >= 3:
- break
- await asyncio.sleep(0.01)
- adapter._closing = True
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
- assert adapter._chatto_client.update_presence.await_count >= 3
- for call in adapter._chatto_client.update_presence.await_args_list:
- assert call.kwargs["status"] == PresenceStatus.ONLINE
- async def test_refresh_survives_a_failing_call(self):
- """One bad tick must not kill the loop and strand the bot offline."""
- adapter = self._adapter()
- adapter._closing = False
- adapter._chatto_client.update_presence = AsyncMock(
- side_effect=[RuntimeError("boom"), None, None]
- )
- with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
- task = asyncio.create_task(adapter._presence_refresh_loop())
- for _ in range(200):
- if adapter._chatto_client.update_presence.await_count >= 3:
- break
- await asyncio.sleep(0.01)
- adapter._closing = True
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
- assert adapter._chatto_client.update_presence.await_count >= 3
- async def test_announce_online_reports_failure(self):
- adapter = self._adapter()
- adapter._chatto_client.update_presence = AsyncMock(
- side_effect=RuntimeError("nope")
- )
- assert await adapter._announce_online() is False
- async def test_disconnect_does_not_broadcast_offline(self):
- """chattolib raises ValueError on OFFLINE — going offline means stopping."""
- adapter = self._adapter()
- adapter._chatto_client.close = AsyncMock()
- client = adapter._chatto_client # disconnect() drops the reference
- await adapter.disconnect()
- client.update_presence.assert_not_called()
- # -- Mentions of other people --
- class TestForeignMention:
- """In an open channel (CHATTO_OPTIONAL_MENTION_ROOMS) the bot reads
- everything, so a message aimed at a named colleague would otherwise get an
- unsolicited answer. Acknowledge it with 🫥 and stay out of the conversation."""
- def _adapter(self, **overrides):
- adapter = _make_adapter()
- adapter.chatto_config.allow_all_users.value = True
- adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
- adapter.chatto_config.reactions.value = True
- for key, value in overrides.items():
- getattr(adapter.chatto_config, key).value = value
- adapter.me = _make_user("bot-user-id", "hermes_bot")
- adapter._user_cache["user-1"] = _make_user("user-1", "alice")
- adapter.handle_message = AsyncMock()
- adapter.add_reaction = AsyncMock(return_value=True)
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- adapter._room_kinds["dm-1"] = RoomKind.DM
- # The directory knows bob and nobody else. Resolution is
- # case-insensitive, as Chatto's single mention namespace guarantees
- # (FDR-006) — the fake models that at its own boundary.
- adapter._chatto_client.get_user = AsyncMock(
- side_effect=lambda **kw: (
- DirectoryMember(user=_make_user("user-2", "bob"))
- if str(kw.get("login", "")).lower() == "bob"
- else None
- )
- )
- return adapter
- async def _dispatch(self, adapter, body, room_id="room-1"):
- payload = _make_posted_payload(room_id=room_id)
- payload.fetch_message = AsyncMock(
- return_value=_make_message(body=body, room_id=room_id)
- )
- await adapter._dispatch_message_posted(payload)
- async def test_message_for_someone_else_is_only_acknowledged(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "@bob can you take a look?")
- adapter.handle_message.assert_not_called()
- adapter.add_reaction.assert_awaited_once()
- assert adapter.add_reaction.await_args.args[2] == "🫥"
- async def test_being_mentioned_alongside_someone_else_still_answers(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "@bob and @hermes_bot, thoughts?")
- adapter.handle_message.assert_called_once()
- adapter.add_reaction.assert_not_awaited()
- async def test_several_people_addressed_and_none_of_them_us(self):
- adapter = self._adapter()
- adapter._chatto_client.get_user = AsyncMock(
- side_effect=lambda **kw: (
- DirectoryMember(user=_make_user("u", kw["login"]))
- if kw.get("login") in {"bob", "carol"}
- else None
- )
- )
- await self._dispatch(adapter, "@bob @carol schaut mal drüber")
- adapter.handle_message.assert_not_called()
- adapter.add_reaction.assert_awaited_once()
- async def test_a_real_handle_after_an_unknown_one_still_counts(self):
- """The scan must not stop at the first token it cannot resolve."""
- adapter = self._adapter()
- await self._dispatch(adapter, "@nonexistent @bob schaut mal drüber")
- adapter.handle_message.assert_not_called()
- adapter.add_reaction.assert_awaited_once()
- async def test_being_named_among_several_others_still_answers(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "@bob @hermes_bot @carol — was meint ihr?")
- adapter.handle_message.assert_called_once()
- adapter.add_reaction.assert_not_awaited()
- async def test_broadcast_alongside_a_named_colleague_still_answers(self):
- """@here keeps the bot in the audience; naming bob as well does not
- remove it."""
- adapter = self._adapter()
- await self._dispatch(adapter, "@here @bob schaut mal drüber")
- adapter.handle_message.assert_called_once()
- adapter.add_reaction.assert_not_awaited()
- async def test_broadcast_mentions_address_the_bot_too(self):
- adapter = self._adapter()
- # @here/@all are Chatto's virtual handles (FDR-006) — the bot is in
- # their audience. @channel/@everyone do not exist as handles; they are
- # unknown candidates, and an unknown handle is not someone else either,
- # so the message still gets answered rather than acknowledged away.
- for body in ("@here standup in 5", "@all heads up", "@everyone hi"):
- adapter.handle_message.reset_mock()
- await self._dispatch(adapter, body)
- adapter.handle_message.assert_called_once()
- async def test_broadcast_mention_alone_is_not_someone_else(self):
- """A pure @here must not be read as a message aimed at another person."""
- adapter = self._adapter()
- await self._dispatch(adapter, "@here standup in 5")
- adapter.handle_message.assert_called_once()
- adapter.add_reaction.assert_not_awaited()
- async def test_trailing_punctuation_still_resolves_the_handle(self):
- """'frag @bob.' must resolve bob — a sentence-ending dot is not part of
- the handle, mirroring the web frontend's extraction."""
- adapter = self._adapter()
- await self._dispatch(adapter, "frag mal @bob.")
- adapter.handle_message.assert_not_called()
- adapter.add_reaction.assert_awaited_once()
- async def test_foreign_mention_matching_is_case_insensitive(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "@BOB can you take a look?")
- adapter.handle_message.assert_not_called()
- adapter.add_reaction.assert_awaited_once()
- async def test_own_mention_matching_is_case_insensitive(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "@HERMES_BOT ping")
- adapter.handle_message.assert_called_once()
- async def test_mentions_inside_a_fenced_block_are_ignored(self):
- """Upstream does not resolve mentions in code blocks; quoting '@bob'
- there must not silence us."""
- adapter = self._adapter()
- await self._dispatch(adapter, "so sieht ein ping aus:\n```\n@bob\n```")
- adapter.handle_message.assert_called_once()
- async def test_mentions_inside_an_inline_span_are_ignored(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "schreib `@bob` mal direkt an")
- adapter.handle_message.assert_called_once()
- async def test_an_unterminated_fence_shields_the_rest(self):
- """An unclosed fence runs to the end of the text, like upstream."""
- adapter = self._adapter()
- await self._dispatch(adapter, "beispiel:\n``` bash\n@bob schaut")
- adapter.handle_message.assert_called_once()
- async def test_broadcast_handles_are_shared_across_cases_in_the_cache(self):
- """The directory cache keys on the lowercased handle: one lookup
- serves '@bob' and '@BOB' alike."""
- adapter = self._adapter()
- await self._dispatch(adapter, "@BOB ping")
- await self._dispatch(adapter, "@bob again")
- assert adapter._chatto_client.get_user.await_count == 1
- async def test_talking_about_mentions_is_not_a_mention(self):
- """Verbatim from the field: the instruction to send a mention later must
- not read as a mention now. '@-mention' is not a handle anyone holds."""
- adapter = self._adapter()
- await self._dispatch(
- adapter,
- "Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den "
- 'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
- )
- adapter.handle_message.assert_called_once()
- adapter.add_reaction.assert_not_awaited()
- async def test_handle_nobody_holds_is_not_a_mention(self):
- """A plausible-looking @token that resolves to no user is not someone
- else — answering a false positive beats falling silent on one."""
- adapter = self._adapter()
- adapter._chatto_client.get_user = AsyncMock(return_value=None)
- await self._dispatch(adapter, "gilt das auch für @nonexistent_person?")
- adapter.handle_message.assert_called_once()
- adapter.add_reaction.assert_not_awaited()
- async def test_a_resolvable_handle_is_looked_up_once(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "@bob ping")
- await self._dispatch(adapter, "@bob again")
- assert adapter._chatto_client.get_user.await_count == 1
- assert adapter.handle_message.await_count == 0
- async def test_an_email_address_is_not_a_mention(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "schreib an bob@example.com")
- adapter.handle_message.assert_called_once()
- adapter._chatto_client.get_user.assert_not_awaited()
- async def test_plain_message_is_unaffected(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "how do I reset the cache?")
- adapter.handle_message.assert_called_once()
- async def test_dms_are_answered_even_when_they_name_someone_else(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "@bob said the build is red", room_id="dm-1")
- adapter.handle_message.assert_called_once()
- async def test_require_list_keeps_discarding_without_a_reaction(self):
- """The require gate wins: it drops the message before we get here, and
- it deliberately says nothing at all."""
- adapter = self._adapter(
- optional_mention_rooms=[],
- require_mention_rooms=["room-1"],
- )
- await self._dispatch(adapter, "@bob can you take a look?")
- adapter.handle_message.assert_not_called()
- adapter.add_reaction.assert_not_awaited()
- async def test_silence_holds_when_reactions_are_disabled(self):
- adapter = self._adapter(reactions=False)
- await self._dispatch(adapter, "@bob can you take a look?")
- adapter.handle_message.assert_not_called()
- adapter.add_reaction.assert_not_awaited()
- # -- Channel mention policies --
- class TestChannelPolicies:
- """Every non-DM room is opt-in via the two mention lists — Chatto has no
- group rooms, and an unknown kind counts as a channel too. A DM is already
- addressed at the bot."""
- def _adapter(self):
- adapter = _make_adapter()
- adapter.chatto_config.allow_all_users.value = True
- adapter.me = _make_user("bot-user-id", "hermes_bot")
- adapter._user_cache["user-1"] = _make_user("user-1", "alice")
- adapter.handle_message = AsyncMock()
- return adapter
- async def _dispatch(self, adapter, room_id, body):
- payload = _make_posted_payload(room_id=room_id)
- payload.fetch_message = AsyncMock(
- return_value=_make_message(body=body, room_id=room_id)
- )
- await adapter._dispatch_message_posted(payload)
- async def test_unlisted_room_is_silent(self):
- """Opt-in by default: an unlisted room is not dispatched at all."""
- adapter = self._adapter()
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
- adapter.handle_message.assert_not_called()
- async def test_unlisted_room_is_dropped_before_any_api_call(self):
- """Silent rooms must not even fetch the message."""
- adapter = self._adapter()
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- payload = _make_posted_payload(room_id="room-1")
- payload.fetch_message = AsyncMock()
- await adapter._dispatch_message_posted(payload)
- payload.fetch_message.assert_not_awaited()
- adapter.handle_message.assert_not_called()
- async def test_unknown_room_kind_follows_the_mention_lists(self):
- """A server that never sets kind shows up as UNSPECIFIED; Chatto has
- no group rooms, so it is treated as a channel: silent when unlisted,
- gated when listed."""
- adapter = self._adapter()
- adapter._room_kinds["mystery-room"] = RoomKind.UNSPECIFIED
- payload = _make_posted_payload(room_id="mystery-room")
- payload.fetch_message = AsyncMock()
- await adapter._dispatch_message_posted(payload)
- payload.fetch_message.assert_not_awaited()
- adapter.chatto_config.optional_mention_rooms.value = ["mystery-room"]
- await self._dispatch(adapter, "mystery-room", "hi there")
- adapter.handle_message.assert_called_once()
- async def test_require_list_room_without_mention_is_discarded(self):
- adapter = self._adapter()
- adapter.chatto_config.require_mention_rooms.value = ["room-1"]
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- await self._dispatch(adapter, "room-1", "hi there")
- adapter.handle_message.assert_not_called()
- async def test_require_list_room_with_mention_is_answered(self):
- adapter = self._adapter()
- adapter.chatto_config.require_mention_rooms.value = ["room-1"]
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
- adapter.handle_message.assert_called_once()
- async def test_broadcast_mention_counts_as_addressed(self):
- """@here/@all address the bot too — one definition of 'addressed'
- serves this gate and the someone-else check alike (FDR-006)."""
- adapter = self._adapter()
- adapter.chatto_config.require_mention_rooms.value = ["room-1"]
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- await self._dispatch(adapter, "room-1", "@here standup in 5")
- adapter.handle_message.assert_called_once()
- async def test_optional_list_room_answers_without_a_mention(self):
- adapter = self._adapter()
- adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- await self._dispatch(adapter, "room-1", "hi there")
- adapter.handle_message.assert_called_once()
- async def test_optional_list_beats_the_require_list_at_runtime(self):
- """validate_config rejects the overlap, but if contradictory config
- reaches a running adapter anyway, answering is safer than silence."""
- adapter = self._adapter()
- adapter.chatto_config.require_mention_rooms.value = ["room-1"]
- adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- assert adapter._room_policy("room-1") is ChannelPolicy.OPEN
- async def test_dm_is_answered_without_a_mention(self):
- """The point of the room_kind check: mention gating must not mute DMs."""
- adapter = self._adapter()
- adapter._room_kinds["dm-1"] = RoomKind.DM
- await self._dispatch(adapter, "dm-1", "hi there")
- adapter.handle_message.assert_called_once()
- # -- Inbound edits (edit-dispatch) --
- def _make_edited_event(room_id="room-1", message_event_id="msg-1", actor_id="human-1"):
- """A message_edited envelope whose payload the caller stubs."""
- event = MagicMock()
- event.id = "evt-edit-1"
- event.kind = "message_edited"
- event.actor_id = actor_id
- payload = MagicMock()
- payload.room_id = room_id
- payload.message_event_id = message_event_id
- # RealtimeEvent.get() only yields the payload for its own kind.
- event.get = MagicMock(
- side_effect=lambda kind: payload if kind == "message_edited" else None
- )
- return event, payload
- class TestEditDispatch:
- """message_edited events become corrections, late mentions or nothing."""
- def _adapter(self):
- adapter = _make_adapter()
- adapter.chatto_config.allow_all_users.value = True
- adapter.me = _make_user("bot-user-id", "hermes_bot")
- adapter._user_cache["user-1"] = _make_user("user-1", "alice")
- adapter.handle_message = AsyncMock()
- return adapter
- async def _edit(
- self,
- adapter,
- body,
- *,
- message_id="msg-1",
- room_id="room-1",
- created_at=None,
- updated_at=None,
- ):
- event, payload = _make_edited_event(
- room_id=room_id, message_event_id=message_id
- )
- payload.fetch_message = AsyncMock(
- return_value=_make_message(
- body=body,
- message_id=message_id,
- room_id=room_id,
- created_at=created_at,
- updated_at=updated_at,
- )
- )
- await adapter._handle_realtime_event(event)
- return payload
- async def _post_original(self, adapter, body, *, message_id, room_id):
- """Dispatch the original posted message so its turn exists."""
- payload = _make_posted_payload(room_id=room_id, message_event_id=message_id)
- payload.fetch_message = AsyncMock(
- return_value=_make_message(
- body=body, message_id=message_id, room_id=room_id
- )
- )
- await adapter._dispatch_message_posted(payload)
- def test_edit_dispatch_defaults_on_with_five_minute_window(self):
- adapter = self._adapter()
- assert adapter.chatto_config.edit_dispatch.value is True
- assert adapter.chatto_config.edit_window.value == 300
- async def test_own_edit_echo_is_ignored(self):
- """Our streaming edits echo back as message_edited — never inbound traffic."""
- adapter = self._adapter()
- event, payload = _make_edited_event(actor_id="bot-user-id")
- payload.fetch_message = AsyncMock()
- await adapter._handle_realtime_event(event)
- payload.fetch_message.assert_not_awaited()
- adapter.handle_message.assert_not_called()
- async def test_disabled_feature_drops_edits(self):
- adapter = self._adapter()
- adapter.chatto_config.edit_dispatch.value = False
- await self._edit(adapter, "corrected")
- adapter.handle_message.assert_not_called()
- async def test_read_only_room_costs_no_api_call(self):
- """Silent-room gating sits before fetch_message, like for posts."""
- adapter = self._adapter()
- fetch = await self._edit(adapter, "corrected")
- fetch.fetch_message.assert_not_awaited() # type: ignore[union-attr]
- adapter.handle_message.assert_not_called()
- async def test_mention_added_by_edit_starts_a_turn(self):
- adapter = self._adapter()
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- adapter.chatto_config.require_mention_rooms.value = ["room-1"]
- await self._edit(adapter, "@hermes_bot corrected text")
- adapter.handle_message.assert_called_once()
- assert (
- adapter.handle_message.call_args.args[0].text
- == "@hermes_bot corrected text"
- )
- assert "msg-1" in adapter._dispatched_ids
- async def test_edit_without_the_mention_stays_silent(self):
- """Gates run against the new body — no mention in, no answer out."""
- adapter = self._adapter()
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
- adapter.chatto_config.require_mention_rooms.value = ["room-1"]
- await self._edit(adapter, "still no mention")
- adapter.handle_message.assert_not_called()
- async def test_already_answered_message_stays_answered(self):
- """The retro-lock: editing an old answered message must not re-animate it."""
- adapter = self._adapter()
- adapter._dispatched_ids.append("msg-1")
- await self._edit(adapter, "late fix")
- adapter.handle_message.assert_not_called()
- async def test_deleted_message_is_ignored(self):
- adapter = self._adapter()
- now = datetime.now(UTC)
- event, payload = _make_edited_event()
- payload.fetch_message = AsyncMock(
- return_value=_make_message(body="gone", created_at=now, updated_at=now)
- )
- payload.fetch_message.return_value.deleted_at = now
- await adapter._handle_realtime_event(event)
- adapter.handle_message.assert_not_called()
- async def test_stale_edit_outside_the_window_is_ignored(self):
- adapter = self._adapter()
- now = datetime.now(UTC)
- await self._edit(
- adapter,
- "very late fix",
- created_at=now,
- updated_at=now + timedelta(seconds=400),
- )
- adapter.handle_message.assert_not_called()
- async def test_fresh_edit_within_the_window_passes_gates(self):
- adapter = self._adapter()
- adapter._room_kinds["dm-room"] = RoomKind.DM
- now = datetime.now(UTC)
- await self._edit(
- adapter,
- "typo fixed",
- room_id="dm-room",
- created_at=now,
- updated_at=now + timedelta(seconds=5),
- )
- adapter.handle_message.assert_called_once()
- async def test_midrun_edit_restarts_the_turn(self):
- adapter = self._adapter()
- adapter._room_kinds["dm-room"] = RoomKind.DM
- await self._post_original(
- adapter, "orignal text", message_id="msg-run", room_id="dm-room"
- )
- first_event = adapter.handle_message.call_args.args[0]
- await adapter.on_processing_start(first_event)
- adapter.cancel_session_processing = AsyncMock()
- await self._edit(
- adapter, "original text", message_id="msg-run", room_id="dm-room"
- )
- adapter.cancel_session_processing.assert_awaited_once()
- assert adapter.handle_message.await_count == 2
- assert adapter.handle_message.await_args.args[0].text == "original text"
- async def test_completion_hook_rearms_the_processing_map(self):
- """After any outcome (incl. CANCELLED) the session accepts new turns."""
- adapter = self._adapter()
- adapter._room_kinds["dm-room"] = RoomKind.DM
- await self._post_original(
- adapter, "hello", message_id="msg-run", room_id="dm-room"
- )
- event = adapter.handle_message.call_args.args[0]
- await adapter.on_processing_start(event)
- session_key = adapter._session_key_for(event.source)
- assert adapter._processing.get(session_key) == "msg-run"
- await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
- assert session_key not in adapter._processing
- async def test_queued_followup_gets_corrected_in_place(self):
- """A still-queued message answers later with its edited wording."""
- from types import SimpleNamespace
- adapter = self._adapter()
- adapter._room_kinds["dm-room"] = RoomKind.DM
- await self._post_original(
- adapter, "queue me", message_id="msg-other", room_id="dm-room"
- )
- other_event = adapter.handle_message.call_args.args[0]
- session_key = adapter._session_key_for(other_event.source)
- pending = SimpleNamespace(message_id="msg-run", text="stale wording")
- adapter._pending_messages[session_key] = pending
- adapter._dispatched_ids.extend(["msg-other", "msg-run"])
- adapter.cancel_session_processing = AsyncMock()
- await self._edit(
- adapter, "fixed wording", message_id="msg-run", room_id="dm-room"
- )
- assert pending.text == "fixed wording"
- adapter.cancel_session_processing.assert_not_awaited()
- # No second dispatch: the queued original carries the correction.
- assert adapter.handle_message.await_count == 1
- async def test_dm_command_edit_neither_reruns_nor_dispatches(self):
- """/join ran when posted; its edited copy must not run twice or leak."""
- adapter = self._adapter()
- adapter._room_kinds["dm-room"] = RoomKind.DM
- adapter._handle_dm_command = AsyncMock(return_value=True)
- await self._edit(adapter, "/leave", room_id="dm-room")
- adapter._handle_dm_command.assert_not_called()
- adapter.handle_message.assert_not_called()
- # -- Inbound attachments --
- class TestInboundAttachments:
- """Messages carrying files must reach the agent, body or not."""
- @pytest_asyncio.fixture
- def adapter(self):
- adapter = _make_adapter()
- adapter.chatto_config.allow_all_users.value = True
- adapter.me = _make_user("bot-user-id", "hermes_bot")
- adapter._room_kinds["room-1"] = RoomKind.DM
- adapter._user_cache["user-1"] = _make_user("user-1", "alice")
- adapter.handle_message = AsyncMock()
- adapter._download_attachment_bytes = AsyncMock(
- return_value=b"\x89PNG\r\n\x1a\nrest"
- )
- return adapter
- async def test_image_attachment_becomes_media_url(self, adapter):
- payload = _make_posted_payload()
- adapter._chatto_client.get_room = AsyncMock()
- message = _make_message(
- body="look at this",
- attachments=[_make_attachment("shot.png", "image/png")],
- )
- payload.fetch_message = AsyncMock(return_value=message)
- with patch(
- "adapter.cache_media_bytes",
- return_value=_cached("/cache/shot.png", "image/png", "image"),
- ):
- await adapter._dispatch_message_posted(payload)
- event = adapter.handle_message.call_args.args[0]
- assert event.media_urls == ["/cache/shot.png"]
- assert event.media_types == ["image/png"]
- assert event.message_type == MessageType.PHOTO
- async def test_attachment_only_message_is_not_dropped(self, adapter):
- """The empty-body early return is what silently ate file uploads."""
- payload = _make_posted_payload()
- message = _make_message(
- body="",
- attachments=[_make_attachment("report.pdf", "application/pdf")],
- )
- payload.fetch_message = AsyncMock(return_value=message)
- with patch(
- "adapter.cache_media_bytes",
- return_value=_cached("/cache/report.pdf", "application/pdf", "document"),
- ):
- await adapter._dispatch_message_posted(payload)
- adapter.handle_message.assert_called_once()
- event = adapter.handle_message.call_args.args[0]
- assert event.message_type == MessageType.DOCUMENT
- assert event.media_urls == ["/cache/report.pdf"]
- async def test_empty_message_without_attachments_is_dropped(self, adapter):
- payload = _make_posted_payload()
- payload.fetch_message = AsyncMock(return_value=_make_message(body=""))
- await adapter._dispatch_message_posted(payload)
- adapter.handle_message.assert_not_called()
- async def test_download_failure_still_delivers_the_text(self, adapter):
- adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404"))
- payload = _make_posted_payload()
- payload.fetch_message = AsyncMock(
- return_value=_make_message(
- body="see attached",
- attachments=[_make_attachment("a.png", "image/png")],
- )
- )
- await adapter._dispatch_message_posted(payload)
- event = adapter.handle_message.call_args.args[0]
- assert event.text == "see attached"
- assert event.media_urls == []
- assert event.message_type == MessageType.TEXT
- async def test_attachment_without_asset_url_is_skipped(self, adapter):
- """Videos are announced before transcoding finishes."""
- payload = _make_posted_payload()
- att = _make_attachment("clip.mp4", "video/mp4")
- att.asset_url = None
- payload.fetch_message = AsyncMock(
- return_value=_make_message(
- body="clip",
- attachments=[att],
- )
- )
- await adapter._dispatch_message_posted(payload)
- event = adapter.handle_message.call_args.args[0]
- assert event.media_urls == []
- adapter._download_attachment_bytes.assert_not_called()
- async def test_document_wins_over_image(self, adapter):
- """Mixed batches classify as DOCUMENT — that gates context injection."""
- assert (
- adapter._message_type_for_media_kinds(["image", "document"])
- is MessageType.DOCUMENT
- )
- assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO
- assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO
- assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO
- assert adapter._message_type_for_media_kinds([]) is MessageType.TEXT
- async def test_oversized_attachment_is_rejected(self, adapter):
- """The gateway media cap must bound what a hostile upload can buffer."""
- import httpx
- big = get_inbound_media_max_bytes() + 1
- transport = httpx.MockTransport(
- lambda request: httpx.Response(
- 200,
- headers={"content-length": str(big)},
- content=b"x",
- )
- )
- real_adapter = _make_adapter()
- real_client_cls = httpx.AsyncClient
- with (
- patch(
- "httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)
- ),
- pytest.raises(ValueError),
- ):
- await real_adapter._download_attachment_bytes(
- "https://chat.example.com/a.png"
- )
- # NOTE: there are deliberately no tests for get_user(), set_presence() or
- # set_custom_status() on the adapter. Those are not adapter responsibilities —
- # callers use the chattolib client directly, which exposes them (client.get_user,
- # client.update_presence, client.update_custom_status). The adapter only touches
- # presence in connect()/disconnect().
- # -- Room operations --
- class TestRoomOperations:
- """Test room creation and DM initiation."""
- @pytest_asyncio.fixture
- def adapter(self):
- _clear_chatto_env()
- cfg = _make_config()
- adapter = ChattoAdapter(cfg)
- adapter._chatto_client = MagicMock()
- # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
- # plain MagicMock raises TypeError, which create_room()/start_dm()
- # swallow into a None return.
- adapter._chatto_client.create_room = AsyncMock()
- adapter._chatto_client.start_dm = AsyncMock()
- adapter._token = "test-token"
- adapter._room_names = {}
- adapter._room_kinds = {}
- return adapter
- async def test_create_room(self, adapter):
- adapter._chatto_client.create_room.return_value = _make_room(
- "room-123",
- "Test Room",
- RoomKind.CHANNEL,
- )
- result = await adapter.create_room("Test Room", "A test room")
- assert result == "room-123"
- adapter._chatto_client.create_room.assert_called_once()
- assert adapter._room_names["room-123"] == "Test Room"
- async def test_start_dm(self, adapter):
- adapter._chatto_client.start_dm.return_value = _make_room(
- "dm-123",
- "DM with user",
- RoomKind.DM,
- )
- result = await adapter.start_dm("user-123")
- assert result == "dm-123"
- adapter._chatto_client.start_dm.assert_called_once()
- assert adapter._room_kinds["dm-123"] == RoomKind.DM
- # -- DM room management (/join, /leave) --
- def _make_room_state(room, is_member):
- """Build a RoomWithViewerState the way list_rooms()/get_room() return it."""
- return RoomWithViewerState(
- room=room,
- viewer_state=RoomViewerState(is_member=is_member),
- )
- class TestDmRoomCommands:
- """/join and /leave arrive over DMs, change server-side membership and
- must never reach the agent pipeline."""
- def _adapter(self):
- adapter = _make_adapter()
- adapter.chatto_config.allow_all_users.value = True
- adapter.me = _make_user("bot-user-id", "hermes_bot")
- adapter._user_cache["user-1"] = _make_user("user-1", "alice")
- client = adapter._chatto_client
- client.list_rooms = AsyncMock(return_value=[])
- client.get_room_events = AsyncMock(return_value=MagicMock(events=[]))
- client.join_room = AsyncMock()
- client.leave_room = AsyncMock(return_value=True)
- client.get_room = AsyncMock()
- adapter._room_kinds["dm-1"] = RoomKind.DM
- adapter.handle_message = AsyncMock()
- adapter.send = AsyncMock()
- return adapter
- async def _dispatch(self, adapter, body, room_id="dm-1"):
- payload = _make_posted_payload(room_id=room_id)
- payload.fetch_message = AsyncMock(
- return_value=_make_message(body=body, room_id=room_id)
- )
- await adapter._dispatch_message_posted(payload)
- def _reply(self, adapter):
- assert adapter.send.await_count == 1
- return adapter.send.await_args.kwargs["content"]
- async def test_join_silent_channel_names_the_env_lines(self):
- """Users copy room IDs from this reply — an unlisted channel hands
- them both mention-list lines verbatim instead of staying quiet."""
- adapter = self._adapter()
- adapter._seed_room = AsyncMock()
- state = _make_room_state(
- _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
- )
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
- adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
- await self._dispatch(adapter, "/join #deploy")
- adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
- assert adapter._joined_room_ids == ["room-9"]
- reply = self._reply(adapter)
- assert "Joined 'Deploy' (room-9)" in reply
- assert "CHATTO_REQUIRE_MENTION_ROOMS=room-9" in reply
- assert "CHATTO_OPTIONAL_MENTION_ROOMS=room-9" in reply
- async def test_join_silent_channel_does_not_seed(self):
- """Same rule as _refresh_rooms: history nothing will ever answer is
- not pushed into context."""
- adapter = self._adapter()
- adapter._seed_room = AsyncMock()
- state = _make_room_state(
- _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
- )
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
- adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
- await self._dispatch(adapter, "/join #deploy")
- adapter._seed_room.assert_not_awaited()
- async def test_join_listed_channel_reports_policy_and_seeds(self):
- adapter = self._adapter()
- adapter.chatto_config.require_mention_rooms.value = ["room-9"]
- adapter._seed_room = AsyncMock()
- state = _make_room_state(
- _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
- )
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
- adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
- await self._dispatch(adapter, "/join #deploy")
- adapter._seed_room.assert_awaited_once_with("room-9")
- reply = self._reply(adapter)
- assert "answers only @mentions" in reply
- assert "CHATTO_OPTIONAL_MENTION_ROOMS=" not in reply
- async def test_join_room_with_unknown_kind_gets_the_channel_hint(self):
- """A server that never sets kind counts as a channel too — the reply
- carries the mention-list hint and nothing is seeded."""
- adapter = self._adapter()
- adapter._seed_room = AsyncMock()
- state = _make_room_state(
- _make_room("grp-9", "Deploy", RoomKind.UNSPECIFIED), False
- )
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
- adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
- await self._dispatch(adapter, "/join #deploy")
- adapter._chatto_client.join_room.assert_awaited_once_with("grp-9")
- assert adapter._joined_room_ids == ["grp-9"]
- adapter._seed_room.assert_not_awaited()
- reply = self._reply(adapter)
- assert "Joined 'Deploy' (grp-9)" in reply
- assert "CHATTO_REQUIRE_MENTION_ROOMS=grp-9" in reply
- async def test_join_skips_rpc_when_already_a_member(self):
- adapter = self._adapter()
- """Natively invited accounts hold membership already — they only need
- seeding into the joined list (which silent channels skip)."""
- adapter._seed_room = AsyncMock()
- state = _make_room_state(_make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
- await self._dispatch(adapter, "/join #deploy")
- adapter._chatto_client.join_room.assert_not_awaited()
- assert adapter._joined_room_ids == ["room-9"]
- assert "Already a member" in self._reply(adapter)
- async def test_join_unknown_name_reports_without_joining(self):
- adapter = self._adapter()
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[])
- await self._dispatch(adapter, "/join #nope")
- adapter._chatto_client.join_room.assert_not_awaited()
- assert "No room named '#nope'" in self._reply(adapter)
- assert adapter._joined_room_ids == []
- async def test_ambiguous_name_offers_the_candidate_ids(self):
- adapter = self._adapter()
- matches = [
- _make_room_state(_make_room(f"r-{i}", "General", RoomKind.CHANNEL), False)
- for i in range(2)
- ]
- adapter._chatto_client.list_rooms = AsyncMock(return_value=matches)
- adapter._chatto_client.join_room = AsyncMock()
- await self._dispatch(adapter, "/join #general")
- adapter._chatto_client.join_room.assert_not_awaited()
- reply = self._reply(adapter)
- assert "r-0" in reply and "r-1" in reply
- async def test_join_by_room_id_verifies_via_get_room(self):
- adapter = self._adapter()
- state = _make_room_state(
- _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
- )
- adapter._chatto_client.get_room = AsyncMock(return_value=state)
- adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
- await self._dispatch(adapter, "/join room-9")
- adapter._chatto_client.get_room.assert_awaited_once_with("room-9")
- adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
- assert adapter._joined_room_ids == ["room-9"]
- async def test_leave_drops_the_room_from_the_joined_list(self):
- adapter = self._adapter()
- adapter._joined_room_ids = ["room-7"]
- state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
- adapter._chatto_client.get_room = AsyncMock(return_value=state)
- await self._dispatch(adapter, "/leave room-7")
- adapter._chatto_client.leave_room.assert_awaited_once_with("room-7")
- assert adapter._joined_room_ids == []
- assert "Left 'Deploy' (room-7)" in self._reply(adapter)
- async def test_leave_refuses_direct_messages(self):
- adapter = self._adapter()
- state = _make_room_state(_make_room("dm-2", "", RoomKind.DM), True)
- adapter._chatto_client.get_room = AsyncMock(return_value=state)
- await self._dispatch(adapter, "/leave dm-2")
- adapter._chatto_client.leave_room.assert_not_awaited()
- assert "Direct messages cannot be left" in self._reply(adapter)
- async def test_leave_refuses_home_channel(self):
- """Leaving CHATTO_HOME_CHANNEL would break cron/notification delivery."""
- adapter = self._adapter()
- adapter.chatto_config.home_channel.value = "room-7"
- adapter._joined_room_ids = ["room-7"]
- state = _make_room_state(_make_room("room-7", "Home", RoomKind.CHANNEL), True)
- adapter._chatto_client.get_room = AsyncMock(return_value=state)
- await self._dispatch(adapter, "/leave room-7")
- adapter._chatto_client.leave_room.assert_not_awaited()
- assert "home channel" in self._reply(adapter)
- async def test_commands_outside_dms_are_ignored(self):
- adapter = self._adapter()
- """In a channel the text is just a message — mention gating applies,
- no command runs, nothing is sent."""
- adapter.chatto_config.require_mention_rooms.value = ["chan-1"]
- adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
- await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
- adapter._chatto_client.leave_room.assert_not_awaited()
- adapter.handle_message.assert_not_called()
- adapter.send.assert_not_called()
- async def test_non_command_dm_falls_through_to_pipeline(self):
- adapter = self._adapter()
- await self._dispatch(adapter, "/status all good")
- adapter.handle_message.assert_awaited_once()
- adapter.send.assert_not_called()
- async def test_missing_argument_gets_usage_reply(self):
- adapter = self._adapter()
- for body in ("/join", "/leave"):
- adapter.send.reset_mock()
- await self._dispatch(adapter, body)
- assert self._reply(adapter).startswith("Usage:")
- class TestJoinedRoomsRefresh:
- """_refresh_rooms mirrors the joined list against server-side membership."""
- def _adapter(self):
- adapter = _make_adapter()
- client = adapter._chatto_client
- client.list_rooms = AsyncMock(return_value=[])
- return adapter
- async def test_drops_rooms_no_longer_joined(self):
- adapter = self._adapter()
- adapter._joined_room_ids = ["gone-1", "kept"]
- kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
- await adapter._refresh_rooms()
- assert adapter._joined_room_ids == ["kept"]
- async def test_warns_once_when_home_channel_is_not_joined(self):
- adapter = self._adapter()
- adapter.chatto_config.home_channel.value = "home-x"
- other = _make_room_state(_make_room("other", "Other", RoomKind.CHANNEL), True)
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[other])
- await adapter._refresh_rooms()
- assert adapter._home_warning_logged
- await adapter._refresh_rooms()
- assert adapter._home_warning_logged
- async def test_no_warning_while_home_channel_is_member(self):
- adapter = self._adapter()
- adapter.chatto_config.home_channel.value = "home-x"
- home = _make_room_state(_make_room("home-x", "Home", RoomKind.CHANNEL), True)
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[home])
- await adapter._refresh_rooms()
- assert not adapter._home_warning_logged
- class TestSilentRoomRefresh:
- """_refresh_rooms keeps silent rooms joined but skips seeding them."""
- def _adapter(self, optional_mention_rooms):
- adapter = _make_adapter()
- adapter.chatto_config.optional_mention_rooms.value = optional_mention_rooms
- client = adapter._chatto_client
- client.list_rooms = AsyncMock(return_value=[])
- client.join_room = AsyncMock()
- adapter._seed_room = AsyncMock()
- return adapter
- async def test_silent_rooms_are_joined_but_not_seeded(self):
- adapter = self._adapter(["team-1"])
- news = _make_room_state(_make_room("news-1", "News", RoomKind.CHANNEL), True)
- team = _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True)
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[news, team])
- await adapter._refresh_rooms()
- assert sorted(adapter._joined_room_ids) == ["news-1", "team-1"]
- adapter._seed_room.assert_awaited_once_with("team-1")
- async def test_join_log_tags_read_only_and_universal(self, caplog):
- adapter = self._adapter(["team-1"])
- news = Room(id="news-1", name="News", kind=RoomKind.CHANNEL, universal=True)
- team = Room(id="team-1", name="Team", kind=RoomKind.CHANNEL, universal=False)
- adapter._chatto_client.list_rooms = AsyncMock(
- return_value=[_make_room_state(news, True), _make_room_state(team, True)]
- )
- with caplog.at_level("INFO"):
- await adapter._refresh_rooms()
- joined = [m for m in caplog.messages if "currently joined" in m]
- assert joined, "expected the joined-rooms summary log line"
- assert "[read-only]" in joined[-1]
- assert "[universal]" in joined[-1]
- # -- Constants --
- class TestConstants:
- """Test that constants are properly defined."""
- def test_max_message_length(self):
- assert _MAX_MESSAGE_LENGTH == 10000
- def test_seen_cap(self):
- assert _SEEN_CAP == 500
- # -- Client creation credentials --
- class TestOpenClientCredentials:
- """Token wins; nothing configured raises as a normal creation failure."""
- def _adapter(self):
- return _make_adapter()
- async def test_token_only_connects_without_login(self):
- """The documented CHATTO_TOKEN path must not demand a login."""
- adapter = self._adapter()
- client = await adapter._open_client(
- base_url="https://chat.example.com", login="", password="", token="t"
- )
- assert isinstance(client, ChattoClient)
- async def test_no_credentials_raises_value_error(self):
- """Misconfiguration reads as a logged creation failure, not a crash."""
- adapter = self._adapter()
- with pytest.raises(ValueError, match="neither token nor login"):
- await adapter._open_client(
- base_url="https://chat.example.com", login="", password=""
- )
- async def test_login_password_path_is_used_without_token(self):
- adapter = self._adapter()
- with patch("adapter.ChattoClient") as client_cls:
- client_cls.login = AsyncMock(return_value=MagicMock())
- await adapter._open_client(
- base_url="https://chat.example.com", login="u", password="p"
- )
- client_cls.login.assert_awaited_once_with(
- "u", "p", base_url="https://chat.example.com"
- )
|