|
@@ -40,6 +40,7 @@ from vendor_path import setup_vendor_path
|
|
|
|
|
|
|
|
setup_vendor_path()
|
|
setup_vendor_path()
|
|
|
|
|
|
|
|
|
|
+from chattolib._pb.chatto.realtime.v1.events_pb2 import MessageMention
|
|
|
from chattolib.client import ChattoClient
|
|
from chattolib.client import ChattoClient
|
|
|
from chattolib.exceptions import ChattoError
|
|
from chattolib.exceptions import ChattoError
|
|
|
from chattolib.types import (
|
|
from chattolib.types import (
|
|
@@ -81,6 +82,7 @@ from adapter import (
|
|
|
_handle_chatto_list_rooms,
|
|
_handle_chatto_list_rooms,
|
|
|
_handle_chatto_send_dm,
|
|
_handle_chatto_send_dm,
|
|
|
_handle_chatto_send_room,
|
|
_handle_chatto_send_room,
|
|
|
|
|
+ _Mentions,
|
|
|
_room_answers_in,
|
|
_room_answers_in,
|
|
|
chat_type_for_room_kind,
|
|
chat_type_for_room_kind,
|
|
|
hermes_env_enablement_fn,
|
|
hermes_env_enablement_fn,
|
|
@@ -282,12 +284,60 @@ def _make_message(
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _make_posted_payload(room_id="room-1", message_event_id="msg-1"):
|
|
|
|
|
- """A message_posted payload whose get_message() the caller stubs."""
|
|
|
|
|
|
|
+def _make_mentions(*specs, viewer=False):
|
|
|
|
|
+ """Real MessageMention protos, as the server resolves them.
|
|
|
|
|
+
|
|
|
|
|
+ Each spec is ``(cause, value)`` where cause is one of ``direct`` (value is
|
|
|
|
|
+ a user ID), ``role`` (a role name), or ``all``/``here`` (value is ignored).
|
|
|
|
|
+ These are the library's own types rather than mocks shaped like them, so a
|
|
|
|
|
+ renamed proto field fails here instead of passing silently.
|
|
|
|
|
+
|
|
|
|
|
+ ``viewer=True`` marks the target as having included this bot, which is the
|
|
|
|
|
+ server's own ``includes_viewer`` answer for a target that reached us.
|
|
|
|
|
+ """
|
|
|
|
|
+ mentions = []
|
|
|
|
|
+ for cause, value in specs:
|
|
|
|
|
+ mention = MessageMention()
|
|
|
|
|
+ if cause == "direct":
|
|
|
|
|
+ mention.direct.user_id = value
|
|
|
|
|
+ elif cause == "role":
|
|
|
|
|
+ mention.role.role_name = value
|
|
|
|
|
+ elif cause == "all":
|
|
|
|
|
+ mention.all.SetInParent()
|
|
|
|
|
+ elif cause == "here":
|
|
|
|
|
+ mention.here.SetInParent()
|
|
|
|
|
+ else:
|
|
|
|
|
+ raise ValueError(f"unknown mention cause: {cause}")
|
|
|
|
|
+ mention.includes_viewer = viewer
|
|
|
|
|
+ mentions.append(mention)
|
|
|
|
|
+ return mentions
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _mentions_me(user_id="bot-user-id"):
|
|
|
|
|
+ """One direct target naming this bot — the server's "this reached you".
|
|
|
|
|
+
|
|
|
|
|
+ The default for tests that are about something else (thread context, media,
|
|
|
|
|
+ auth) and simply need a message that got through the mention gate.
|
|
|
|
|
+ """
|
|
|
|
|
+ mention = MessageMention()
|
|
|
|
|
+ mention.direct.user_id = user_id
|
|
|
|
|
+ mention.includes_viewer = True
|
|
|
|
|
+ return [mention]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _make_posted_payload(room_id="room-1", message_event_id="msg-1", mentions=None):
|
|
|
|
|
+ """A message_posted payload whose get_message() the caller stubs.
|
|
|
|
|
+
|
|
|
|
|
+ ``mentions`` are the server-resolved mention targets of the posted event —
|
|
|
|
|
+ the same protos the adapter reads off ``payload.mentions`` in production.
|
|
|
|
|
+ It defaults to a message that addressed this bot, because that is the case
|
|
|
|
|
+ most tests are about; pass ``[]`` for a message that addressed nobody.
|
|
|
|
|
+ """
|
|
|
payload = MagicMock()
|
|
payload = MagicMock()
|
|
|
payload.room_id = room_id
|
|
payload.room_id = room_id
|
|
|
payload.message_event_id = message_event_id
|
|
payload.message_event_id = message_event_id
|
|
|
payload.thread_root_event_id = None
|
|
payload.thread_root_event_id = None
|
|
|
|
|
+ payload.mentions = _mentions_me() if mentions is None else list(mentions)
|
|
|
return payload
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1245,10 +1295,175 @@ class TestPresence:
|
|
|
# -- Mentions of other people --
|
|
# -- Mentions of other people --
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class TestServerResolvedMentions:
|
|
|
|
|
+ """The server resolves mentions when a message is posted and reports the
|
|
|
|
|
+ targets on the event, so the posted path never re-derives them from the
|
|
|
|
|
+ body. These tests state the resolved targets directly — that is the whole
|
|
|
|
|
+ point of trusting them."""
|
|
|
|
|
+
|
|
|
|
|
+ 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")
|
|
|
|
|
+ for member in (
|
|
|
|
|
+ DirectoryMember(user=adapter.me),
|
|
|
|
|
+ DirectoryMember(user=_make_user("user-1", "alice")),
|
|
|
|
|
+ DirectoryMember(user=_make_user("user-2", "bob")),
|
|
|
|
|
+ ):
|
|
|
|
|
+ adapter._user_cache[member.user.id] = member.user
|
|
|
|
|
+ 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
|
|
|
|
|
+ adapter._chatto_client.list_room_members = AsyncMock(
|
|
|
|
|
+ return_value=(["user-1", "user-2"], Page())
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter._chatto_client.batch_get_room_members = AsyncMock(
|
|
|
|
|
+ return_value=[
|
|
|
|
|
+ DirectoryMember(user=_make_user("user-1", "alice")),
|
|
|
|
|
+ DirectoryMember(user=_make_user("user-2", "bob")),
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ return adapter
|
|
|
|
|
+
|
|
|
|
|
+ async def _dispatch(
|
|
|
|
|
+ self, adapter, body, mentions=(), room_id="room-1", viewer=False
|
|
|
|
|
+ ):
|
|
|
|
|
+ """Dispatch with the server-resolved mention targets the test states.
|
|
|
|
|
+
|
|
|
|
|
+ ``mentions`` is a list of ``(cause, value)`` specs; ``viewer`` marks
|
|
|
|
|
+ every one of them as having included this bot.
|
|
|
|
|
+ """
|
|
|
|
|
+ payload = _make_posted_payload(
|
|
|
|
|
+ room_id=room_id, mentions=_make_mentions(*mentions, viewer=viewer)
|
|
|
|
|
+ )
|
|
|
|
|
+ _with_get_message(adapter, payload, _make_message(body=body, room_id=room_id))
|
|
|
|
|
+ await adapter._dispatch_message_posted(payload, payload.message_event_id)
|
|
|
|
|
+
|
|
|
|
|
+ async def test_broadcast_target_addresses_the_bot(self):
|
|
|
|
|
+ """@all arrives as one `all` target with includes_viewer set — the
|
|
|
|
|
+ server does not expand it to the room's members."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter, "@all please read", mentions=[("all", None)], viewer=True
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
|
|
+ adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_here_target_addresses_the_bot(self):
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter, "@here look", mentions=[("here", None)], viewer=True
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_role_target_addresses_the_bot(self):
|
|
|
|
|
+ """A role mention the bot belongs to reaches it. The body-derived path
|
|
|
|
|
+ had no notion of roles at all, so @moderator could never match."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter,
|
|
|
|
|
+ "@moderator please look",
|
|
|
|
|
+ mentions=[("role", "moderator")],
|
|
|
|
|
+ viewer=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_direct_target_on_another_user_is_someone_else(self):
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter, "@bob take a look", mentions=[("direct", "user-2")]
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter.handle_message.assert_not_called()
|
|
|
|
|
+ adapter.add_reaction.assert_awaited_once()
|
|
|
|
|
+ assert adapter.add_reaction.await_args.args[2] == "🫥"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_being_addressed_alongside_another_still_answers(self):
|
|
|
|
|
+ """A message naming us and a colleague is ours — the direct target for
|
|
|
|
|
+ somebody else does not cancel the target that included us."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter,
|
|
|
|
|
+ "@bob and @hermes_bot, thoughts?",
|
|
|
|
|
+ mentions=[("direct", "user-2"), ("direct", "bot-user-id")],
|
|
|
|
|
+ viewer=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
|
|
+ adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_target_without_recipients_is_not_someone_else(self):
|
|
|
|
|
+ """The server omits targets it resolved to nobody ("mentioning a user
|
|
|
|
|
+ who isn't a room member leaves the @name as plain text"), so an empty
|
|
|
|
|
+ mentions list means the message addressed nobody we can see."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ await self._dispatch(adapter, "@nonexistent_person bitte")
|
|
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
|
|
+ adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_mention_inside_a_url_is_not_a_mention(self):
|
|
|
|
|
+ """The body-derived regex read @bob out of a URL; the server resolves
|
|
|
|
|
+ nothing there and reports no target."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ await self._dispatch(adapter, "https://social.5f9.de/@bob/1")
|
|
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
|
|
+ adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_mention_in_a_blockquote_is_not_a_mention(self):
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ await self._dispatch(adapter, "> @bob take a look")
|
|
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
|
|
+ adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_the_directory_is_never_consulted(self):
|
|
|
|
|
+ """Nothing about the posted path needs to resolve a handle locally any
|
|
|
|
|
+ more — the answer arrives already resolved."""
|
|
|
|
|
+ adapter = self._adapter()
|
|
|
|
|
+ adapter._chatto_client.admin_get_member = AsyncMock(return_value={})
|
|
|
|
|
+ await self._dispatch(adapter, "@bob ping", mentions=[("direct", "user-2")])
|
|
|
|
|
+ adapter._chatto_client.admin_get_member.assert_not_awaited()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_require_list_room_gates_on_the_resolved_target(self):
|
|
|
|
|
+ adapter = self._adapter(
|
|
|
|
|
+ optional_mention_rooms=[], require_mention_rooms=["room-1"]
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._dispatch(adapter, "@bob ping", mentions=[("direct", "user-2")])
|
|
|
|
|
+ adapter.handle_message.assert_not_called()
|
|
|
|
|
+ adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_require_list_room_answers_on_our_own_target(self):
|
|
|
|
|
+ adapter = self._adapter(
|
|
|
|
|
+ optional_mention_rooms=[], require_mention_rooms=["room-1"]
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._dispatch(
|
|
|
|
|
+ adapter,
|
|
|
|
|
+ "@hermes_bot ping",
|
|
|
|
|
+ mentions=[("direct", "bot-user-id")],
|
|
|
|
|
+ viewer=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ adapter.handle_message.assert_called_once()
|
|
|
|
|
+
|
|
|
|
|
+ async def test_require_list_room_drops_an_unmentioned_message(self):
|
|
|
|
|
+ adapter = self._adapter(
|
|
|
|
|
+ optional_mention_rooms=[], require_mention_rooms=["room-1"]
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._dispatch(adapter, "just talking")
|
|
|
|
|
+ adapter.handle_message.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class TestForeignMention:
|
|
class TestForeignMention:
|
|
|
"""In an open channel (CHATTO_OPTIONAL_MENTION_ROOMS) the bot reads
|
|
"""In an open channel (CHATTO_OPTIONAL_MENTION_ROOMS) the bot reads
|
|
|
everything, so a message aimed at a named colleague would otherwise get an
|
|
everything, so a message aimed at a named colleague would otherwise get an
|
|
|
- unsolicited answer. Acknowledge it with 🫥 and stay out of the conversation."""
|
|
|
|
|
|
|
+ unsolicited answer. Acknowledge it with 🫥 and stay out of the conversation.
|
|
|
|
|
+
|
|
|
|
|
+ These exercise the *body-derived* mentions the edited path falls back to —
|
|
|
|
|
+ the posted path reads the server's resolved targets instead (see
|
|
|
|
|
+ TestServerResolvedMentions). Each test therefore states a body and the
|
|
|
|
|
+ resulting ``_Mentions``, which is exactly what an edit hands the gates.
|
|
|
|
|
+ """
|
|
|
|
|
|
|
|
def _adapter(self, **overrides):
|
|
def _adapter(self, **overrides):
|
|
|
adapter = _make_adapter()
|
|
adapter = _make_adapter()
|
|
@@ -1293,22 +1508,31 @@ class TestForeignMention:
|
|
|
return adapter
|
|
return adapter
|
|
|
|
|
|
|
|
async def _dispatch(self, adapter, body, room_id="room-1"):
|
|
async def _dispatch(self, adapter, body, room_id="room-1"):
|
|
|
- payload = _make_posted_payload(room_id=room_id)
|
|
|
|
|
- _with_get_message(adapter, payload, _make_message(body=body, room_id=room_id))
|
|
|
|
|
- await adapter._dispatch_message_posted(payload, payload.message_event_id)
|
|
|
|
|
|
|
+ """Run a body through the derivation an edit relies on.
|
|
|
|
|
+
|
|
|
|
|
+ An edit event carries no resolved targets, so ``_admit_and_build``
|
|
|
|
|
+ calls ``_mentions_from_body`` once the roster has filled the member
|
|
|
|
|
+ cache. This drives that same call, so the tests below cover the real
|
|
|
|
|
+ parsing rules rather than a reimplementation of them.
|
|
|
|
|
+ """
|
|
|
|
|
+ await self._roster(adapter, room_id)
|
|
|
|
|
+ return adapter._mentions_from_body(body)
|
|
|
|
|
|
|
|
- async def test_message_for_someone_else_is_only_acknowledged(self):
|
|
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ async def _roster(adapter, room_id):
|
|
|
|
|
+ """Fill the member cache the way the admission pipeline does."""
|
|
|
|
|
+ await adapter._roster_for_room(adapter._chatto_client, room_id=room_id)
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_foreign_handle_is_someone_else(self):
|
|
|
adapter = self._adapter()
|
|
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] == "🫥"
|
|
|
|
|
|
|
+ assert (
|
|
|
|
|
+ await self._dispatch(adapter, "@bob can you take a look?")
|
|
|
|
|
+ ) == _Mentions(me=False, someone_else=True)
|
|
|
|
|
|
|
|
async def test_being_mentioned_alongside_someone_else_still_answers(self):
|
|
async def test_being_mentioned_alongside_someone_else_still_answers(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "@bob and @hermes_bot, thoughts?")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
- adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(adapter, "@bob and @hermes_bot, thoughts?")
|
|
|
|
|
+ assert mentions == _Mentions(me=True, someone_else=True)
|
|
|
|
|
|
|
|
async def test_several_people_addressed_and_none_of_them_us(self):
|
|
async def test_several_people_addressed_and_none_of_them_us(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
@@ -1319,85 +1543,84 @@ class TestForeignMention:
|
|
|
else {}
|
|
else {}
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
- await self._dispatch(adapter, "@bob @carol schaut mal drüber")
|
|
|
|
|
- adapter.handle_message.assert_not_called()
|
|
|
|
|
- adapter.add_reaction.assert_awaited_once()
|
|
|
|
|
|
|
+ adapter._user_cache["u-bob"] = _make_user("u-bob", "bob")
|
|
|
|
|
+ adapter._user_cache["u-carol"] = _make_user("u-carol", "carol")
|
|
|
|
|
+ mentions = await self._dispatch(adapter, "@bob @carol schaut mal drüber")
|
|
|
|
|
+ assert mentions == _Mentions(me=False, someone_else=True)
|
|
|
|
|
|
|
|
async def test_a_real_handle_after_an_unknown_one_still_counts(self):
|
|
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."""
|
|
"""The scan must not stop at the first token it cannot resolve."""
|
|
|
adapter = self._adapter()
|
|
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()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(adapter, "@nonexistent @bob schaut mal drüber")
|
|
|
|
|
+ assert mentions == _Mentions(me=False, someone_else=True)
|
|
|
|
|
|
|
|
async def test_being_named_among_several_others_still_answers(self):
|
|
async def test_being_named_among_several_others_still_answers(self):
|
|
|
adapter = self._adapter()
|
|
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()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(
|
|
|
|
|
+ adapter, "@bob @hermes_bot @carol — was meint ihr?"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert mentions.me
|
|
|
|
|
|
|
|
async def test_broadcast_alongside_a_named_colleague_still_answers(self):
|
|
async def test_broadcast_alongside_a_named_colleague_still_answers(self):
|
|
|
"""@here keeps the bot in the audience; naming bob as well does not
|
|
"""@here keeps the bot in the audience; naming bob as well does not
|
|
|
remove it."""
|
|
remove it."""
|
|
|
adapter = self._adapter()
|
|
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()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(adapter, "@here @bob schaut mal drüber")
|
|
|
|
|
+ assert mentions == _Mentions(me=True, someone_else=True)
|
|
|
|
|
|
|
|
async def test_broadcast_mentions_address_the_bot_too(self):
|
|
async def test_broadcast_mentions_address_the_bot_too(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
# @here/@all are Chatto's virtual handles (FDR-006) — the bot is in
|
|
# @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()
|
|
|
|
|
|
|
+ # their audience. @everyone does not exist as a handle at all: it is an
|
|
|
|
|
+ # unknown candidate, and an unknown handle is nobody, so the message
|
|
|
|
|
+ # still reaches us rather than being acknowledged away.
|
|
|
|
|
+ for body in ("@here standup in 5", "@all heads up"):
|
|
|
|
|
+ assert (await self._dispatch(adapter, body)).me, body
|
|
|
|
|
+ # @everyone resolves to nobody, so it neither reaches nor excludes us.
|
|
|
|
|
+ assert await self._dispatch(adapter, "@everyone hi") == _Mentions(
|
|
|
|
|
+ me=False, someone_else=False
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
async def test_broadcast_mention_alone_is_not_someone_else(self):
|
|
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."""
|
|
"""A pure @here must not be read as a message aimed at another person."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "@here standup in 5")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
- adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
|
|
+ assert await self._dispatch(adapter, "@here standup in 5") == _Mentions(
|
|
|
|
|
+ me=True, someone_else=False
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
async def test_trailing_punctuation_still_resolves_the_handle(self):
|
|
async def test_trailing_punctuation_still_resolves_the_handle(self):
|
|
|
"""'frag @bob.' must resolve bob — a sentence-ending dot is not part of
|
|
"""'frag @bob.' must resolve bob — a sentence-ending dot is not part of
|
|
|
the handle, mirroring the web frontend's extraction."""
|
|
the handle, mirroring the web frontend's extraction."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "frag mal @bob.")
|
|
|
|
|
- adapter.handle_message.assert_not_called()
|
|
|
|
|
- adapter.add_reaction.assert_awaited_once()
|
|
|
|
|
|
|
+ assert (await self._dispatch(adapter, "frag mal @bob.")).someone_else
|
|
|
|
|
|
|
|
async def test_foreign_mention_matching_is_case_insensitive(self):
|
|
async def test_foreign_mention_matching_is_case_insensitive(self):
|
|
|
adapter = self._adapter()
|
|
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 (await self._dispatch(adapter, "@BOB can you take a look?")).someone_else
|
|
|
|
|
|
|
|
async def test_own_mention_matching_is_case_insensitive(self):
|
|
async def test_own_mention_matching_is_case_insensitive(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "@HERMES_BOT ping")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
+ assert (await self._dispatch(adapter, "@HERMES_BOT ping")).me
|
|
|
|
|
|
|
|
async def test_mentions_inside_a_fenced_block_are_ignored(self):
|
|
async def test_mentions_inside_a_fenced_block_are_ignored(self):
|
|
|
"""Upstream does not resolve mentions in code blocks; quoting '@bob'
|
|
"""Upstream does not resolve mentions in code blocks; quoting '@bob'
|
|
|
there must not silence us."""
|
|
there must not silence us."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "so sieht ein ping aus:\n```\n@bob\n```")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(
|
|
|
|
|
+ adapter, "so sieht ein ping aus:\n```\n@bob\n```"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert mentions == _Mentions(me=False, someone_else=False)
|
|
|
|
|
|
|
|
async def test_mentions_inside_an_inline_span_are_ignored(self):
|
|
async def test_mentions_inside_an_inline_span_are_ignored(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "schreib `@bob` mal direkt an")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(adapter, "schreib `@bob` mal direkt an")
|
|
|
|
|
+ assert mentions == _Mentions(me=False, someone_else=False)
|
|
|
|
|
|
|
|
async def test_an_unterminated_fence_shields_the_rest(self):
|
|
async def test_an_unterminated_fence_shields_the_rest(self):
|
|
|
"""An unclosed fence runs to the end of the text, like upstream."""
|
|
"""An unclosed fence runs to the end of the text, like upstream."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "beispiel:\n``` bash\n@bob schaut")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(adapter, "beispiel:\n``` bash\n@bob schaut")
|
|
|
|
|
+ assert mentions == _Mentions(me=False, someone_else=False)
|
|
|
|
|
|
|
|
async def test_broadcast_handles_are_shared_across_cases_in_the_cache(self):
|
|
async def test_broadcast_handles_are_shared_across_cases_in_the_cache(self):
|
|
|
"""The mention namespace is case-insensitive (FDR-006): '@BOB' and
|
|
"""The mention namespace is case-insensitive (FDR-006): '@BOB' and
|
|
@@ -1406,35 +1629,33 @@ class TestForeignMention:
|
|
|
await self._dispatch(adapter, "@BOB ping")
|
|
await self._dispatch(adapter, "@BOB ping")
|
|
|
await self._dispatch(adapter, "@bob again")
|
|
await self._dispatch(adapter, "@bob again")
|
|
|
assert adapter._chatto_client.admin_get_member.await_count == 0
|
|
assert adapter._chatto_client.admin_get_member.await_count == 0
|
|
|
- assert adapter.handle_message.await_count == 0
|
|
|
|
|
|
|
|
|
|
async def test_talking_about_mentions_is_not_a_mention(self):
|
|
async def test_talking_about_mentions_is_not_a_mention(self):
|
|
|
"""Verbatim from the field: the instruction to send a mention later must
|
|
"""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."""
|
|
not read as a mention now. '@-mention' is not a handle anyone holds."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(
|
|
|
|
|
|
|
+ mentions = await self._dispatch(
|
|
|
adapter,
|
|
adapter,
|
|
|
"Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den "
|
|
"Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den "
|
|
|
'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
|
|
'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
|
|
|
)
|
|
)
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
- adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
|
|
+ assert mentions == _Mentions(me=False, someone_else=False)
|
|
|
|
|
|
|
|
async def test_handle_nobody_holds_is_not_a_mention(self):
|
|
async def test_handle_nobody_holds_is_not_a_mention(self):
|
|
|
"""A plausible-looking @token that resolves to no user is not someone
|
|
"""A plausible-looking @token that resolves to no user is not someone
|
|
|
else — answering a false positive beats falling silent on one."""
|
|
else — answering a false positive beats falling silent on one."""
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
adapter._chatto_client.admin_get_member = AsyncMock(return_value={})
|
|
adapter._chatto_client.admin_get_member = AsyncMock(return_value={})
|
|
|
- await self._dispatch(adapter, "gilt das auch für @nonexistent_person?")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
- adapter.add_reaction.assert_not_awaited()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(
|
|
|
|
|
+ adapter, "gilt das auch für @nonexistent_person?"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert mentions == _Mentions(me=False, someone_else=False)
|
|
|
|
|
|
|
|
async def test_a_resolvable_handle_is_looked_up_once(self):
|
|
async def test_a_resolvable_handle_is_looked_up_once(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
await self._dispatch(adapter, "@bob ping")
|
|
await self._dispatch(adapter, "@bob ping")
|
|
|
await self._dispatch(adapter, "@bob again")
|
|
await self._dispatch(adapter, "@bob again")
|
|
|
assert adapter._chatto_client.admin_get_member.await_count == 0
|
|
assert adapter._chatto_client.admin_get_member.await_count == 0
|
|
|
- assert adapter.handle_message.await_count == 0
|
|
|
|
|
|
|
|
|
|
async def test_bot_without_admin_rights_resolves_via_local_knowledge(self):
|
|
async def test_bot_without_admin_rights_resolves_via_local_knowledge(self):
|
|
|
"""From the field: a bot account without admin permissions gets
|
|
"""From the field: a bot account without admin permissions gets
|
|
@@ -1453,26 +1674,27 @@ class TestForeignMention:
|
|
|
adapter._chatto_client.batch_get_room_members = AsyncMock(
|
|
adapter._chatto_client.batch_get_room_members = AsyncMock(
|
|
|
return_value=[DirectoryMember(user=_make_user("user-1", "nickk"))]
|
|
return_value=[DirectoryMember(user=_make_user("user-1", "nickk"))]
|
|
|
)
|
|
)
|
|
|
- await self._dispatch(adapter, "@nickk ein Test wegen Replies")
|
|
|
|
|
- adapter.handle_message.assert_not_called()
|
|
|
|
|
- adapter.add_reaction.assert_awaited_once()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(adapter, "@nickk ein Test wegen Replies")
|
|
|
|
|
+ assert mentions == _Mentions(me=False, someone_else=True)
|
|
|
adapter._chatto_client.admin_get_member.assert_not_awaited()
|
|
adapter._chatto_client.admin_get_member.assert_not_awaited()
|
|
|
|
|
|
|
|
async def test_an_email_address_is_not_a_mention(self):
|
|
async def test_an_email_address_is_not_a_mention(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "schreib an bob@example.com")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
+ assert not (
|
|
|
|
|
+ await self._dispatch(adapter, "schreib an bob@example.com")
|
|
|
|
|
+ ).someone_else
|
|
|
adapter._chatto_client.admin_get_member.assert_not_awaited()
|
|
adapter._chatto_client.admin_get_member.assert_not_awaited()
|
|
|
|
|
|
|
|
async def test_plain_message_is_unaffected(self):
|
|
async def test_plain_message_is_unaffected(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "how do I reset the cache?")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
+ assert await self._dispatch(adapter, "how do I reset the cache?") == _Mentions()
|
|
|
|
|
|
|
|
async def test_dms_are_answered_even_when_they_name_someone_else(self):
|
|
async def test_dms_are_answered_even_when_they_name_someone_else(self):
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
- await self._dispatch(adapter, "@bob said the build is red", room_id="dm-1")
|
|
|
|
|
- adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
+ mentions = await self._dispatch(
|
|
|
|
|
+ adapter, "@bob said the build is red", room_id="dm-1"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert mentions == _Mentions(me=False, someone_else=True)
|
|
|
|
|
|
|
|
async def test_require_list_keeps_discarding_without_a_reaction(self):
|
|
async def test_require_list_keeps_discarding_without_a_reaction(self):
|
|
|
"""The require gate wins: it drops the message before we get here, and
|
|
"""The require gate wins: it drops the message before we get here, and
|
|
@@ -1508,8 +1730,16 @@ class TestRoomPolicies:
|
|
|
adapter.handle_message = AsyncMock()
|
|
adapter.handle_message = AsyncMock()
|
|
|
return adapter
|
|
return adapter
|
|
|
|
|
|
|
|
- async def _dispatch(self, adapter, room_id, body):
|
|
|
|
|
- payload = _make_posted_payload(room_id=room_id)
|
|
|
|
|
|
|
+ async def _dispatch(self, adapter, room_id, body, mentions=None):
|
|
|
|
|
+ """Dispatch a message, defaulting to one that addressed nobody.
|
|
|
|
|
+
|
|
|
|
|
+ These tests are about room policy, not about who a message addresses,
|
|
|
|
|
+ so the default is "no mention targets" and the ones that need a
|
|
|
|
|
+ delivered message say so.
|
|
|
|
|
+ """
|
|
|
|
|
+ payload = _make_posted_payload(
|
|
|
|
|
+ room_id=room_id, mentions=[] if mentions is None else mentions
|
|
|
|
|
+ )
|
|
|
_with_get_message(adapter, payload, _make_message(body=body, room_id=room_id))
|
|
_with_get_message(adapter, payload, _make_message(body=body, room_id=room_id))
|
|
|
await adapter._dispatch_message_posted(payload, payload.message_event_id)
|
|
await adapter._dispatch_message_posted(payload, payload.message_event_id)
|
|
|
|
|
|
|
@@ -1560,7 +1790,8 @@ class TestRoomPolicies:
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
adapter.chatto_config.require_mention_rooms.value = ["room-1"]
|
|
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")
|
|
|
|
|
|
|
+ mentions = _mentions_me()
|
|
|
|
|
+ await self._dispatch(adapter, "room-1", "@hermes_bot hi there", mentions)
|
|
|
adapter.handle_message.assert_called_once()
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
|
async def test_broadcast_mention_counts_as_addressed(self):
|
|
async def test_broadcast_mention_counts_as_addressed(self):
|
|
@@ -1569,7 +1800,9 @@ class TestRoomPolicies:
|
|
|
adapter = self._adapter()
|
|
adapter = self._adapter()
|
|
|
adapter.chatto_config.require_mention_rooms.value = ["room-1"]
|
|
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")
|
|
|
|
|
|
|
+ broadcast = _make_mentions(("here", None))
|
|
|
|
|
+ broadcast[0].includes_viewer = True
|
|
|
|
|
+ await self._dispatch(adapter, "room-1", "@here standup in 5", broadcast)
|
|
|
adapter.handle_message.assert_called_once()
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
|
|
|
|
async def test_optional_list_room_answers_without_a_mention(self):
|
|
async def test_optional_list_room_answers_without_a_mention(self):
|
|
@@ -3806,7 +4039,11 @@ class TestDmRoomCommands:
|
|
|
adapter.chatto_config.require_mention_rooms.value = ["chan-1"]
|
|
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")
|
|
|
|
|
|
|
+ payload = _make_posted_payload(room_id="chan-1", mentions=[])
|
|
|
|
|
+ _with_get_message(
|
|
|
|
|
+ adapter, payload, _make_message(body="/leave room-7", room_id="chan-1")
|
|
|
|
|
+ )
|
|
|
|
|
+ await adapter._dispatch_message_posted(payload, payload.message_event_id)
|
|
|
|
|
|
|
|
adapter._chatto_client.leave_room.assert_not_awaited()
|
|
adapter._chatto_client.leave_room.assert_not_awaited()
|
|
|
adapter.handle_message.assert_not_called()
|
|
adapter.handle_message.assert_not_called()
|