|
@@ -28,10 +28,13 @@ import hashlib
|
|
|
import logging
|
|
import logging
|
|
|
import mimetypes
|
|
import mimetypes
|
|
|
import os
|
|
import os
|
|
|
|
|
+import tempfile
|
|
|
from datetime import UTC, datetime
|
|
from datetime import UTC, datetime
|
|
|
from enum import StrEnum
|
|
from enum import StrEnum
|
|
|
from typing import Any, cast
|
|
from typing import Any, cast
|
|
|
-from urllib.parse import urlsplit
|
|
|
|
|
|
|
+from urllib.parse import unquote, urlsplit
|
|
|
|
|
+
|
|
|
|
|
+import httpx
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
@@ -77,6 +80,7 @@ try:
|
|
|
)
|
|
)
|
|
|
from chattolib.types import (
|
|
from chattolib.types import (
|
|
|
Message,
|
|
Message,
|
|
|
|
|
+ MessageAttachment,
|
|
|
PresenceStatus,
|
|
PresenceStatus,
|
|
|
Room,
|
|
Room,
|
|
|
RoomKind,
|
|
RoomKind,
|
|
@@ -245,7 +249,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
super().__init__(
|
|
super().__init__(
|
|
|
config=pconfig, platform=Platform(ChattoConstants.PLATFORM_NAME)
|
|
config=pconfig, platform=Platform(ChattoConstants.PLATFORM_NAME)
|
|
|
)
|
|
)
|
|
|
- # "extra" has been pre-processed by Hermes-Framework to be a dict of extra config values insode PlatformConfig.
|
|
|
|
|
|
|
+ # "extra" has been pre-populated by Hermes from config.yaml's extra block.
|
|
|
|
|
|
|
|
# --- Configuration from our configuration data class with some logic ---
|
|
# --- Configuration from our configuration data class with some logic ---
|
|
|
self.chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig)
|
|
self.chatto_config: ChattoConfiguration = ChattoConfiguration(pconfig)
|
|
@@ -258,10 +262,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
# --- Runtime state ---
|
|
# --- Runtime state ---
|
|
|
self._room_names: dict[str, str] = {}
|
|
self._room_names: dict[str, str] = {}
|
|
|
self._room_kinds: dict[str, RoomKind] = {}
|
|
self._room_kinds: dict[str, RoomKind] = {}
|
|
|
- self._our_thread_roots: set = set() # thread root event IDs we created
|
|
|
|
|
- self._our_message_ids: set = (
|
|
|
|
|
- set()
|
|
|
|
|
- ) # message IDs we sent (for thread root detection)
|
|
|
|
|
# Event IDs already processed — chattolib may redeliver events across
|
|
# Event IDs already processed — chattolib may redeliver events across
|
|
|
# reconnects, so every inbound event is checked against this list.
|
|
# reconnects, so every inbound event is checked against this list.
|
|
|
self._seen: list[str] = []
|
|
self._seen: list[str] = []
|
|
@@ -419,8 +419,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
retryable=False,
|
|
retryable=False,
|
|
|
)
|
|
)
|
|
|
try:
|
|
try:
|
|
|
- assert self._chatto_client
|
|
|
|
|
- await self._chatto_client.close()
|
|
|
|
|
|
|
+ if self._chatto_client is not None:
|
|
|
|
|
+ await self._chatto_client.close()
|
|
|
finally:
|
|
finally:
|
|
|
self._chatto_client = None
|
|
self._chatto_client = None
|
|
|
return False
|
|
return False
|
|
@@ -628,9 +628,14 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
def _check_auth(self, user: User) -> bool:
|
|
def _check_auth(self, user: User) -> bool:
|
|
|
- """We roll our own auth-systen on the against the chatto_config'ured allowed_users etc.
|
|
|
|
|
-
|
|
|
|
|
- because.. Hermes authz_mixin.py IS NOT SANE.
|
|
|
|
|
|
|
+ """Whether this Chatto user may talk to the agent.
|
|
|
|
|
+
|
|
|
|
|
+ Deliberately our own gate instead of the gateway's authz_mixin: its
|
|
|
|
|
+ group allowlists key on chat_type and per-platform env vars
|
|
|
|
|
+ (``{PLATFORM}_GROUP_ALLOWED_USERS``), none of which fit Chatto's one
|
|
|
|
|
+ flat member directory. ``CHATTO_ALLOWED_USERS`` matches login and id,
|
|
|
|
|
+ ``CHATTO_ALLOW_ALL_USERS`` overrides both — the gate stays
|
|
|
|
|
+ chat_type-independent by design.
|
|
|
"""
|
|
"""
|
|
|
if self.chatto_config.allow_all_users.value:
|
|
if self.chatto_config.allow_all_users.value:
|
|
|
return True
|
|
return True
|
|
@@ -797,8 +802,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
as chunks arrive, because a missing or lying header must not smuggle an
|
|
as chunks arrive, because a missing or lying header must not smuggle an
|
|
|
unbounded body past the cap.
|
|
unbounded body past the cap.
|
|
|
"""
|
|
"""
|
|
|
- import httpx
|
|
|
|
|
-
|
|
|
|
|
max_bytes = get_inbound_media_max_bytes()
|
|
max_bytes = get_inbound_media_max_bytes()
|
|
|
chunks: list[bytes] = []
|
|
chunks: list[bytes] = []
|
|
|
total = 0
|
|
total = 0
|
|
@@ -834,8 +837,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
async def _cache_attachments(
|
|
async def _cache_attachments(
|
|
|
self,
|
|
self,
|
|
|
- room_id: str,
|
|
|
|
|
- attachments: list[Any],
|
|
|
|
|
|
|
+ attachments: list[MessageAttachment],
|
|
|
) -> tuple[list[str], list[str], list[str]]:
|
|
) -> tuple[list[str], list[str], list[str]]:
|
|
|
"""Download message attachments into the gateway media cache.
|
|
"""Download message attachments into the gateway media cache.
|
|
|
|
|
|
|
@@ -848,11 +850,10 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
media_types: list[str] = []
|
|
media_types: list[str] = []
|
|
|
media_kinds: list[str] = []
|
|
media_kinds: list[str] = []
|
|
|
|
|
|
|
|
- for att in attachments or []:
|
|
|
|
|
- asset_url = getattr(att, "asset_url", None)
|
|
|
|
|
- url = getattr(asset_url, "url", "") if asset_url else ""
|
|
|
|
|
- filename = getattr(att, "filename", "") or ""
|
|
|
|
|
- content_type = getattr(att, "content_type", "") or ""
|
|
|
|
|
|
|
+ for att in attachments:
|
|
|
|
|
+ url = att.asset_url.url if att.asset_url else ""
|
|
|
|
|
+ filename = att.filename or ""
|
|
|
|
|
+ content_type = att.content_type or ""
|
|
|
if not url:
|
|
if not url:
|
|
|
# Videos are announced before transcoding finishes, so the
|
|
# Videos are announced before transcoding finishes, so the
|
|
|
# signed URL can legitimately be missing on arrival.
|
|
# signed URL can legitimately be missing on arrival.
|
|
@@ -1045,7 +1046,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
was_dispatched
|
|
was_dispatched
|
|
|
and payload.message_event_id not in self._processing.values()
|
|
and payload.message_event_id not in self._processing.values()
|
|
|
and not any(
|
|
and not any(
|
|
|
- getattr(pending, "message_id", None) == payload.message_event_id
|
|
|
|
|
|
|
+ pending.message_id == payload.message_event_id
|
|
|
for pending in self._pending_messages.values()
|
|
for pending in self._pending_messages.values()
|
|
|
)
|
|
)
|
|
|
):
|
|
):
|
|
@@ -1244,15 +1245,11 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
# Attachments — download and hand the local cache paths to the gateway,
|
|
# Attachments — download and hand the local cache paths to the gateway,
|
|
|
# which runs vision enrichment / document extraction off media_urls.
|
|
# which runs vision enrichment / document extraction off media_urls.
|
|
|
- attachments = list(message.attachments or [])
|
|
|
|
|
(
|
|
(
|
|
|
message_event.media_urls,
|
|
message_event.media_urls,
|
|
|
message_event.media_types,
|
|
message_event.media_types,
|
|
|
media_kinds,
|
|
media_kinds,
|
|
|
- ) = await self._cache_attachments(
|
|
|
|
|
- room_id,
|
|
|
|
|
- attachments,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ ) = await self._cache_attachments(list(message.attachments))
|
|
|
if media_kinds:
|
|
if media_kinds:
|
|
|
# Same precedence as the Teams/Signal adapters: document-context
|
|
# Same precedence as the Teams/Signal adapters: document-context
|
|
|
# injection gates strictly on DOCUMENT, image handling keys off the
|
|
# injection gates strictly on DOCUMENT, image handling keys off the
|
|
@@ -1416,7 +1413,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
await self._sleep_interruptible(wait)
|
|
await self._sleep_interruptible(wait)
|
|
|
continue
|
|
continue
|
|
|
except ChattoRealtimeError as exc:
|
|
except ChattoRealtimeError as exc:
|
|
|
- if getattr(exc, "fatal", False):
|
|
|
|
|
|
|
+ if exc.fatal:
|
|
|
logger.error(
|
|
logger.error(
|
|
|
"Chatto: fatal realtime error (%s): %s", exc.code, exc.message
|
|
"Chatto: fatal realtime error (%s): %s", exc.code, exc.message
|
|
|
)
|
|
)
|
|
@@ -1579,11 +1576,15 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
for _rid in list(self._joined_room_ids):
|
|
for _rid in list(self._joined_room_ids):
|
|
|
try:
|
|
try:
|
|
|
await client.mark_room_as_read(room_id=_rid)
|
|
await client.mark_room_as_read(room_id=_rid)
|
|
|
- await client.dismiss_all_notifications()
|
|
|
|
|
except Exception:
|
|
except Exception:
|
|
|
logger.debug(
|
|
logger.debug(
|
|
|
"Chatto: mark_room_as_read failed for %s", _rid, exc_info=True
|
|
"Chatto: mark_room_as_read failed for %s", _rid, exc_info=True
|
|
|
)
|
|
)
|
|
|
|
|
+ # Dismissal is server-global — once after the per-room sweep.
|
|
|
|
|
+ try:
|
|
|
|
|
+ await client.dismiss_all_notifications()
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ logger.debug("Chatto: dismiss_all_notifications failed", exc_info=True)
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# ------------------------------------------------------------------ #
|
|
|
# Sending (ConnectRPC — unchanged from polling version)
|
|
# Sending (ConnectRPC — unchanged from polling version)
|
|
@@ -1667,11 +1668,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
self._mark_seen(msg_obj.id)
|
|
self._mark_seen(msg_obj.id)
|
|
|
message_ids.append(msg_obj.id)
|
|
message_ids.append(msg_obj.id)
|
|
|
- self._our_message_ids.add(msg_obj.id)
|
|
|
|
|
- # If we sent a message WITHOUT a thread_id, this message could
|
|
|
|
|
- # become a thread root if someone replies to it
|
|
|
|
|
- if not thread_id:
|
|
|
|
|
- self._our_thread_roots.add(msg_obj.id)
|
|
|
|
|
# Auto-thread: first chunk becomes the thread root,
|
|
# Auto-thread: first chunk becomes the thread root,
|
|
|
# subsequent chunks go in the thread
|
|
# subsequent chunks go in the thread
|
|
|
if use_auto_thread and i == 0 and not thread_id:
|
|
if use_auto_thread and i == 0 and not thread_id:
|
|
@@ -1796,7 +1792,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
# Our own edit comes back as a message_edited event; mark it seen so it
|
|
# Our own edit comes back as a message_edited event; mark it seen so it
|
|
|
# is never mistaken for inbound traffic.
|
|
# is never mistaken for inbound traffic.
|
|
|
- edited_id = getattr(msg, "id", "") or str(message_id)
|
|
|
|
|
|
|
+ edited_id = msg.id or str(message_id)
|
|
|
self._mark_seen(edited_id)
|
|
self._mark_seen(edited_id)
|
|
|
return SendResult(success=True, message_id=edited_id)
|
|
return SendResult(success=True, message_id=edited_id)
|
|
|
|
|
|
|
@@ -1865,14 +1861,12 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
)
|
|
)
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
- seed_id = getattr(msg, "id", "") or ""
|
|
|
|
|
|
|
+ seed_id = msg.id
|
|
|
if not seed_id:
|
|
if not seed_id:
|
|
|
logger.warning("Chatto: handoff thread seed-post returned no message id")
|
|
logger.warning("Chatto: handoff thread seed-post returned no message id")
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
self._mark_seen(seed_id)
|
|
self._mark_seen(seed_id)
|
|
|
- self._our_message_ids.add(seed_id)
|
|
|
|
|
- self._our_thread_roots.add(seed_id)
|
|
|
|
|
try:
|
|
try:
|
|
|
await client.follow_thread(str(parent_chat_id), seed_id)
|
|
await client.follow_thread(str(parent_chat_id), seed_id)
|
|
|
except Exception:
|
|
except Exception:
|
|
@@ -1964,7 +1958,10 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
return emoji
|
|
return emoji
|
|
|
|
|
|
|
|
async def add_reaction(self, room_id: str, message_id: str, emoji: str) -> bool:
|
|
async def add_reaction(self, room_id: str, message_id: str, emoji: str) -> bool:
|
|
|
- """Add a reaction to a message via MessageService/AddReaction."""
|
|
|
|
|
|
|
+ """Add a reaction to a message via MessageService/AddReaction.
|
|
|
|
|
+
|
|
|
|
|
+ BasePlatformAdapter override
|
|
|
|
|
+ """
|
|
|
shortcode = self._emoji_to_shortcode(emoji)
|
|
shortcode = self._emoji_to_shortcode(emoji)
|
|
|
client = await self._get_chatto_client()
|
|
client = await self._get_chatto_client()
|
|
|
if client is None:
|
|
if client is None:
|
|
@@ -1985,7 +1982,10 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
|
|
async def remove_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
|
|
|
- """Remove a reaction from a message via MessageService/RemoveReaction."""
|
|
|
|
|
|
|
+ """Remove a reaction from a message via MessageService/RemoveReaction.
|
|
|
|
|
+
|
|
|
|
|
+ BasePlatformAdapter override
|
|
|
|
|
+ """
|
|
|
shortcode = self._emoji_to_shortcode(emoji)
|
|
shortcode = self._emoji_to_shortcode(emoji)
|
|
|
client = await self._get_chatto_client()
|
|
client = await self._get_chatto_client()
|
|
|
if client is None:
|
|
if client is None:
|
|
@@ -2013,6 +2013,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
"""Start a direct message with a user via RoomService/StartDM.
|
|
"""Start a direct message with a user via RoomService/StartDM.
|
|
|
|
|
|
|
|
Returns the room ID on success, or None on failure.
|
|
Returns the room ID on success, or None on failure.
|
|
|
|
|
+
|
|
|
|
|
+ BasePlatformAdapter override
|
|
|
"""
|
|
"""
|
|
|
if not user_id:
|
|
if not user_id:
|
|
|
return None
|
|
return None
|
|
@@ -2045,6 +2047,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
"""Create an ad-hoc room via RoomService/CreateRoom.
|
|
"""Create an ad-hoc room via RoomService/CreateRoom.
|
|
|
|
|
|
|
|
Returns the room ID on success, or None on failure.
|
|
Returns the room ID on success, or None on failure.
|
|
|
|
|
+
|
|
|
|
|
+ BasePlatformAdapter override
|
|
|
"""
|
|
"""
|
|
|
client = await self._get_chatto_client()
|
|
client = await self._get_chatto_client()
|
|
|
if client is None:
|
|
if client is None:
|
|
@@ -2208,7 +2212,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
logger.error("Chatto: CompleteUpload returned no asset")
|
|
logger.error("Chatto: CompleteUpload returned no asset")
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
- asset_id = str(getattr(cast(Any, asset), "id", ""))
|
|
|
|
|
|
|
+ asset_id = asset.id
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"Chatto: uploaded %s as asset %s (%d bytes)",
|
|
"Chatto: uploaded %s as asset %s (%d bytes)",
|
|
|
file_name,
|
|
file_name,
|
|
@@ -2250,7 +2254,6 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
thread_root_event_id=str(thread_id) if thread_id else "",
|
|
thread_root_event_id=str(thread_id) if thread_id else "",
|
|
|
)
|
|
)
|
|
|
self._mark_seen(msg.id)
|
|
self._mark_seen(msg.id)
|
|
|
- self._our_message_ids.add(msg.id)
|
|
|
|
|
return SendResult(success=True, message_id=msg.id)
|
|
return SendResult(success=True, message_id=msg.id)
|
|
|
except ChattoError as e:
|
|
except ChattoError as e:
|
|
|
return SendResult(success=False, error=str(e), retryable=True)
|
|
return SendResult(success=False, error=str(e), retryable=True)
|
|
@@ -2453,11 +2456,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
Accepts ``http(s)://`` URLs (downloaded to a temp file), ``file://``
|
|
Accepts ``http(s)://`` URLs (downloaded to a temp file), ``file://``
|
|
|
URIs and bare paths. Returns ``(path, is_temp)`` — the caller unlinks
|
|
URIs and bare paths. Returns ``(path, is_temp)`` — the caller unlinks
|
|
|
- when ``is_temp``. ``(None, False)`` means the entry is unusable.
|
|
|
|
|
|
|
+ when ``is_temp``. ``(None, False)`` means the entry is unusable.
|
|
|
"""
|
|
"""
|
|
|
- import tempfile
|
|
|
|
|
- from urllib.parse import unquote as _unquote
|
|
|
|
|
-
|
|
|
|
|
if image_url.startswith(("http://", "https://")):
|
|
if image_url.startswith(("http://", "https://")):
|
|
|
parsed = urlsplit(image_url)
|
|
parsed = urlsplit(image_url)
|
|
|
ext = os.path.splitext(parsed.path)[1] or ".png"
|
|
ext = os.path.splitext(parsed.path)[1] or ".png"
|
|
@@ -2477,7 +2477,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
local = image_url
|
|
local = image_url
|
|
|
if local.startswith("file://"):
|
|
if local.startswith("file://"):
|
|
|
- local = _unquote(urlsplit(local).path)
|
|
|
|
|
|
|
+ local = unquote(urlsplit(local).path)
|
|
|
return self.validate_media_delivery_path(local), False
|
|
return self.validate_media_delivery_path(local), False
|
|
|
|
|
|
|
|
async def send_multiple_images(
|
|
async def send_multiple_images(
|
|
@@ -2645,18 +2645,13 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
|
|
|
)
|
|
)
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
- if chatto_config.base_url.value:
|
|
|
|
|
- if (chatto_config.token.value is not None) or (
|
|
|
|
|
- chatto_config.login.value and chatto_config.password.value
|
|
|
|
|
- ):
|
|
|
|
|
- return True
|
|
|
|
|
- else:
|
|
|
|
|
- logger.error(
|
|
|
|
|
- "Chatto: Minimally, either token or login/password must be set."
|
|
|
|
|
- )
|
|
|
|
|
- else:
|
|
|
|
|
- logger.error("Chatto: base_url must be set.")
|
|
|
|
|
-
|
|
|
|
|
|
|
+ # base_url always resolves (it defaults to ChattoHQ), so the only real
|
|
|
|
|
+ # question is whether any credentials came in.
|
|
|
|
|
+ if (chatto_config.token.value is not None) or (
|
|
|
|
|
+ chatto_config.login.value and chatto_config.password.value
|
|
|
|
|
+ ):
|
|
|
|
|
+ return True
|
|
|
|
|
+ logger.error("Chatto: Minimally, either token or login/password must be set.")
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
@@ -2678,9 +2673,12 @@ def hermes_check_fn() -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
def hermes_is_connected(config: PlatformConfig) -> bool:
|
|
def hermes_is_connected(config: PlatformConfig) -> bool:
|
|
|
- """Check whether Chatto Plugin is connected. But where to: the Hermes Agent or the Chatto server.
|
|
|
|
|
|
|
+ """Report whether the Chatto platform is configured and enabled.
|
|
|
|
|
|
|
|
- The Hermes Agent plugin docs suck and it seems there are many functions to do the same.
|
|
|
|
|
|
|
+ The name is fixed by the register() contract, but despite what it
|
|
|
|
|
+ suggests this does not open a connection — it validates configuration
|
|
|
|
|
+ only (see :func:`hermes_validate_config`); the gateway probes real
|
|
|
|
|
+ connectivity through ``connect()``.
|
|
|
"""
|
|
"""
|
|
|
return bool(hermes_validate_config(config) and config.enabled)
|
|
return bool(hermes_validate_config(config) and config.enabled)
|
|
|
|
|
|
|
@@ -2814,11 +2812,6 @@ def register(ctx) -> None:
|
|
|
for capability in _capabilities():
|
|
for capability in _capabilities():
|
|
|
logger.info("Chatto capability: %s", capability)
|
|
logger.info("Chatto capability: %s", capability)
|
|
|
|
|
|
|
|
- logger.info(
|
|
|
|
|
- "ChattoConfiguration.allowed_users.env_name: %s",
|
|
|
|
|
- ChattoConfiguration.allowed_users.env_name,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
ctx.register_platform(
|
|
ctx.register_platform(
|
|
|
name=ChattoConstants.PLATFORM_NAME, # this will be the config.yaml key.
|
|
name=ChattoConstants.PLATFORM_NAME, # this will be the config.yaml key.
|
|
|
label=ChattoConstants.PLATFORM_LABEL,
|
|
label=ChattoConstants.PLATFORM_LABEL,
|