|
@@ -122,12 +122,14 @@ _CHATTO_ENV_KEYS = [
|
|
|
"CHATTO_BASE_URL",
|
|
"CHATTO_BASE_URL",
|
|
|
"CHATTO_LOGIN",
|
|
"CHATTO_LOGIN",
|
|
|
"CHATTO_PASSWORD",
|
|
"CHATTO_PASSWORD",
|
|
|
|
|
+ "CHATTO_TOKEN",
|
|
|
"CHATTO_HOME_CHANNEL",
|
|
"CHATTO_HOME_CHANNEL",
|
|
|
"CHATTO_REQUIRE_MENTION",
|
|
"CHATTO_REQUIRE_MENTION",
|
|
|
"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",
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1518,6 +1520,138 @@ class TestRoomWatchRefresh:
|
|
|
assert not adapter._home_warning_logged
|
|
assert not adapter._home_warning_logged
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class TestRespondRooms:
|
|
|
|
|
+ """CHATTO_RESPOND_ROOMS gates inbound messages: positive list, DMs exempt."""
|
|
|
|
|
+
|
|
|
|
|
+ def _dropping_adapter(self, respond_rooms):
|
|
|
|
|
+ """An adapter whose every API call explodes — the gate must exit first."""
|
|
|
|
|
+ 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._require_client = AsyncMock(side_effect=RuntimeError("no client"))
|
|
|
|
|
+ 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._require_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._require_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._require_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 watched but skips seeding them."""
|
|
|
|
|
+
|
|
|
|
|
+ def _adapter(self, respond_rooms):
|
|
|
|
|
+ adapter = _make_adapter()
|
|
|
|
|
+ adapter.chatto_config.respond_rooms.value = respond_rooms
|
|
|
|
|
+ client = adapter._chatto_client
|
|
|
|
|
+ client.list_rooms = AsyncMock(return_value=[])
|
|
|
|
|
+ client.join_room = AsyncMock()
|
|
|
|
|
+ adapter._seed_room = AsyncMock()
|
|
|
|
|
+ return adapter
|
|
|
|
|
+
|
|
|
|
|
+ async def test_read_only_rooms_are_watched_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._watch_room_ids) == ["news-1", "team-1"]
|
|
|
|
|
+ adapter._seed_room.assert_awaited_once_with("team-1")
|
|
|
|
|
+
|
|
|
|
|
+ async def test_watch_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()
|
|
|
|
|
+
|
|
|
|
|
+ watching = [m for m in caplog.messages if "Watching" in m]
|
|
|
|
|
+ assert watching, "expected the watch summary log line"
|
|
|
|
|
+ assert "[read-only]" in watching[-1]
|
|
|
|
|
+ assert "[universal]" in watching[-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 --
|
|
|
|
|
|
|
|
|
|
|