|
@@ -65,6 +65,7 @@ from gateway.platforms.base import (
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
from adapter import (
|
|
from adapter import (
|
|
|
|
|
+ ChannelPolicy,
|
|
|
ChattoAdapter,
|
|
ChattoAdapter,
|
|
|
HermesChatType,
|
|
HermesChatType,
|
|
|
_capabilities,
|
|
_capabilities,
|
|
@@ -127,12 +128,12 @@ _CHATTO_ENV_KEYS = [
|
|
|
"CHATTO_PASSWORD",
|
|
"CHATTO_PASSWORD",
|
|
|
"CHATTO_TOKEN",
|
|
"CHATTO_TOKEN",
|
|
|
"CHATTO_HOME_CHANNEL",
|
|
"CHATTO_HOME_CHANNEL",
|
|
|
- "CHATTO_REQUIRE_MENTION",
|
|
|
|
|
|
|
+ "CHATTO_REQUIRE_MENTION_ROOMS",
|
|
|
|
|
+ "CHATTO_OPTIONAL_MENTION_ROOMS",
|
|
|
"CHATTO_ALLOWED_USERS",
|
|
"CHATTO_ALLOWED_USERS",
|
|
|
"CHATTO_ALLOW_ALL_USERS",
|
|
"CHATTO_ALLOW_ALL_USERS",
|
|
|
"CHATTO_AUTO_THREAD",
|
|
"CHATTO_AUTO_THREAD",
|
|
|
"CHATTO_REACTIONS",
|
|
"CHATTO_REACTIONS",
|
|
|
- "CHATTO_RESPOND_ROOMS",
|
|
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
@@ -342,6 +343,82 @@ class TestRegistration:
|
|
|
_clear_chatto_env()
|
|
_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 --
|
|
# -- Send functionality --
|
|
|
|
|
|
|
|
|
|
|
|
@@ -848,6 +925,7 @@ class TestChatTypeMapping:
|
|
|
it is — a raw RoomKind lands in SessionSource.description's else-branch."""
|
|
it is — a raw RoomKind lands in SessionSource.description's else-branch."""
|
|
|
adapter = _make_adapter()
|
|
adapter = _make_adapter()
|
|
|
adapter.chatto_config.allow_all_users.value = True
|
|
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.me = _make_user("bot-user-id", "hermes_bot")
|
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
@@ -935,14 +1013,14 @@ class TestPresence:
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestForeignMention:
|
|
class TestForeignMention:
|
|
|
- """With require_mention off 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."""
|
|
|
|
|
|
|
+ """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):
|
|
def _adapter(self, **overrides):
|
|
|
adapter = _make_adapter()
|
|
adapter = _make_adapter()
|
|
|
adapter.chatto_config.allow_all_users.value = True
|
|
adapter.chatto_config.allow_all_users.value = True
|
|
|
- adapter.chatto_config.require_mention.value = False
|
|
|
|
|
|
|
+ adapter.chatto_config.optional_mention_rooms.value = ["room-1"]
|
|
|
adapter.chatto_config.reactions.value = True
|
|
adapter.chatto_config.reactions.value = True
|
|
|
for key, value in overrides.items():
|
|
for key, value in overrides.items():
|
|
|
getattr(adapter.chatto_config, key).value = value
|
|
getattr(adapter.chatto_config, key).value = value
|
|
@@ -1125,10 +1203,13 @@ class TestForeignMention:
|
|
|
await self._dispatch(adapter, "@bob said the build is red", room_id="dm-1")
|
|
await self._dispatch(adapter, "@bob said the build is red", room_id="dm-1")
|
|
|
adapter.handle_message.assert_called_once()
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
|
- async def test_require_mention_keeps_discarding_without_a_reaction(self):
|
|
|
|
|
- """The older gate wins: it drops the message before we get here, and it
|
|
|
|
|
- deliberately says nothing at all."""
|
|
|
|
|
- adapter = self._adapter(require_mention=True)
|
|
|
|
|
|
|
+ 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?")
|
|
await self._dispatch(adapter, "@bob can you take a look?")
|
|
|
adapter.handle_message.assert_not_called()
|
|
adapter.handle_message.assert_not_called()
|
|
|
adapter.add_reaction.assert_not_awaited()
|
|
adapter.add_reaction.assert_not_awaited()
|
|
@@ -1140,16 +1221,17 @@ class TestForeignMention:
|
|
|
adapter.add_reaction.assert_not_awaited()
|
|
adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
-# -- require_mention --
|
|
|
|
|
|
|
+# -- Channel mention policies --
|
|
|
|
|
|
|
|
|
|
|
|
|
-class TestRequireMention:
|
|
|
|
|
- """require_mention gates channels only — a DM is already addressed at the bot."""
|
|
|
|
|
|
|
+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):
|
|
def _adapter(self):
|
|
|
adapter = _make_adapter()
|
|
adapter = _make_adapter()
|
|
|
adapter.chatto_config.allow_all_users.value = True
|
|
adapter.chatto_config.allow_all_users.value = True
|
|
|
- adapter.chatto_config.require_mention.value = True
|
|
|
|
|
adapter.me = _make_user("bot-user-id", "hermes_bot")
|
|
adapter.me = _make_user("bot-user-id", "hermes_bot")
|
|
|
adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
|
adapter.handle_message = AsyncMock()
|
|
adapter.handle_message = AsyncMock()
|
|
@@ -1162,28 +1244,83 @@ class TestRequireMention:
|
|
|
)
|
|
)
|
|
|
await adapter._dispatch_message_posted(payload)
|
|
await adapter._dispatch_message_posted(payload)
|
|
|
|
|
|
|
|
- async def test_channel_without_mention_is_discarded(self):
|
|
|
|
|
|
|
+ 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 = self._adapter()
|
|
|
|
|
+ adapter.chatto_config.require_mention_rooms.value = ["room-1"]
|
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
await self._dispatch(adapter, "room-1", "hi there")
|
|
await self._dispatch(adapter, "room-1", "hi there")
|
|
|
adapter.handle_message.assert_not_called()
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
|
|
|
|
- async def test_channel_with_mention_is_answered(self):
|
|
|
|
|
|
|
+ async def test_require_list_room_with_mention_is_answered(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
|
|
+ adapter.chatto_config.require_mention_rooms.value = ["room-1"]
|
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
|
|
await self._dispatch(adapter, "room-1", "@hermes_bot hi there")
|
|
|
adapter.handle_message.assert_called_once()
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
|
- async def test_broadcast_mention_counts_as_addressed_in_channels(self):
|
|
|
|
|
|
|
+ async def test_broadcast_mention_counts_as_addressed(self):
|
|
|
"""@here/@all address the bot too — one definition of 'addressed'
|
|
"""@here/@all address the bot too — one definition of 'addressed'
|
|
|
serves this gate and the someone-else check alike (FDR-006)."""
|
|
serves this gate and the someone-else check alike (FDR-006)."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
|
|
+ adapter.chatto_config.require_mention_rooms.value = ["room-1"]
|
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
await self._dispatch(adapter, "room-1", "@here standup in 5")
|
|
await self._dispatch(adapter, "room-1", "@here standup in 5")
|
|
|
adapter.handle_message.assert_called_once()
|
|
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):
|
|
async def test_dm_is_answered_without_a_mention(self):
|
|
|
- """The point of the room_kind check: require_mention must not mute DMs."""
|
|
|
|
|
|
|
+ """The point of the room_kind check: mention gating must not mute DMs."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
adapter._room_kinds["dm-1"] = RoomKind.DM
|
|
adapter._room_kinds["dm-1"] = RoomKind.DM
|
|
|
await self._dispatch(adapter, "dm-1", "hi there")
|
|
await self._dispatch(adapter, "dm-1", "hi there")
|
|
@@ -1280,9 +1417,8 @@ class TestEditDispatch:
|
|
|
adapter.handle_message.assert_not_called()
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
|
|
|
|
async def test_read_only_room_costs_no_api_call(self):
|
|
async def test_read_only_room_costs_no_api_call(self):
|
|
|
- """Respond-room gating sits before fetch_message, like for posts."""
|
|
|
|
|
|
|
+ """Silent-room gating sits before fetch_message, like for posts."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- adapter.chatto_config.respond_rooms.value = ["other-room"]
|
|
|
|
|
|
|
|
|
|
fetch = await self._edit(adapter, "corrected")
|
|
fetch = await self._edit(adapter, "corrected")
|
|
|
fetch.fetch_message.assert_not_awaited() # type: ignore[union-attr]
|
|
fetch.fetch_message.assert_not_awaited() # type: ignore[union-attr]
|
|
@@ -1291,7 +1427,7 @@ class TestEditDispatch:
|
|
|
async def test_mention_added_by_edit_starts_a_turn(self):
|
|
async def test_mention_added_by_edit_starts_a_turn(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
- adapter.chatto_config.require_mention.value = True
|
|
|
|
|
|
|
+ adapter.chatto_config.require_mention_rooms.value = ["room-1"]
|
|
|
|
|
|
|
|
await self._edit(adapter, "@hermes_bot corrected text")
|
|
await self._edit(adapter, "@hermes_bot corrected text")
|
|
|
|
|
|
|
@@ -1306,7 +1442,7 @@ class TestEditDispatch:
|
|
|
"""Gates run against the new body — no mention in, no answer out."""
|
|
"""Gates run against the new body — no mention in, no answer out."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
- adapter.chatto_config.require_mention.value = True
|
|
|
|
|
|
|
+ adapter.chatto_config.require_mention_rooms.value = ["room-1"]
|
|
|
|
|
|
|
|
await self._edit(adapter, "still no mention")
|
|
await self._edit(adapter, "still no mention")
|
|
|
|
|
|
|
@@ -1664,8 +1800,11 @@ class TestDmRoomCommands:
|
|
|
assert adapter.send.await_count == 1
|
|
assert adapter.send.await_count == 1
|
|
|
return adapter.send.await_args.kwargs["content"]
|
|
return adapter.send.await_args.kwargs["content"]
|
|
|
|
|
|
|
|
- async def test_join_by_name_joins_and_seeds(self):
|
|
|
|
|
|
|
+ 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 = self._adapter()
|
|
|
|
|
+ adapter._seed_room = AsyncMock()
|
|
|
state = _make_room_state(
|
|
state = _make_room_state(
|
|
|
_make_room("room-9", "Deploy", RoomKind.CHANNEL), False
|
|
_make_room("room-9", "Deploy", RoomKind.CHANNEL), False
|
|
|
)
|
|
)
|
|
@@ -1676,12 +1815,68 @@ class TestDmRoomCommands:
|
|
|
|
|
|
|
|
adapter._chatto_client.join_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"]
|
|
assert adapter._joined_room_ids == ["room-9"]
|
|
|
- assert "Joined 'Deploy' (room-9)" in self._reply(adapter)
|
|
|
|
|
|
|
+ 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):
|
|
async def test_join_skips_rpc_when_already_a_member(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
"""Natively invited accounts hold membership already — they only need
|
|
"""Natively invited accounts hold membership already — they only need
|
|
|
- seeding into the joined list."""
|
|
|
|
|
|
|
+ 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)
|
|
state = _make_room_state(_make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
|
|
|
adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
|
|
adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
|
|
|
|
|
|
|
@@ -1769,7 +1964,7 @@ class TestDmRoomCommands:
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
"""In a channel the text is just a message — mention gating applies,
|
|
"""In a channel the text is just a message — mention gating applies,
|
|
|
no command runs, nothing is sent."""
|
|
no command runs, nothing is sent."""
|
|
|
- adapter.chatto_config.require_mention.value = True
|
|
|
|
|
|
|
+ adapter.chatto_config.require_mention_rooms.value = ["chan-1"]
|
|
|
adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
|
|
adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
|
|
|
|
|
|
|
|
await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
|
|
await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
|
|
@@ -1833,101 +2028,19 @@ class TestJoinedRoomsRefresh:
|
|
|
assert not adapter._home_warning_logged
|
|
assert not adapter._home_warning_logged
|
|
|
|
|
|
|
|
|
|
|
|
|
-class TestRespondRooms:
|
|
|
|
|
- """CHATTO_RESPOND_ROOMS gates inbound messages: positive list, DMs exempt."""
|
|
|
|
|
|
|
+class TestSilentRoomRefresh:
|
|
|
|
|
+ """_refresh_rooms keeps silent rooms joined but skips seeding them."""
|
|
|
|
|
|
|
|
- def _dropping_adapter(self, respond_rooms):
|
|
|
|
|
- """An adapter whose every API call explodes — the gate must exit first."""
|
|
|
|
|
|
|
+ def _adapter(self, optional_mention_rooms):
|
|
|
adapter = _make_adapter()
|
|
adapter = _make_adapter()
|
|
|
- adapter.chatto_config.respond_rooms.value = respond_rooms
|
|
|
|
|
- adapter.me = _make_user("bot-user-id", "hermes_bot")
|
|
|
|
|
- adapter.handle_message = AsyncMock()
|
|
|
|
|
- adapter._get_chatto_client = AsyncMock(return_value=None)
|
|
|
|
|
- return adapter
|
|
|
|
|
-
|
|
|
|
|
- async def test_unlisted_room_is_dropped_before_any_api_call(self):
|
|
|
|
|
- adapter = self._dropping_adapter(["listed-1"])
|
|
|
|
|
- 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_fails_closed(self):
|
|
|
|
|
- """No cached kind: the allowlist assumes the worst and drops."""
|
|
|
|
|
- adapter = self._dropping_adapter(["listed-1"])
|
|
|
|
|
- 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.handle_message.assert_not_called()
|
|
|
|
|
-
|
|
|
|
|
- async def test_listed_room_reaches_the_pipeline(self):
|
|
|
|
|
- adapter = self._dropping_adapter(["room-1"])
|
|
|
|
|
- adapter.chatto_config.allow_all_users.value = True
|
|
|
|
|
- adapter._get_chatto_client = AsyncMock(return_value=adapter._chatto_client)
|
|
|
|
|
- adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
|
|
- adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
|
|
|
- payload = _make_posted_payload(room_id="room-1")
|
|
|
|
|
- payload.fetch_message = AsyncMock(
|
|
|
|
|
- return_value=_make_message(body="hi", room_id="room-1")
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- await adapter._dispatch_message_posted(payload)
|
|
|
|
|
-
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
-
|
|
|
|
|
- async def test_dm_outside_the_list_still_answers(self):
|
|
|
|
|
- """DMs stay respond rooms so /join remains reachable."""
|
|
|
|
|
- adapter = self._dropping_adapter(["listed-1"])
|
|
|
|
|
- adapter.chatto_config.allow_all_users.value = True
|
|
|
|
|
- adapter._get_chatto_client = AsyncMock(return_value=adapter._chatto_client)
|
|
|
|
|
- adapter._room_kinds["dm-1"] = RoomKind.DM
|
|
|
|
|
- adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
|
|
|
- payload = _make_posted_payload(room_id="dm-1")
|
|
|
|
|
- payload.fetch_message = AsyncMock(
|
|
|
|
|
- return_value=_make_message(body="hi", room_id="dm-1")
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- await adapter._dispatch_message_posted(payload)
|
|
|
|
|
-
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
-
|
|
|
|
|
- async def test_empty_list_answers_everywhere(self):
|
|
|
|
|
- """Unset list keeps the pre-existing behaviour: every room responds."""
|
|
|
|
|
- adapter = self._dropping_adapter([])
|
|
|
|
|
- adapter.chatto_config.allow_all_users.value = True
|
|
|
|
|
- adapter._get_chatto_client = AsyncMock(return_value=adapter._chatto_client)
|
|
|
|
|
- adapter._room_kinds["any-room"] = RoomKind.CHANNEL
|
|
|
|
|
- adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
|
|
|
- payload = _make_posted_payload(room_id="any-room")
|
|
|
|
|
- payload.fetch_message = AsyncMock(
|
|
|
|
|
- return_value=_make_message(body="hi", room_id="any-room")
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- await adapter._dispatch_message_posted(payload)
|
|
|
|
|
-
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class TestRespondRoomRefresh:
|
|
|
|
|
- """_refresh_rooms keeps read-only rooms joined but skips seeding them."""
|
|
|
|
|
-
|
|
|
|
|
- def _adapter(self, respond_rooms):
|
|
|
|
|
- adapter = _make_adapter()
|
|
|
|
|
- adapter.chatto_config.respond_rooms.value = respond_rooms
|
|
|
|
|
|
|
+ adapter.chatto_config.optional_mention_rooms.value = optional_mention_rooms
|
|
|
client = adapter._chatto_client
|
|
client = adapter._chatto_client
|
|
|
client.list_rooms = AsyncMock(return_value=[])
|
|
client.list_rooms = AsyncMock(return_value=[])
|
|
|
client.join_room = AsyncMock()
|
|
client.join_room = AsyncMock()
|
|
|
adapter._seed_room = AsyncMock()
|
|
adapter._seed_room = AsyncMock()
|
|
|
return adapter
|
|
return adapter
|
|
|
|
|
|
|
|
- async def test_read_only_rooms_are_joined_but_not_seeded(self):
|
|
|
|
|
|
|
+ async def test_silent_rooms_are_joined_but_not_seeded(self):
|
|
|
adapter = self._adapter(["team-1"])
|
|
adapter = self._adapter(["team-1"])
|
|
|
news = _make_room_state(_make_room("news-1", "News", RoomKind.CHANNEL), True)
|
|
news = _make_room_state(_make_room("news-1", "News", RoomKind.CHANNEL), True)
|
|
|
team = _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True)
|
|
team = _make_room_state(_make_room("team-1", "Team", RoomKind.CHANNEL), True)
|
|
@@ -1954,16 +2067,6 @@ class TestRespondRoomRefresh:
|
|
|
assert "[read-only]" in joined[-1]
|
|
assert "[read-only]" in joined[-1]
|
|
|
assert "[universal]" in joined[-1]
|
|
assert "[universal]" in joined[-1]
|
|
|
|
|
|
|
|
- async def test_warns_when_respond_list_names_unjoined_rooms(self, caplog):
|
|
|
|
|
- adapter = self._adapter(["ghost-id"])
|
|
|
|
|
- kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
|
|
|
|
|
- adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
|
|
|
|
|
-
|
|
|
|
|
- with caplog.at_level("WARNING"):
|
|
|
|
|
- await adapter._refresh_rooms()
|
|
|
|
|
-
|
|
|
|
|
- assert any("ghost-id" in message for message in caplog.messages)
|
|
|
|
|
-
|
|
|
|
|
|
|
|
|
|
# -- Constants --
|
|
# -- Constants --
|
|
|
|
|
|