|
@@ -28,6 +28,7 @@ import hashlib
|
|
|
import logging
|
|
import logging
|
|
|
import mimetypes
|
|
import mimetypes
|
|
|
import os
|
|
import os
|
|
|
|
|
+import re
|
|
|
import tempfile
|
|
import tempfile
|
|
|
from datetime import UTC, datetime
|
|
from datetime import UTC, datetime
|
|
|
from enum import StrEnum
|
|
from enum import StrEnum
|
|
@@ -568,32 +569,60 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
# WebSocket Realtime Transport
|
|
# WebSocket Realtime Transport
|
|
|
# ------------------------------------------------------------------ #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
+ def _own_handles(self) -> set[str]:
|
|
|
|
|
+ """The handles that address this bot, lowercased for comparison.
|
|
|
|
|
+
|
|
|
|
|
+ Chatto resolves mentions case-insensitively (FDR-006), so ``@Hermes_Bot``
|
|
|
|
|
+ and ``@hermes_bot`` are the same handle everywhere in our gates.
|
|
|
|
|
+ """
|
|
|
|
|
+ if not self.me:
|
|
|
|
|
+ return set()
|
|
|
|
|
+ return {
|
|
|
|
|
+ handle.lower() for handle in (self.me.login, self.me.display_name) if handle
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def _mention_candidates(self, body: str) -> list[str]:
|
|
|
|
|
+ """Candidate @-handles in a message body, in order of appearance.
|
|
|
|
|
+
|
|
|
|
|
+ Mirrors the Chatto web frontend's extraction (apps/frontend/src/lib/
|
|
|
|
|
+ mentions.ts upstream): candidates come from ``ChattoConstants.
|
|
|
|
|
+ MENTION_RE`` outside code regions. Mentions inside fenced code blocks
|
|
|
|
|
+ and inline code spans do not resolve upstream either, so a ``@bob``
|
|
|
|
|
+ quoted in a snippet must not gate our behaviour.
|
|
|
|
|
+ """
|
|
|
|
|
+ without_fences = re.sub(r"(?s)(```|~~~).*?(\1|$)", " ", body)
|
|
|
|
|
+ without_code = re.sub(r"`[^`\n]*`", " ", without_fences)
|
|
|
|
|
+ return ChattoConstants.MENTION_RE.findall(without_code)
|
|
|
|
|
+
|
|
|
def _mentions_me(self, body: str) -> bool:
|
|
def _mentions_me(self, body: str) -> bool:
|
|
|
"""Whether the message addresses this bot.
|
|
"""Whether the message addresses this bot.
|
|
|
|
|
|
|
|
By login, by display name, or by a broadcast handle — ``@here`` speaks
|
|
By login, by display name, or by a broadcast handle — ``@here`` speaks
|
|
|
to everyone present and the bot is one of them, so naming a colleague
|
|
to everyone present and the bot is one of them, so naming a colleague
|
|
|
- alongside it does not take the bot out of the audience.
|
|
|
|
|
|
|
+ alongside it does not take the bot out of the audience. Matching is
|
|
|
|
|
+ case-insensitive, like every mention resolution in Chatto.
|
|
|
"""
|
|
"""
|
|
|
- if not self.me:
|
|
|
|
|
- return False
|
|
|
|
|
- for handle in (self.me.login, self.me.display_name):
|
|
|
|
|
- if handle and f"@{handle}" in body:
|
|
|
|
|
|
|
+ own = self._own_handles()
|
|
|
|
|
+ for handle in self._mention_candidates(body):
|
|
|
|
|
+ lowered = handle.lower()
|
|
|
|
|
+ if lowered in own or lowered in ChattoConstants.BROADCAST_MENTIONS:
|
|
|
return True
|
|
return True
|
|
|
- return any(
|
|
|
|
|
- handle.lower() in ChattoConstants.BROADCAST_MENTIONS
|
|
|
|
|
- for handle in ChattoConstants.MENTION_RE.findall(body)
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ return False
|
|
|
|
|
|
|
|
async def _handle_belongs_to_a_user(self, handle: str) -> bool:
|
|
async def _handle_belongs_to_a_user(self, handle: str) -> bool:
|
|
|
"""Whether ``handle`` is the login of a real Chatto user.
|
|
"""Whether ``handle`` is the login of a real Chatto user.
|
|
|
|
|
|
|
|
The API carries no mention entities — ``mention_confirmation_token`` is
|
|
The API carries no mention entities — ``mention_confirmation_token`` is
|
|
|
reserved in the message descriptor — so an @-token is only a candidate
|
|
reserved in the message descriptor — so an @-token is only a candidate
|
|
|
- until the directory confirms it. Results are cached both ways, since
|
|
|
|
|
- the same handles recur and a miss is as reusable as a hit.
|
|
|
|
|
|
|
+ until the directory confirms it. Results are cached both ways under the
|
|
|
|
|
+ lowercased handle (the mention namespace is case-insensitive per
|
|
|
|
|
+ FDR-006), since the same handles recur and a miss is as reusable as a
|
|
|
|
|
+ hit. Only logins are looked up: matching another user's display name,
|
|
|
|
|
+ as the web frontend does against its room member list, has no directory
|
|
|
|
|
+ equivalent here.
|
|
|
"""
|
|
"""
|
|
|
- known = self._known_handles.get(handle)
|
|
|
|
|
|
|
+ cache_key = handle.lower()
|
|
|
|
|
+ known = self._known_handles.get(cache_key)
|
|
|
if known is not None:
|
|
if known is not None:
|
|
|
return known
|
|
return known
|
|
|
client = await self._get_chatto_client()
|
|
client = await self._get_chatto_client()
|
|
@@ -606,7 +635,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
logger.debug("Chatto: could not resolve handle @%s: %s", handle, exc)
|
|
logger.debug("Chatto: could not resolve handle @%s: %s", handle, exc)
|
|
|
return False
|
|
return False
|
|
|
exists = member is not None and member.user is not None
|
|
exists = member is not None and member.user is not None
|
|
|
- self._known_handles[handle] = exists
|
|
|
|
|
|
|
+ self._known_handles[cache_key] = exists
|
|
|
return exists
|
|
return exists
|
|
|
|
|
|
|
|
async def _mentions_someone_else(self, body: str) -> bool:
|
|
async def _mentions_someone_else(self, body: str) -> bool:
|
|
@@ -618,10 +647,12 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
("per @-mention", "@nonexistent") is talking to us, and staying silent
|
|
("per @-mention", "@nonexistent") is talking to us, and staying silent
|
|
|
on a false positive is worse than answering one.
|
|
on a false positive is worse than answering one.
|
|
|
"""
|
|
"""
|
|
|
- for handle in ChattoConstants.MENTION_RE.findall(body):
|
|
|
|
|
- if handle.lower() in ChattoConstants.BROADCAST_MENTIONS:
|
|
|
|
|
|
|
+ own = self._own_handles()
|
|
|
|
|
+ for handle in self._mention_candidates(body):
|
|
|
|
|
+ lowered = handle.lower()
|
|
|
|
|
+ if lowered in ChattoConstants.BROADCAST_MENTIONS:
|
|
|
continue
|
|
continue
|
|
|
- if self.me and handle in (self.me.login, self.me.display_name):
|
|
|
|
|
|
|
+ if lowered in own:
|
|
|
continue
|
|
continue
|
|
|
if await self._handle_belongs_to_a_user(handle):
|
|
if await self._handle_belongs_to_a_user(handle):
|
|
|
return True
|
|
return True
|
|
@@ -1174,25 +1205,19 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
# require_mention deliberately gates channels only: in a channel the bot
|
|
# require_mention deliberately gates channels only: in a channel the bot
|
|
|
# is one of many listeners and must be addressed, whereas a DM is already
|
|
# is one of many listeners and must be addressed, whereas a DM is already
|
|
|
- # addressed at it — so DMs are always answered, mention or not.
|
|
|
|
|
- mentioned = False
|
|
|
|
|
|
|
+ # addressed at it — so DMs are always answered, mention or not. The
|
|
|
|
|
+ # shared _mentions_me gate keeps this path and the someone-else check
|
|
|
|
|
+ # below on one definition of "addressed", broadcast handles included.
|
|
|
if (
|
|
if (
|
|
|
room_kind == RoomKind.CHANNEL
|
|
room_kind == RoomKind.CHANNEL
|
|
|
and self.chatto_config.require_mention.value
|
|
and self.chatto_config.require_mention.value
|
|
|
- and self.me
|
|
|
|
|
|
|
+ and not self._mentions_me(message_body)
|
|
|
):
|
|
):
|
|
|
- if self.me.login and not mentioned:
|
|
|
|
|
- mentioned = bool(f"@{self.me.login}" in message_body)
|
|
|
|
|
- if self.me.display_name and not mentioned:
|
|
|
|
|
- mentioned = bool(f"@{self.me.display_name}" in message_body)
|
|
|
|
|
- if mentioned is False:
|
|
|
|
|
- logger.debug(
|
|
|
|
|
- "Discarding message. Bot was not mentionend but require_mention is '%s'.",
|
|
|
|
|
- self.chatto_config.require_mention.value,
|
|
|
|
|
- )
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- logger.debug("mentioned: %s", mentioned)
|
|
|
|
|
|
|
+ logger.debug(
|
|
|
|
|
+ "Discarding message. Bot was not mentioned but require_mention is '%s'.",
|
|
|
|
|
+ self.chatto_config.require_mention.value,
|
|
|
|
|
+ )
|
|
|
|
|
+ return None
|
|
|
|
|
|
|
|
# With require_mention off we see every message in the channel, including
|
|
# With require_mention off we see every message in the channel, including
|
|
|
# ones plainly aimed at a named colleague. Answering those would be
|
|
# ones plainly aimed at a named colleague. Answering those would be
|