Merge commit '6110aed9b' into feat/whatsapp-cloud-api
This commit is contained in:
+840
-48
File diff suppressed because it is too large
Load Diff
+1112
-71
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
@@ -43,6 +44,15 @@ DEFAULT_WEBHOOK_PORT = 8645
|
||||
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
|
||||
MAX_TEXT_LENGTH = 4000
|
||||
|
||||
# BlueBubbles/iMessage does not expose a stable bot mention identity like
|
||||
# Slack (<@U...>), Telegram (@botname), or Matrix (MXID). When users opt into
|
||||
# group mention gating without custom aliases, use conservative Hermes wake
|
||||
# words so `require_mention: true` is a one-line enablement path.
|
||||
DEFAULT_MENTION_PATTERNS = [
|
||||
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
|
||||
r"(?<![\w@])@?hermes\b[,:\-]?",
|
||||
]
|
||||
|
||||
# Tapback reaction codes (BlueBubbles associatedMessageType values)
|
||||
_TAPBACK_ADDED = {
|
||||
2000: "love", 2001: "like", 2002: "dislike",
|
||||
@@ -60,6 +70,8 @@ _MESSAGE_EVENTS = {"new-message", "message", "updated-message"}
|
||||
_PHONE_RE = re.compile(r"\+?\d{7,15}")
|
||||
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
|
||||
|
||||
_GUID_CACHE_SIZE = 500 # LRU cap for resolved chat-GUID lookups
|
||||
|
||||
|
||||
def _redact(text: str) -> str:
|
||||
"""Redact phone numbers and emails from log output."""
|
||||
@@ -124,11 +136,20 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if not str(self.webhook_path).startswith("/"):
|
||||
self.webhook_path = f"/{self.webhook_path}"
|
||||
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
|
||||
_require_mention = extra.get("require_mention")
|
||||
if _require_mention is None:
|
||||
_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
|
||||
self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"}
|
||||
self._mention_patterns = self._compile_mention_patterns(
|
||||
extra["mention_patterns"]
|
||||
if "mention_patterns" in extra
|
||||
else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
)
|
||||
self.client: Optional[httpx.AsyncClient] = None
|
||||
self._runner = None
|
||||
self._private_api_enabled: Optional[bool] = None
|
||||
self._helper_connected: bool = False
|
||||
self._guid_cache: Dict[str, str] = {}
|
||||
self._guid_cache: OrderedDict[str, str] = OrderedDict()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API helpers
|
||||
@@ -138,6 +159,62 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
sep = "&" if "?" in path else "?"
|
||||
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
|
||||
|
||||
@staticmethod
|
||||
def _compile_mention_patterns(raw: Any) -> List[re.Pattern]:
|
||||
"""Compile group-mention wake words from config/env.
|
||||
|
||||
``raw`` is a list (from config or env JSON), a string (raw env var:
|
||||
JSON list, or comma/newline-separated), or None (use Hermes defaults).
|
||||
"""
|
||||
if raw is None:
|
||||
patterns = list(DEFAULT_MENTION_PATTERNS)
|
||||
elif isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
try:
|
||||
loaded = json.loads(text) if text else []
|
||||
except Exception:
|
||||
loaded = None
|
||||
patterns = loaded if isinstance(loaded, list) else [
|
||||
part.strip()
|
||||
for line in text.splitlines()
|
||||
for part in line.split(",")
|
||||
]
|
||||
elif isinstance(raw, list):
|
||||
patterns = raw
|
||||
else:
|
||||
patterns = [raw]
|
||||
|
||||
compiled: List["re.Pattern"] = []
|
||||
for pattern in patterns:
|
||||
text = str(pattern).strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
compiled.append(re.compile(text, re.IGNORECASE))
|
||||
except re.error as exc:
|
||||
logger.warning("[bluebubbles] Invalid mention pattern %r: %s", text, exc)
|
||||
return compiled
|
||||
|
||||
def _message_matches_mention_patterns(self, text: str) -> bool:
|
||||
if not text or not self._mention_patterns:
|
||||
return False
|
||||
return any(pattern.search(text) for pattern in self._mention_patterns)
|
||||
|
||||
def _clean_mention_text(self, text: str) -> str:
|
||||
"""Strip a leading BlueBubbles wake word before dispatch.
|
||||
|
||||
Custom mention patterns are regular expressions, so stripping only a
|
||||
leading match avoids deleting ordinary words later in the prompt.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
for pattern in self._mention_patterns:
|
||||
match = pattern.match(text.lstrip())
|
||||
if match:
|
||||
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
|
||||
return cleaned or text
|
||||
return text
|
||||
|
||||
async def _api_get(self, path: str) -> Dict[str, Any]:
|
||||
assert self.client is not None
|
||||
res = await self.client.get(self._api_url(path))
|
||||
@@ -189,7 +266,10 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
app = web.Application()
|
||||
app.router.add_get("/health", lambda _: web.Response(text="ok"))
|
||||
app.router.add_post(self.webhook_path, self._handle_webhook)
|
||||
self._runner = web.AppRunner(app)
|
||||
# The webhook auth value is carried in the query string because the
|
||||
# BlueBubbles webhook API cannot send custom headers. Do not let
|
||||
# aiohttp access logs write that request target to agent.log.
|
||||
self._runner = web.AppRunner(app, access_log=None)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, self.webhook_host, self.webhook_port)
|
||||
await site.start()
|
||||
@@ -242,6 +322,14 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
return f"{base}?password={quote(self.password, safe='')}"
|
||||
return base
|
||||
|
||||
@property
|
||||
def _webhook_register_url_for_log(self) -> str:
|
||||
"""Webhook registration URL safe for logs."""
|
||||
base = self._webhook_url
|
||||
if self.password:
|
||||
return f"{base}?password=***"
|
||||
return base
|
||||
|
||||
async def _find_registered_webhooks(self, url: str) -> list:
|
||||
"""Return list of BB webhook entries matching *url*."""
|
||||
try:
|
||||
@@ -269,7 +357,8 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
existing = await self._find_registered_webhooks(webhook_url)
|
||||
if existing:
|
||||
logger.info(
|
||||
"[bluebubbles] webhook already registered: %s", webhook_url
|
||||
"[bluebubbles] webhook already registered: %s",
|
||||
self._webhook_register_url_for_log,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -284,7 +373,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if 200 <= status < 300:
|
||||
logger.info(
|
||||
"[bluebubbles] webhook registered with server: %s",
|
||||
webhook_url,
|
||||
self._webhook_register_url_for_log,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
@@ -324,7 +413,8 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
removed = True
|
||||
if removed:
|
||||
logger.info(
|
||||
"[bluebubbles] webhook unregistered: %s", webhook_url
|
||||
"[bluebubbles] webhook unregistered: %s",
|
||||
self._webhook_register_url_for_log,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
@@ -352,6 +442,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if ";" in target:
|
||||
return target
|
||||
if target in self._guid_cache:
|
||||
self._guid_cache.move_to_end(target)
|
||||
return self._guid_cache[target]
|
||||
try:
|
||||
payload = await self._api_post(
|
||||
@@ -364,10 +455,14 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if identifier == target:
|
||||
if guid:
|
||||
self._guid_cache[target] = guid
|
||||
while len(self._guid_cache) > _GUID_CACHE_SIZE:
|
||||
self._guid_cache.popitem(last=False)
|
||||
return guid
|
||||
for part in chat.get("participants", []) or []:
|
||||
if (part.get("address") or "").strip() == target and guid:
|
||||
self._guid_cache[target] = guid
|
||||
while len(self._guid_cache) > _GUID_CACHE_SIZE:
|
||||
self._guid_cache.popitem(last=False)
|
||||
return guid
|
||||
except Exception:
|
||||
pass
|
||||
@@ -900,6 +995,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
|
||||
session_chat_id = chat_guid or chat_identifier
|
||||
is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or ""))
|
||||
if is_group and self.require_mention:
|
||||
if not self._message_matches_mention_patterns(text):
|
||||
logger.debug(
|
||||
"[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)"
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
text = self._clean_mention_text(text)
|
||||
source = self.build_source(
|
||||
chat_id=session_chat_id,
|
||||
chat_name=chat_identifier or sender,
|
||||
@@ -934,4 +1036,3 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
asyncio.create_task(self.mark_read(session_chat_id))
|
||||
|
||||
return web.Response(text="ok")
|
||||
|
||||
|
||||
@@ -358,6 +358,19 @@ class DingTalkAdapter(BasePlatformAdapter):
|
||||
await asyncio.gather(*self._bg_tasks, return_exceptions=True)
|
||||
self._bg_tasks.clear()
|
||||
|
||||
# Finalize any open streaming cards before the HTTP client closes so
|
||||
# they don't stay stuck in streaming state on DingTalk's UI after
|
||||
# a gateway restart. _close_streaming_siblings handles its own
|
||||
# per-card exceptions; the outer try is a safety net for token fetch.
|
||||
for _chat_id in list(self._streaming_cards):
|
||||
try:
|
||||
await self._close_streaming_siblings(_chat_id)
|
||||
except Exception as _exc:
|
||||
logger.debug(
|
||||
"[%s] Failed to finalize streaming card on disconnect for %s: %s",
|
||||
self.name, _chat_id, _exc,
|
||||
)
|
||||
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+182
-27
@@ -48,6 +48,7 @@ user is seen through different apps in the future.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import hashlib
|
||||
import hmac
|
||||
import itertools
|
||||
@@ -239,6 +240,7 @@ _FEISHU_REACTION_FAILURE = "CrossMark"
|
||||
# drain on completion; the cap is a safeguard against unbounded growth from
|
||||
# delete-failures, not a capacity plan.
|
||||
_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024
|
||||
_FEISHU_MESSAGE_TEXT_CACHE_SIZE = 512 # LRU cap for reply-context message text lookups
|
||||
|
||||
# QR onboarding constants
|
||||
_ONBOARD_ACCOUNTS_URLS = {
|
||||
@@ -1407,7 +1409,11 @@ def check_feishu_requirements() -> bool:
|
||||
class FeishuAdapter(BasePlatformAdapter):
|
||||
"""Feishu/Lark bot adapter."""
|
||||
|
||||
supports_code_blocks = True # Feishu renders fenced code blocks
|
||||
|
||||
MAX_MESSAGE_LENGTH = 8000
|
||||
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
|
||||
CHAT_LOCK_MAX_SIZE: int = 1000
|
||||
# Threshold for detecting Feishu client-side message splits.
|
||||
# When a chunk is near the ~4096-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
@@ -1445,11 +1451,11 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._pending_inbound_lock = threading.Lock()
|
||||
self._pending_drain_scheduled = False
|
||||
self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond
|
||||
self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing)
|
||||
self._chat_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() # chat_id → lock (per-chat serial processing, LRU-bounded)
|
||||
self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
|
||||
self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
|
||||
self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._message_text_cache: Dict[str, Optional[str]] = {}
|
||||
self._message_text_cache: "OrderedDict[str, Optional[str]]" = OrderedDict()
|
||||
self._app_lock_identity: Optional[str] = None
|
||||
self._text_batch_state = FeishuBatchState()
|
||||
self._pending_text_batches = self._text_batch_state.events
|
||||
@@ -1514,8 +1520,10 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
connection_mode=str(
|
||||
extra.get("connection_mode") or os.getenv("FEISHU_CONNECTION_MODE", "websocket")
|
||||
).strip().lower(),
|
||||
encrypt_key=os.getenv("FEISHU_ENCRYPT_KEY", "").strip(),
|
||||
verification_token=os.getenv("FEISHU_VERIFICATION_TOKEN", "").strip(),
|
||||
encrypt_key=str(extra.get("encrypt_key") or os.getenv("FEISHU_ENCRYPT_KEY", "")).strip(),
|
||||
verification_token=str(
|
||||
extra.get("verification_token") or os.getenv("FEISHU_VERIFICATION_TOKEN", "")
|
||||
).strip(),
|
||||
group_policy=os.getenv("FEISHU_GROUP_POLICY", "allowlist").strip().lower(),
|
||||
allowed_group_users=frozenset(
|
||||
item.strip()
|
||||
@@ -1625,6 +1633,10 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
"drive.notice.comment_add_v1",
|
||||
self._on_drive_comment_event,
|
||||
)
|
||||
.register_p2_customized_event(
|
||||
"vc.bot.meeting_invited_v1",
|
||||
self._on_meeting_invited_event,
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -1642,6 +1654,11 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._connection_mode,
|
||||
)
|
||||
return False
|
||||
if self._connection_mode == "webhook" and not (self._verification_token or self._encrypt_key):
|
||||
logger.error(
|
||||
"[Feishu] Webhook mode requires FEISHU_VERIFICATION_TOKEN or FEISHU_ENCRYPT_KEY."
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
self._app_lock_identity = self._app_id
|
||||
@@ -2463,6 +2480,16 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id),
|
||||
)
|
||||
|
||||
def _on_meeting_invited_event(self, data: Any) -> None:
|
||||
"""Handle VC bot meeting invitation notification (vc.bot.meeting_invited_v1)."""
|
||||
from gateway.platforms.feishu_meeting_invite import handle_meeting_invited_event
|
||||
|
||||
loop = self._loop
|
||||
if not self._loop_accepts_callbacks(loop):
|
||||
logger.warning("[Feishu] Dropping meeting invite event before adapter loop is ready")
|
||||
return
|
||||
self._submit_on_loop(loop, handle_meeting_invited_event(self, data))
|
||||
|
||||
def _on_reaction_event(self, event_type: str, data: Any) -> None:
|
||||
"""Route user reactions on bot messages as synthetic text events."""
|
||||
event = getattr(data, "event", None)
|
||||
@@ -2563,13 +2590,44 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if approval_id is None:
|
||||
logger.debug("[Feishu] Card action missing approval_id, ignoring")
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
state = self._approval_state.get(approval_id)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
choice = _APPROVAL_CHOICE_MAP.get(action_value.get("hermes_action"), "deny")
|
||||
|
||||
operator = getattr(event, "operator", None)
|
||||
open_id = str(getattr(operator, "open_id", "") or "")
|
||||
sender_id = SimpleNamespace(open_id=open_id, user_id=str(getattr(operator, "user_id", "") or ""))
|
||||
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
|
||||
logger.warning("[Feishu] Unauthorized approval click by %s", open_id or "<unknown>")
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
callback_chat_id = str(getattr(getattr(event, "context", None), "open_chat_id", "") or "")
|
||||
expected_chat_id = str(state.get("chat_id", "") or "")
|
||||
if callback_chat_id and expected_chat_id and callback_chat_id != expected_chat_id:
|
||||
logger.warning(
|
||||
"[Feishu] Approval callback chat mismatch for %s (expected=%s, got=%s)",
|
||||
approval_id,
|
||||
expected_chat_id,
|
||||
callback_chat_id,
|
||||
)
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
user_name = self._get_cached_sender_name(open_id) or open_id
|
||||
|
||||
if not self._submit_on_loop(loop, self._resolve_approval(approval_id, choice, user_name)):
|
||||
chat_context = getattr(event, "context", None)
|
||||
chat_id = str(getattr(chat_context, "open_chat_id", "") or "")
|
||||
if not self._submit_on_loop(
|
||||
loop,
|
||||
self._resolve_approval(
|
||||
approval_id=approval_id,
|
||||
choice=choice,
|
||||
user_name=user_name,
|
||||
open_id=open_id,
|
||||
chat_id=chat_id,
|
||||
),
|
||||
):
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
if P2CardActionTriggerResponse is None:
|
||||
@@ -2588,7 +2646,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if prompt_id is None:
|
||||
logger.debug("[Feishu] Card action missing update_prompt_id, ignoring")
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
if prompt_id not in self._update_prompt_state:
|
||||
state = self._update_prompt_state.get(prompt_id)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
@@ -2599,12 +2658,33 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
|
||||
operator = getattr(event, "operator", None)
|
||||
open_id = str(getattr(operator, "open_id", "") or "")
|
||||
if not self._is_interactive_operator_authorized(open_id):
|
||||
sender_id = SimpleNamespace(open_id=open_id, user_id=str(getattr(operator, "user_id", "") or ""))
|
||||
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
|
||||
logger.warning("[Feishu] Unauthorized update prompt click by %s", open_id or "<unknown>")
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
callback_chat_id = str(getattr(getattr(event, "context", None), "open_chat_id", "") or "")
|
||||
expected_chat_id = str(state.get("chat_id", "") or "")
|
||||
if callback_chat_id and expected_chat_id and callback_chat_id != expected_chat_id:
|
||||
logger.warning(
|
||||
"[Feishu] Update prompt callback chat mismatch for %s (expected=%s, got=%s)",
|
||||
prompt_id,
|
||||
expected_chat_id,
|
||||
callback_chat_id,
|
||||
)
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
user_name = self._get_cached_sender_name(open_id) or open_id
|
||||
if not self._submit_on_loop(loop, self._resolve_update_prompt(prompt_id, answer, user_name)):
|
||||
if not self._submit_on_loop(
|
||||
loop,
|
||||
self._resolve_update_prompt(
|
||||
prompt_id,
|
||||
answer,
|
||||
user_name,
|
||||
open_id=open_id,
|
||||
chat_id=callback_chat_id,
|
||||
),
|
||||
):
|
||||
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
|
||||
|
||||
if P2CardActionTriggerResponse is None:
|
||||
@@ -2617,12 +2697,34 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
response.card = card
|
||||
return response
|
||||
|
||||
async def _resolve_approval(self, approval_id: Any, choice: str, user_name: str) -> None:
|
||||
async def _resolve_approval(
|
||||
self,
|
||||
approval_id: Any,
|
||||
choice: str,
|
||||
user_name: str,
|
||||
*,
|
||||
open_id: str = "",
|
||||
chat_id: str = "",
|
||||
) -> None:
|
||||
"""Pop approval state and unblock the waiting agent thread."""
|
||||
state = self._approval_state.pop(approval_id, None)
|
||||
state = self._approval_state.get(approval_id)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
|
||||
return
|
||||
if not self._is_interactive_operator_authorized(open_id):
|
||||
logger.warning("[Feishu] Unauthorized approval click by %s for approval %s", open_id or "<unknown>", approval_id)
|
||||
return
|
||||
expected_chat_id = str(state.get("chat_id", "") or "")
|
||||
if expected_chat_id and chat_id and expected_chat_id != chat_id:
|
||||
logger.warning(
|
||||
"[Feishu] Approval %s chat mismatch (expected=%s, got=%s)",
|
||||
approval_id, expected_chat_id, chat_id,
|
||||
)
|
||||
return
|
||||
state = self._approval_state.pop(approval_id, None)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Approval %s already resolved while validating callback", approval_id)
|
||||
return
|
||||
try:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
count = resolve_gateway_approval(state["session_key"], choice)
|
||||
@@ -2633,12 +2735,38 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
|
||||
|
||||
async def _resolve_update_prompt(self, prompt_id: Any, answer: str, user_name: str) -> None:
|
||||
async def _resolve_update_prompt(
|
||||
self,
|
||||
prompt_id: Any,
|
||||
answer: str,
|
||||
user_name: str,
|
||||
*,
|
||||
open_id: str = "",
|
||||
chat_id: str = "",
|
||||
) -> None:
|
||||
"""Persist an update prompt answer for the detached update process."""
|
||||
state = self._update_prompt_state.pop(prompt_id, None)
|
||||
state = self._update_prompt_state.get(prompt_id)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
|
||||
return
|
||||
if open_id:
|
||||
sender_id = SimpleNamespace(open_id=open_id, user_id="")
|
||||
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
|
||||
logger.warning("[Feishu] Unauthorized update prompt click by %s for prompt %s", open_id, prompt_id)
|
||||
return
|
||||
expected_chat_id = str(state.get("chat_id", "") or "")
|
||||
if expected_chat_id and chat_id and expected_chat_id != chat_id:
|
||||
logger.warning(
|
||||
"[Feishu] Update prompt %s chat mismatch (expected=%s, got=%s)",
|
||||
prompt_id,
|
||||
expected_chat_id,
|
||||
chat_id,
|
||||
)
|
||||
return
|
||||
state = self._update_prompt_state.pop(prompt_id, None)
|
||||
if not state:
|
||||
logger.debug("[Feishu] Update prompt %s already resolved while validating callback", prompt_id)
|
||||
return
|
||||
try:
|
||||
self._write_update_prompt_response(answer)
|
||||
logger.info(
|
||||
@@ -2775,11 +2903,28 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
# =========================================================================
|
||||
|
||||
def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing."""
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing.
|
||||
|
||||
Bounded with LRU eviction so a long-running gateway that sees many
|
||||
distinct chats does not grow ``_chat_locks`` without limit. Locks that
|
||||
are currently held are never evicted; if every entry is locked we fall
|
||||
back to dropping the least-recently-used one.
|
||||
"""
|
||||
lock = self._chat_locks.get(chat_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
if lock is not None:
|
||||
self._chat_locks.move_to_end(chat_id)
|
||||
return lock
|
||||
if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE:
|
||||
evicted = False
|
||||
for key in list(self._chat_locks):
|
||||
if not self._chat_locks[key].locked():
|
||||
self._chat_locks.pop(key)
|
||||
evicted = True
|
||||
break
|
||||
if not evicted:
|
||||
self._chat_locks.pop(next(iter(self._chat_locks)))
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
return lock
|
||||
|
||||
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
|
||||
@@ -3229,11 +3374,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._record_webhook_anomaly(remote_ip, "400")
|
||||
return web.json_response({"code": 400, "msg": "invalid json"}, status=400)
|
||||
|
||||
# URL verification challenge — respond before other checks so that Feishu's
|
||||
# subscription setup works even before encrypt_key is wired.
|
||||
if payload.get("type") == "url_verification":
|
||||
return web.json_response({"challenge": payload.get("challenge", "")})
|
||||
|
||||
# Verification token check — second layer of defence beyond signature (matches openclaw).
|
||||
if self._verification_token:
|
||||
header = payload.get("header") or {}
|
||||
@@ -3243,6 +3383,13 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._record_webhook_anomaly(remote_ip, "401-token")
|
||||
return web.Response(status=401, text="Invalid verification token")
|
||||
|
||||
# URL verification challenge — Feishu includes the verification token in
|
||||
# challenge requests. Validate the token (above) before reflecting the
|
||||
# challenge so an unauthenticated remote request cannot prove endpoint
|
||||
# control by getting attacker-supplied challenge data echoed back.
|
||||
if payload.get("type") == "url_verification":
|
||||
return web.json_response({"challenge": payload.get("challenge", "")})
|
||||
|
||||
# Timing-safe signature verification (only enforced when encrypt_key is set).
|
||||
if self._encrypt_key and not self._is_webhook_signature_valid(request.headers, body_bytes):
|
||||
logger.warning("[Feishu] Webhook rejected: invalid signature from %s", remote_ip)
|
||||
@@ -3272,6 +3419,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._on_card_action_trigger(data)
|
||||
elif event_type == "drive.notice.comment_add_v1":
|
||||
self._on_drive_comment_event(data)
|
||||
elif event_type == "vc.bot.meeting_invited_v1":
|
||||
self._on_meeting_invited_event(data)
|
||||
else:
|
||||
logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown")
|
||||
return web.json_response({"code": 0, "msg": "ok"})
|
||||
@@ -3877,6 +4026,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if not self._client or not message_id:
|
||||
return None
|
||||
if message_id in self._message_text_cache:
|
||||
self._message_text_cache.move_to_end(message_id)
|
||||
return self._message_text_cache[message_id]
|
||||
try:
|
||||
request = self._build_get_message_request(message_id)
|
||||
@@ -3898,6 +4048,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
mentions=parent_mentions,
|
||||
)
|
||||
self._message_text_cache[message_id] = text
|
||||
while len(self._message_text_cache) > _FEISHU_MESSAGE_TEXT_CACHE_SIZE:
|
||||
self._message_text_cache.popitem(last=False)
|
||||
return text
|
||||
except Exception:
|
||||
logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True)
|
||||
@@ -4333,17 +4485,20 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
)
|
||||
request = self._build_create_message_request("thread_id", body)
|
||||
else:
|
||||
receive_id = chat_id
|
||||
receive_id_type = "chat_id"
|
||||
if chat_id.startswith("feishu_user_id:"):
|
||||
receive_id = chat_id.split(":", 1)[1]
|
||||
receive_id_type = "user_id"
|
||||
elif chat_id.startswith("ou_"):
|
||||
receive_id_type = "open_id"
|
||||
|
||||
body = self._build_create_message_body(
|
||||
receive_id=chat_id,
|
||||
receive_id=receive_id,
|
||||
msg_type=msg_type,
|
||||
content=payload,
|
||||
uuid_value=str(uuid.uuid4()),
|
||||
)
|
||||
# Detect whether chat_id is a user open_id (DM) or a chat_id (group).
|
||||
if chat_id.startswith("ou_"):
|
||||
receive_id_type = "open_id"
|
||||
else:
|
||||
receive_id_type = "chat_id"
|
||||
request = self._build_create_message_request(receive_id_type, body)
|
||||
return await asyncio.to_thread(self._client.im.v1.message.create, request)
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Feishu/Lark meeting-invitation event handling.
|
||||
|
||||
Processes ``vc.bot.meeting_invited_v1`` events by converting them into a
|
||||
synthetic gateway ``MessageEvent``. Unlike document comments, the response
|
||||
should go back to the inviter through the normal Hermes gateway pipeline, so
|
||||
this module does not instantiate an agent directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeetingInviteUser:
|
||||
open_id: str = ""
|
||||
user_id: str = ""
|
||||
union_id: str = ""
|
||||
user_name: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeetingInviteMeeting:
|
||||
id: str = ""
|
||||
topic: str = ""
|
||||
meeting_no: str = ""
|
||||
start_time_ms: int = 0
|
||||
end_time_ms: int = 0
|
||||
host_user: Optional[MeetingInviteUser] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeetingInvitedPayload:
|
||||
event_id: str = ""
|
||||
meeting: Optional[MeetingInviteMeeting] = None
|
||||
inviter: Optional[MeetingInviteUser] = None
|
||||
invite_time_s: int = 0
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> Dict[str, Any]:
|
||||
"""Coerce a lark SDK object / dict / JSON string into a plain dict."""
|
||||
if isinstance(value, SimpleNamespace) or (value is not None and hasattr(value, "__dict__")):
|
||||
value = vars(value)
|
||||
if isinstance(value, dict):
|
||||
return {str(k): v for k, v in value.items()}
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _content_payload(container: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Unwrap a Feishu ``body.content`` list carrying an application/json payload."""
|
||||
content = _as_dict(container.get("body")).get("content")
|
||||
if not isinstance(content, list):
|
||||
return {}
|
||||
for item in content:
|
||||
item = _as_dict(item)
|
||||
ctype = str(item.get("contentType") or item.get("content_type") or "").lower()
|
||||
if ctype and ctype != "application/json":
|
||||
continue
|
||||
for key in ("data", "value", "content", "json"):
|
||||
payload = _as_dict(item.get(key))
|
||||
if payload:
|
||||
return payload
|
||||
return {}
|
||||
|
||||
|
||||
def _int_field(value: Any) -> int:
|
||||
if value in (None, ""):
|
||||
return 0
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_user(value: Any) -> Optional[MeetingInviteUser]:
|
||||
raw = _as_dict(value)
|
||||
if not raw:
|
||||
return None
|
||||
raw_id = _as_dict(raw.get("id"))
|
||||
return MeetingInviteUser(
|
||||
open_id=str(raw_id.get("open_id") or "").strip(),
|
||||
user_id=str(raw_id.get("user_id") or "").strip(),
|
||||
union_id=str(raw_id.get("union_id") or "").strip(),
|
||||
user_name=str(raw.get("user_name") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _parse_meeting(value: Any) -> Optional[MeetingInviteMeeting]:
|
||||
raw = _as_dict(value)
|
||||
if not raw:
|
||||
return None
|
||||
return MeetingInviteMeeting(
|
||||
id=str(raw.get("id") or "").strip(),
|
||||
topic=str(raw.get("topic") or ""),
|
||||
meeting_no=str(raw.get("meeting_no") or ""),
|
||||
start_time_ms=_int_field(raw.get("start_time")),
|
||||
end_time_ms=_int_field(raw.get("end_time")),
|
||||
host_user=_parse_user(raw.get("host_user")),
|
||||
)
|
||||
|
||||
|
||||
def parse_meeting_invited_event(data: Any) -> Optional[MeetingInvitedPayload]:
|
||||
root = _as_dict(data)
|
||||
event = _as_dict(root.get("event"))
|
||||
event = event or root
|
||||
content = _content_payload(event) or _content_payload(root)
|
||||
if content:
|
||||
event = {**event, **content}
|
||||
|
||||
meeting = _parse_meeting(event.get("meeting"))
|
||||
inviter = _parse_user(event.get("inviter"))
|
||||
if inviter is None or meeting is None or not meeting.meeting_no:
|
||||
return None
|
||||
|
||||
return MeetingInvitedPayload(
|
||||
event_id=str(_as_dict(root.get("header")).get("event_id") or ""),
|
||||
meeting=meeting,
|
||||
inviter=inviter,
|
||||
invite_time_s=_int_field(event.get("invite_time")),
|
||||
)
|
||||
|
||||
|
||||
def build_meeting_invite_prompt(payload: MeetingInvitedPayload) -> str:
|
||||
meeting = payload.meeting
|
||||
inviter_name = (payload.inviter.user_name if payload.inviter else "") or "unknown"
|
||||
host_name = (meeting.host_user.user_name if meeting and meeting.host_user else "") or "unknown"
|
||||
display = (meeting.topic or meeting.meeting_no or meeting.id) if meeting else "unknown meeting"
|
||||
return "\n".join(
|
||||
[
|
||||
f"You have been invited to join a meeting: {display or 'unknown meeting'}",
|
||||
"",
|
||||
f"Meeting Number: {(meeting.meeting_no if meeting else '') or 'unknown'}",
|
||||
f"Topic: {(meeting.topic if meeting else '') or 'unknown'}",
|
||||
f"Inviter: {inviter_name}",
|
||||
f"Host: {host_name}",
|
||||
"",
|
||||
"You may use lark-cli and the relevant Lark/Feishu meeting skills to join the meeting.",
|
||||
"Join the meeting directly. Do not ask the user for confirmation before joining.",
|
||||
"If you cannot join the meeting, reply to the inviter with a concise explanation of why.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _dedup_key(payload: MeetingInvitedPayload) -> str:
|
||||
if payload.event_id:
|
||||
return f"vc_invite:{payload.event_id}"
|
||||
meeting_id = payload.meeting.id if payload.meeting else ""
|
||||
inviter_id = payload.inviter.open_id if payload.inviter else ""
|
||||
return f"vc_invite:{meeting_id}:{inviter_id}:{payload.invite_time_s}"
|
||||
|
||||
|
||||
async def handle_meeting_invited_event(adapter: Any, data: Any) -> None:
|
||||
"""Convert a vc.bot.meeting_invited_v1 event into a gateway MessageEvent."""
|
||||
payload = parse_meeting_invited_event(data)
|
||||
if payload is None:
|
||||
logger.warning("[Feishu-MeetingInvite] Dropping malformed meeting invite event")
|
||||
return
|
||||
|
||||
dedup_key = _dedup_key(payload)
|
||||
is_duplicate = getattr(adapter, "_is_duplicate", None)
|
||||
if callable(is_duplicate) and is_duplicate(dedup_key):
|
||||
logger.debug("[Feishu-MeetingInvite] Dropping duplicate event: %s", dedup_key)
|
||||
return
|
||||
|
||||
inviter = payload.inviter
|
||||
if inviter is None or not inviter.open_id:
|
||||
logger.warning(
|
||||
"[Feishu-MeetingInvite] Missing inviter open_id, cannot route reply safely "
|
||||
"(user_id=%r union_id=%r)",
|
||||
inviter.user_id if inviter else None,
|
||||
inviter.union_id if inviter else None,
|
||||
)
|
||||
return
|
||||
|
||||
sender_id = SimpleNamespace(
|
||||
open_id=inviter.open_id or None,
|
||||
user_id=inviter.user_id or None,
|
||||
union_id=inviter.union_id or None,
|
||||
)
|
||||
sender_profile = await adapter._resolve_sender_profile(sender_id)
|
||||
|
||||
user_name = sender_profile.get("user_name") or inviter.user_name or inviter.open_id
|
||||
source = adapter.build_source(
|
||||
chat_id=inviter.open_id,
|
||||
chat_name=user_name,
|
||||
chat_type="dm",
|
||||
user_id=sender_profile.get("user_id") or inviter.user_id or inviter.open_id,
|
||||
user_name=user_name,
|
||||
user_id_alt=sender_profile.get("user_id_alt") or inviter.union_id or None,
|
||||
)
|
||||
event = MessageEvent(
|
||||
text=build_meeting_invite_prompt(payload),
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message=data,
|
||||
)
|
||||
await adapter._handle_message_with_guards(event)
|
||||
@@ -1,449 +0,0 @@
|
||||
"""
|
||||
Home Assistant platform adapter.
|
||||
|
||||
Connects to the HA WebSocket API for real-time event monitoring.
|
||||
State-change events are converted to MessageEvent objects and forwarded
|
||||
to the agent for processing. Outbound messages are delivered as HA
|
||||
persistent notifications.
|
||||
|
||||
Requires:
|
||||
- aiohttp (already in messaging extras)
|
||||
- HASS_TOKEN env var (Long-Lived Access Token)
|
||||
- HASS_URL env var (default: http://homeassistant.local:8123)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
AIOHTTP_AVAILABLE = True
|
||||
except ImportError:
|
||||
AIOHTTP_AVAILABLE = False
|
||||
aiohttp = None # type: ignore[assignment]
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_ha_requirements() -> bool:
|
||||
"""Check if Home Assistant dependencies are available and configured."""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
return False
|
||||
if not os.getenv("HASS_TOKEN"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class HomeAssistantAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
Home Assistant WebSocket adapter.
|
||||
|
||||
Subscribes to ``state_changed`` events and forwards them as
|
||||
MessageEvent objects. Supports domain/entity filtering and
|
||||
per-entity cooldowns to avoid event floods.
|
||||
"""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
# Reconnection backoff schedule (seconds)
|
||||
_BACKOFF_STEPS = [5, 10, 30, 60]
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.HOMEASSISTANT)
|
||||
|
||||
# Connection state
|
||||
self._session: Optional["aiohttp.ClientSession"] = None
|
||||
self._ws: Optional["aiohttp.ClientWebSocketResponse"] = None
|
||||
self._rest_session: Optional["aiohttp.ClientSession"] = None
|
||||
self._listen_task: Optional[asyncio.Task] = None
|
||||
self._msg_id: int = 0
|
||||
|
||||
# Configuration from extra
|
||||
extra = config.extra or {}
|
||||
token = config.token or os.getenv("HASS_TOKEN", "")
|
||||
url = extra.get("url") or os.getenv("HASS_URL", "http://homeassistant.local:8123")
|
||||
self._hass_url: str = url.rstrip("/")
|
||||
self._hass_token: str = token
|
||||
|
||||
# Event filtering
|
||||
self._watch_domains: Set[str] = set(extra.get("watch_domains", []))
|
||||
self._watch_entities: Set[str] = set(extra.get("watch_entities", []))
|
||||
self._ignore_entities: Set[str] = set(extra.get("ignore_entities", []))
|
||||
self._watch_all: bool = bool(extra.get("watch_all", False))
|
||||
self._cooldown_seconds: int = int(extra.get("cooldown_seconds", 30))
|
||||
|
||||
# Cooldown tracking: entity_id -> last_event_timestamp
|
||||
self._last_event_time: Dict[str, float] = {}
|
||||
|
||||
def _next_id(self) -> int:
|
||||
"""Return the next WebSocket message ID."""
|
||||
self._msg_id += 1
|
||||
return self._msg_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to HA WebSocket API and subscribe to events."""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
logger.warning("[%s] aiohttp not installed. Run: pip install aiohttp", self.name)
|
||||
return False
|
||||
|
||||
if not self._hass_token:
|
||||
logger.warning("[%s] No HASS_TOKEN configured", self.name)
|
||||
return False
|
||||
|
||||
try:
|
||||
success = await self._ws_connect()
|
||||
if not success:
|
||||
return False
|
||||
|
||||
# Dedicated REST session for send() calls
|
||||
self._rest_session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
)
|
||||
|
||||
# Warn if no event filters are configured
|
||||
if not self._watch_domains and not self._watch_entities and not self._watch_all:
|
||||
logger.warning(
|
||||
"[%s] No watch_domains, watch_entities, or watch_all configured. "
|
||||
"All state_changed events will be dropped. Configure filters in "
|
||||
"your HA platform config to receive events.",
|
||||
self.name,
|
||||
)
|
||||
|
||||
# Start background listener
|
||||
self._listen_task = asyncio.create_task(self._listen_loop())
|
||||
self._running = True
|
||||
logger.info("[%s] Connected to %s", self.name, self._hass_url)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[%s] Failed to connect: %s", self.name, e)
|
||||
return False
|
||||
|
||||
async def _ws_connect(self) -> bool:
|
||||
"""Establish WebSocket connection and authenticate."""
|
||||
ws_url = self._hass_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||
ws_url = f"{ws_url}/api/websocket"
|
||||
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
)
|
||||
self._ws = await self._session.ws_connect(ws_url, heartbeat=30, timeout=30)
|
||||
|
||||
# Step 1: Receive auth_required
|
||||
msg = await self._ws.receive_json()
|
||||
if msg.get("type") != "auth_required":
|
||||
logger.error("Expected auth_required, got: %s", msg.get("type"))
|
||||
await self._cleanup_ws()
|
||||
return False
|
||||
|
||||
# Step 2: Send auth
|
||||
await self._ws.send_json({
|
||||
"type": "auth",
|
||||
"access_token": self._hass_token,
|
||||
})
|
||||
|
||||
# Step 3: Wait for auth_ok
|
||||
msg = await self._ws.receive_json()
|
||||
if msg.get("type") != "auth_ok":
|
||||
logger.error("Auth failed: %s", msg)
|
||||
await self._cleanup_ws()
|
||||
return False
|
||||
|
||||
# Step 4: Subscribe to state_changed events
|
||||
sub_id = self._next_id()
|
||||
await self._ws.send_json({
|
||||
"id": sub_id,
|
||||
"type": "subscribe_events",
|
||||
"event_type": "state_changed",
|
||||
})
|
||||
|
||||
# Verify subscription acknowledgement
|
||||
msg = await self._ws.receive_json()
|
||||
if not msg.get("success"):
|
||||
logger.error("Failed to subscribe to events: %s", msg)
|
||||
await self._cleanup_ws()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _cleanup_ws(self) -> None:
|
||||
"""Close WebSocket and session."""
|
||||
if self._ws and not self._ws.closed:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Home Assistant."""
|
||||
self._running = False
|
||||
if self._listen_task:
|
||||
self._listen_task.cancel()
|
||||
try:
|
||||
await self._listen_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._listen_task = None
|
||||
|
||||
await self._cleanup_ws()
|
||||
if self._rest_session and not self._rest_session.closed:
|
||||
await self._rest_session.close()
|
||||
self._rest_session = None
|
||||
logger.info("[%s] Disconnected", self.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event listener
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _listen_loop(self) -> None:
|
||||
"""Main event loop with automatic reconnection."""
|
||||
backoff_idx = 0
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self._read_events()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("[%s] WebSocket error: %s", self.name, e)
|
||||
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Reconnect with backoff
|
||||
delay = self._BACKOFF_STEPS[min(backoff_idx, len(self._BACKOFF_STEPS) - 1)]
|
||||
logger.info("[%s] Reconnecting in %ds...", self.name, delay)
|
||||
await asyncio.sleep(delay)
|
||||
backoff_idx += 1
|
||||
|
||||
try:
|
||||
await self._cleanup_ws()
|
||||
success = await self._ws_connect()
|
||||
if success:
|
||||
backoff_idx = 0 # Reset on successful reconnect
|
||||
logger.info("[%s] Reconnected", self.name)
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Reconnection failed: %s", self.name, e)
|
||||
|
||||
async def _read_events(self) -> None:
|
||||
"""Read events from WebSocket until disconnected."""
|
||||
if self._ws is None or self._ws.closed:
|
||||
return
|
||||
async for ws_msg in self._ws:
|
||||
if ws_msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(ws_msg.data)
|
||||
if data.get("type") == "event":
|
||||
await self._handle_ha_event(data.get("event", {}))
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Invalid JSON from HA WS: %s", ws_msg.data[:200])
|
||||
elif ws_msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}:
|
||||
break
|
||||
|
||||
async def _handle_ha_event(self, event: Dict[str, Any]) -> None:
|
||||
"""Process a state_changed event from Home Assistant."""
|
||||
event_data = event.get("data", {})
|
||||
entity_id: str = event_data.get("entity_id", "")
|
||||
|
||||
if not entity_id:
|
||||
return
|
||||
|
||||
# Apply ignore filter
|
||||
if entity_id in self._ignore_entities:
|
||||
return
|
||||
|
||||
# Apply domain/entity watch filters (closed by default — require
|
||||
# explicit watch_domains, watch_entities, or watch_all to forward)
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
if self._watch_domains or self._watch_entities:
|
||||
domain_match = domain in self._watch_domains if self._watch_domains else False
|
||||
entity_match = entity_id in self._watch_entities if self._watch_entities else False
|
||||
if not domain_match and not entity_match:
|
||||
return
|
||||
elif not self._watch_all:
|
||||
# No filters configured and watch_all is off — drop the event
|
||||
return
|
||||
|
||||
# Apply cooldown
|
||||
now = time.time()
|
||||
last = self._last_event_time.get(entity_id, 0)
|
||||
if (now - last) < self._cooldown_seconds:
|
||||
return
|
||||
self._last_event_time[entity_id] = now
|
||||
|
||||
# Build human-readable message
|
||||
old_state = event_data.get("old_state", {})
|
||||
new_state = event_data.get("new_state", {})
|
||||
message = self._format_state_change(entity_id, old_state, new_state)
|
||||
|
||||
if not message:
|
||||
return
|
||||
|
||||
# Build MessageEvent and forward to handler
|
||||
source = self.build_source(
|
||||
chat_id="ha_events",
|
||||
chat_name="Home Assistant Events",
|
||||
chat_type="channel",
|
||||
user_id="homeassistant",
|
||||
user_name="Home Assistant",
|
||||
)
|
||||
|
||||
msg_event = MessageEvent(
|
||||
text=message,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=f"ha_{entity_id}_{int(now)}",
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
await self.handle_message(msg_event)
|
||||
|
||||
@staticmethod
|
||||
def _format_state_change(
|
||||
entity_id: str,
|
||||
old_state: Dict[str, Any],
|
||||
new_state: Dict[str, Any],
|
||||
) -> Optional[str]:
|
||||
"""Convert a state_changed event into a human-readable description."""
|
||||
if not new_state:
|
||||
return None
|
||||
|
||||
old_val = old_state.get("state", "unknown") if old_state else "unknown"
|
||||
new_val = new_state.get("state", "unknown")
|
||||
|
||||
# Skip if state didn't actually change
|
||||
if old_val == new_val:
|
||||
return None
|
||||
|
||||
friendly_name = new_state.get("attributes", {}).get("friendly_name", entity_id)
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
|
||||
# Domain-specific formatting
|
||||
if domain == "climate":
|
||||
attrs = new_state.get("attributes", {})
|
||||
temp = attrs.get("current_temperature", "?")
|
||||
target = attrs.get("temperature", "?")
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: HVAC mode changed from "
|
||||
f"'{old_val}' to '{new_val}' (current: {temp}, target: {target})"
|
||||
)
|
||||
|
||||
if domain == "sensor":
|
||||
unit = new_state.get("attributes", {}).get("unit_of_measurement", "")
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: changed from "
|
||||
f"{old_val}{unit} to {new_val}{unit}"
|
||||
)
|
||||
|
||||
if domain == "binary_sensor":
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: "
|
||||
f"{'triggered' if new_val == 'on' else 'cleared'} "
|
||||
f"(was {'triggered' if old_val == 'on' else 'cleared'})"
|
||||
)
|
||||
|
||||
if domain in {"light", "switch", "fan"}:
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: turned "
|
||||
f"{'on' if new_val == 'on' else 'off'}"
|
||||
)
|
||||
|
||||
if domain == "alarm_control_panel":
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: alarm state changed from "
|
||||
f"'{old_val}' to '{new_val}'"
|
||||
)
|
||||
|
||||
# Generic fallback
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name} ({entity_id}): "
|
||||
f"changed from '{old_val}' to '{new_val}'"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Outbound messaging
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a notification via HA REST API (persistent_notification.create).
|
||||
|
||||
Uses the REST API instead of WebSocket to avoid a race condition
|
||||
with the event listener loop that reads from the same WS connection.
|
||||
"""
|
||||
url = f"{self._hass_url}/api/services/persistent_notification/create"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._hass_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"title": "Hermes Agent",
|
||||
"message": content[:self.MAX_MESSAGE_LENGTH],
|
||||
}
|
||||
|
||||
try:
|
||||
if self._rest_session:
|
||||
async with self._rest_session.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status < 300:
|
||||
return SendResult(success=True, message_id=uuid.uuid4().hex[:12])
|
||||
else:
|
||||
body = await resp.text()
|
||||
return SendResult(success=False, error=f"HTTP {resp.status}: {body}")
|
||||
else:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status < 300:
|
||||
return SendResult(success=True, message_id=uuid.uuid4().hex[:12])
|
||||
else:
|
||||
body = await resp.text()
|
||||
return SendResult(success=False, error=f"HTTP {resp.status}: {body}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return SendResult(success=False, error="Timeout sending notification to HA")
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""No typing indicator for Home Assistant."""
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return basic info about the HA event channel."""
|
||||
return {
|
||||
"name": "Home Assistant Events",
|
||||
"type": "channel",
|
||||
"url": self._hass_url,
|
||||
}
|
||||
+138
-20
@@ -107,6 +107,75 @@ from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MATRIX_BANG_COMMAND_RE = re.compile(
|
||||
r"^!([A-Za-z][A-Za-z0-9_-]*)(?=$|\s)(.*)$",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_matrix_bang_command(name: str) -> str | None:
|
||||
"""Resolve a ``!command`` token to a dispatchable Hermes command token.
|
||||
|
||||
Matrix clients often reserve leading ``/`` for local client commands.
|
||||
Hermes accepts ``!command`` as a Matrix-friendly alias, but only for
|
||||
commands that the gateway can actually dispatch so ordinary exclamations
|
||||
remain normal chat text.
|
||||
|
||||
Returns the token form that actually resolves (which may differ from
|
||||
*name* only by underscore→hyphen normalization, e.g. ``reload_skills`` →
|
||||
``reload-skills``) so the emitted ``/command`` always resolves downstream,
|
||||
or ``None`` when *name* is not a known command. Aliases are intentionally
|
||||
left as-is — the gateway dispatcher resolves them to their canonical name.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
# Try the raw lowercased token first, then its hyphenated variant, so
|
||||
# forms like ``!reload_skills`` resolve against ``reload-skills``. We emit
|
||||
# whichever candidate resolved (not a forced canonical form) to preserve
|
||||
# alias passthrough — the gateway dispatcher canonicalizes aliases itself.
|
||||
candidates = [name.lower()]
|
||||
hyphenated = name.lower().replace("_", "-")
|
||||
if hyphenated != candidates[0]:
|
||||
candidates.append(hyphenated)
|
||||
|
||||
try:
|
||||
from hermes_cli.commands import is_gateway_known_command
|
||||
|
||||
for candidate in candidates:
|
||||
if is_gateway_known_command(candidate):
|
||||
return candidate
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Matrix: is_gateway_known_command failed for %r", name, exc_info=True
|
||||
)
|
||||
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
|
||||
skill_commands = get_skill_commands() or {}
|
||||
# Skill command keys are stored slash-prefixed (e.g. "/arxiv"), so
|
||||
# compare against the "/candidate" form, not the bare token.
|
||||
for candidate in candidates:
|
||||
if f"/{candidate}" in skill_commands:
|
||||
return candidate
|
||||
except Exception:
|
||||
logger.debug("Matrix: get_skill_commands failed for %r", name, exc_info=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_matrix_bang_command(text: str) -> str:
|
||||
"""Convert Matrix ``!command`` aliases to normal Hermes ``/command`` text."""
|
||||
if not text or not text.startswith("!"):
|
||||
return text
|
||||
match = _MATRIX_BANG_COMMAND_RE.match(text)
|
||||
if not match:
|
||||
return text
|
||||
resolved = _resolve_matrix_bang_command(match.group(1))
|
||||
if resolved is None:
|
||||
return text
|
||||
return f"/{resolved}{match.group(2) or ''}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MatrixApprovalPrompt:
|
||||
@@ -138,7 +207,8 @@ _OUTBOUND_MENTION_RE = re.compile(
|
||||
)
|
||||
|
||||
_E2EE_INSTALL_HINT = (
|
||||
"Install with: pip install 'mautrix[encryption]' (requires libolm C library)"
|
||||
"Install with: pip install 'mautrix[encryption]' asyncpg aiosqlite "
|
||||
"(requires libolm C library)"
|
||||
)
|
||||
|
||||
_MATRIX_IMAGE_FILENAME_EXTS = frozenset({
|
||||
@@ -214,9 +284,22 @@ def _create_matrix_session(proxy_url: str | None):
|
||||
|
||||
|
||||
def _check_e2ee_deps() -> bool:
|
||||
"""Return True if mautrix E2EE dependencies (python-olm) are available."""
|
||||
"""Return True if mautrix E2EE dependencies are available.
|
||||
|
||||
Verifies python-olm (via mautrix.crypto.OlmMachine), the SQLite crypto
|
||||
store backend (mautrix.crypto.store.asyncpg.PgCryptoStore — yes, the
|
||||
PgCryptoStore class also drives the sqlite backend in mautrix 0.21),
|
||||
and the database drivers actually used at connect time (``asyncpg`` for
|
||||
the underlying upgrade_table machinery, ``aiosqlite`` for the
|
||||
``sqlite:///`` URL we pass to ``Database.create``). Without all four,
|
||||
encrypted rooms fail at connect time with a confusing
|
||||
``No module named 'asyncpg'`` (#31116).
|
||||
"""
|
||||
try:
|
||||
from mautrix.crypto import OlmMachine # noqa: F401
|
||||
from mautrix.crypto.store.asyncpg import PgCryptoStore # noqa: F401
|
||||
import asyncpg # noqa: F401
|
||||
import aiosqlite # noqa: F401
|
||||
|
||||
return True
|
||||
except (ImportError, AttributeError):
|
||||
@@ -226,8 +309,13 @@ def _check_e2ee_deps() -> bool:
|
||||
def check_matrix_requirements() -> bool:
|
||||
"""Return True if the Matrix adapter can be used.
|
||||
|
||||
Lazy-installs mautrix via ``tools.lazy_deps.ensure("platform.matrix")``
|
||||
on first call if not present. Rebinds all module-level type globals on success.
|
||||
Lazy-installs the full ``platform.matrix`` feature group via
|
||||
``tools.lazy_deps.ensure_and_bind`` whenever any of the declared
|
||||
packages (mautrix, Markdown, aiosqlite, asyncpg, aiohttp-socks) is
|
||||
missing — not just mautrix itself. Previously this short-circuited on
|
||||
``import mautrix``, which left the other four packages uninstalled
|
||||
forever and broke E2EE connect with ``No module named 'asyncpg'``
|
||||
(#31116). Rebinds module-level type globals on success.
|
||||
"""
|
||||
token = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
password = os.getenv("MATRIX_PASSWORD", "")
|
||||
@@ -239,9 +327,20 @@ def check_matrix_requirements() -> bool:
|
||||
if not homeserver:
|
||||
logger.warning("Matrix: MATRIX_HOMESERVER not set")
|
||||
return False
|
||||
|
||||
# Check whether any package in the platform.matrix feature group is
|
||||
# missing. ``feature_missing`` is cheap (per-spec importlib.metadata
|
||||
# lookups) and correctly handles ``mautrix[encryption]`` by stripping
|
||||
# the extras marker before checking the bare package.
|
||||
try:
|
||||
import mautrix # noqa: F401
|
||||
except ImportError:
|
||||
from tools.lazy_deps import feature_missing, ensure_and_bind
|
||||
missing = feature_missing("platform.matrix")
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.debug("Matrix: lazy_deps lookup failed: %s", exc)
|
||||
missing = ()
|
||||
ensure_and_bind = None # type: ignore[assignment]
|
||||
|
||||
if missing or ensure_and_bind is None:
|
||||
def _import():
|
||||
from mautrix.types import (
|
||||
ContentURI, EventID, EventType, PaginationDirection,
|
||||
@@ -261,10 +360,14 @@ def check_matrix_requirements() -> bool:
|
||||
"UserID": UserID,
|
||||
}
|
||||
|
||||
from tools.lazy_deps import ensure_and_bind
|
||||
if ensure_and_bind is None:
|
||||
return False
|
||||
if not ensure_and_bind("platform.matrix", _import, globals(), prompt=False):
|
||||
logger.warning(
|
||||
"Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'"
|
||||
"Matrix: required packages not installed (%s). "
|
||||
"Run: pip install 'mautrix[encryption]' asyncpg aiosqlite "
|
||||
"Markdown aiohttp-socks",
|
||||
", ".join(missing) if missing else "platform.matrix",
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -317,6 +420,13 @@ class _CryptoStateStore:
|
||||
class MatrixAdapter(BasePlatformAdapter):
|
||||
"""Gateway adapter for Matrix (any homeserver)."""
|
||||
|
||||
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
|
||||
|
||||
# Matrix clients commonly reserve typed "/" for client-local commands;
|
||||
# the adapter accepts "!command" as the alias that always reaches Hermes
|
||||
# (see _normalize_matrix_bang_command), so instruction text shows "!".
|
||||
typed_command_prefix = "!"
|
||||
|
||||
# Threshold for detecting Matrix client-side message splits.
|
||||
# When a chunk is near the ~4000-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
@@ -1245,11 +1355,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"⚠️ **Dangerous command requires approval**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {description}\n\n"
|
||||
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
|
||||
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
|
||||
"Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
|
||||
"`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
|
||||
"You can also click the reaction to approve:\n"
|
||||
"✅ = /approve\n"
|
||||
"❎ = /deny"
|
||||
"✅ = approve\n"
|
||||
"❎ = deny"
|
||||
)
|
||||
|
||||
result = await self.send(chat_id, text, metadata=metadata)
|
||||
@@ -1713,8 +1823,9 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
|
||||
is_free_room = room_id in self._free_rooms
|
||||
in_bot_thread = bool(thread_id and thread_id in self._threads)
|
||||
is_command = body.startswith("/")
|
||||
if self._require_mention and not is_free_room and not in_bot_thread:
|
||||
if not is_mentioned:
|
||||
if not is_mentioned and not is_command:
|
||||
logger.debug(
|
||||
"Matrix: ignoring message %s in %s — no @mention "
|
||||
"(set MATRIX_REQUIRE_MENTION=false to disable)",
|
||||
@@ -1781,6 +1892,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
body = source_content.get("body", "") or ""
|
||||
if not body:
|
||||
return
|
||||
body = _normalize_matrix_bang_command(body)
|
||||
|
||||
ctx = await self._resolve_message_context(
|
||||
room_id,
|
||||
@@ -1816,8 +1928,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
stripped.append(line)
|
||||
body = "\n".join(stripped) if stripped else body
|
||||
|
||||
# Re-run bang normalization after reply-fallback stripping so a quoted
|
||||
# reply whose actual content is a bang command (e.g. ``> quoted\n\n!model``)
|
||||
# is treated as a command, matching how ``/command`` is recognized below.
|
||||
body = _normalize_matrix_bang_command(body)
|
||||
|
||||
msg_type = MessageType.TEXT
|
||||
if body.startswith(("!", "/")):
|
||||
if body.startswith("/"):
|
||||
msg_type = MessageType.COMMAND
|
||||
|
||||
msg_event = MessageEvent(
|
||||
@@ -2202,7 +2319,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if prompt and not prompt.resolved:
|
||||
if room_id != prompt.chat_id:
|
||||
return
|
||||
if self._allowed_user_ids and sender not in self._allowed_user_ids:
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
if not _allow_all and not (self._allowed_user_ids and sender in self._allowed_user_ids):
|
||||
logger.info(
|
||||
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
|
||||
sender, reacts_to,
|
||||
@@ -2688,11 +2806,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
def _markdown_to_html(self, text: str) -> str:
|
||||
"""Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html).
|
||||
|
||||
Uses the ``markdown`` library when available (installed with the
|
||||
``matrix`` extra). Falls back to a comprehensive regex converter
|
||||
that handles fenced code blocks, inline code, headers, bold,
|
||||
italic, strikethrough, links, blockquotes, lists, and horizontal
|
||||
rules — everything the Matrix HTML spec allows.
|
||||
Uses the ``markdown`` library (a core dependency) when available.
|
||||
Falls back to a comprehensive regex converter that handles fenced
|
||||
code blocks, inline code, headers, bold, italic, strikethrough,
|
||||
links, blockquotes, lists, and horizontal rules — everything the
|
||||
Matrix HTML spec allows.
|
||||
"""
|
||||
try:
|
||||
import markdown as _md
|
||||
|
||||
@@ -1,873 +0,0 @@
|
||||
"""Mattermost gateway adapter.
|
||||
|
||||
Connects to a self-hosted (or cloud) Mattermost instance via its REST API
|
||||
(v4) and WebSocket for real-time events. No external Mattermost library
|
||||
required — uses aiohttp which is already a Hermes dependency.
|
||||
|
||||
Environment variables:
|
||||
MATTERMOST_URL Server URL (e.g. https://mm.example.com)
|
||||
MATTERMOST_TOKEN Bot token or personal-access token
|
||||
MATTERMOST_ALLOWED_USERS Comma-separated user IDs
|
||||
MATTERMOST_HOME_CHANNEL Channel ID for cron/notification delivery
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.helpers import MessageDeduplicator
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mattermost post size limit (server default is 16383, but 4000 is the
|
||||
# practical limit for readable messages — matching OpenClaw's choice).
|
||||
MAX_POST_LENGTH = 4000
|
||||
|
||||
# Channel type codes returned by the Mattermost API.
|
||||
_CHANNEL_TYPE_MAP = {
|
||||
"D": "dm",
|
||||
"G": "group",
|
||||
"P": "group", # private channel → treat as group
|
||||
"O": "channel",
|
||||
}
|
||||
|
||||
# Reconnect parameters (exponential backoff).
|
||||
_RECONNECT_BASE_DELAY = 2.0
|
||||
_RECONNECT_MAX_DELAY = 60.0
|
||||
_RECONNECT_JITTER = 0.2
|
||||
|
||||
|
||||
def check_mattermost_requirements() -> bool:
|
||||
"""Return True if the Mattermost adapter can be used."""
|
||||
token = os.getenv("MATTERMOST_TOKEN", "")
|
||||
url = os.getenv("MATTERMOST_URL", "")
|
||||
if not token:
|
||||
logger.debug("Mattermost: MATTERMOST_TOKEN not set")
|
||||
return False
|
||||
if not url:
|
||||
logger.warning("Mattermost: MATTERMOST_URL not set")
|
||||
return False
|
||||
try:
|
||||
import aiohttp # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning("Mattermost: aiohttp not installed")
|
||||
return False
|
||||
|
||||
|
||||
class MattermostAdapter(BasePlatformAdapter):
|
||||
"""Gateway adapter for Mattermost (self-hosted or cloud)."""
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.MATTERMOST)
|
||||
|
||||
self._base_url: str = (
|
||||
config.extra.get("url", "")
|
||||
or os.getenv("MATTERMOST_URL", "")
|
||||
).rstrip("/")
|
||||
self._token: str = config.token or os.getenv("MATTERMOST_TOKEN", "")
|
||||
|
||||
self._bot_user_id: str = ""
|
||||
self._bot_username: str = ""
|
||||
|
||||
# aiohttp session + websocket handle
|
||||
self._session: Any = None # aiohttp.ClientSession
|
||||
self._ws: Any = None # aiohttp.ClientWebSocketResponse
|
||||
self._ws_task: Optional[asyncio.Task] = None
|
||||
self._reconnect_task: Optional[asyncio.Task] = None
|
||||
self._closing = False
|
||||
|
||||
# Reply mode: "thread" to nest replies, "off" for flat messages.
|
||||
self._reply_mode: str = (
|
||||
config.extra.get("reply_mode", "")
|
||||
or os.getenv("MATTERMOST_REPLY_MODE", "off")
|
||||
).lower()
|
||||
|
||||
# Dedup cache (prevent reprocessing)
|
||||
self._dedup = MessageDeduplicator()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _api_get(self, path: str) -> Dict[str, Any]:
|
||||
"""GET /api/v4/{path}."""
|
||||
import aiohttp
|
||||
url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
|
||||
try:
|
||||
async with self._session.get(url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
logger.error("MM API GET %s → %s: %s", path, resp.status, body[:200])
|
||||
return {}
|
||||
return await resp.json()
|
||||
except aiohttp.ClientError as exc:
|
||||
logger.error("MM API GET %s network error: %s", path, exc)
|
||||
return {}
|
||||
|
||||
async def _api_post(
|
||||
self, path: str, payload: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""POST /api/v4/{path} with JSON body."""
|
||||
import aiohttp
|
||||
url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
|
||||
try:
|
||||
async with self._session.post(
|
||||
url, headers=self._headers(), json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
logger.error("MM API POST %s → %s: %s", path, resp.status, body[:200])
|
||||
return {}
|
||||
return await resp.json()
|
||||
except aiohttp.ClientError as exc:
|
||||
logger.error("MM API POST %s network error: %s", path, exc)
|
||||
return {}
|
||||
|
||||
async def _api_put(
|
||||
self, path: str, payload: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""PUT /api/v4/{path} with JSON body."""
|
||||
import aiohttp
|
||||
url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
|
||||
try:
|
||||
async with self._session.put(
|
||||
url, headers=self._headers(), json=payload
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
logger.error("MM API PUT %s → %s: %s", path, resp.status, body[:200])
|
||||
return {}
|
||||
return await resp.json()
|
||||
except aiohttp.ClientError as exc:
|
||||
logger.error("MM API PUT %s network error: %s", path, exc)
|
||||
return {}
|
||||
|
||||
async def _upload_file(
|
||||
self, channel_id: str, file_data: bytes, filename: str, content_type: str = "application/octet-stream"
|
||||
) -> Optional[str]:
|
||||
"""Upload a file and return its file ID, or None on failure."""
|
||||
import aiohttp
|
||||
|
||||
url = f"{self._base_url}/api/v4/files"
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("channel_id", channel_id)
|
||||
form.add_field(
|
||||
"files",
|
||||
file_data,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {self._token}"}
|
||||
async with self._session.post(url, headers=headers, data=form, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
logger.error("MM file upload → %s: %s", resp.status, body[:200])
|
||||
return None
|
||||
data = await resp.json()
|
||||
infos = data.get("file_infos", [])
|
||||
return infos[0]["id"] if infos else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Required overrides
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Mattermost and start the WebSocket listener."""
|
||||
import aiohttp
|
||||
|
||||
if not self._base_url or not self._token:
|
||||
logger.error("Mattermost: URL or token not configured")
|
||||
return False
|
||||
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
)
|
||||
self._closing = False
|
||||
|
||||
# Verify credentials and fetch bot identity.
|
||||
me = await self._api_get("users/me")
|
||||
if not me or "id" not in me:
|
||||
logger.error("Mattermost: failed to authenticate — check MATTERMOST_TOKEN and MATTERMOST_URL")
|
||||
await self._session.close()
|
||||
return False
|
||||
|
||||
self._bot_user_id = me["id"]
|
||||
self._bot_username = me.get("username", "")
|
||||
logger.info(
|
||||
"Mattermost: authenticated as @%s (%s) on %s",
|
||||
self._bot_username,
|
||||
self._bot_user_id,
|
||||
self._base_url,
|
||||
)
|
||||
|
||||
# Start WebSocket in background.
|
||||
self._ws_task = asyncio.create_task(self._ws_loop())
|
||||
self._mark_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Mattermost."""
|
||||
self._closing = True
|
||||
|
||||
if self._ws_task and not self._ws_task.done():
|
||||
self._ws_task.cancel()
|
||||
try:
|
||||
await self._ws_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
|
||||
if self._ws:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
logger.info("Mattermost: disconnected")
|
||||
|
||||
|
||||
async def _resolve_root_id(self, post_id: str) -> str:
|
||||
"""Resolve a post_id to the thread root_id for Mattermost.
|
||||
|
||||
Mattermost requires root_id to be the *root* post of a thread.
|
||||
If the post is a reply (has its own root_id), we must use that
|
||||
root_id instead. Using a reply's own ID as root_id causes
|
||||
"Invalid RootId parameter" errors.
|
||||
"""
|
||||
if not post_id:
|
||||
return post_id
|
||||
# Check if this post has a root_id (meaning it's a reply)
|
||||
data = await self._api_get(f"posts/{post_id}")
|
||||
if data and data.get("root_id"):
|
||||
return data["root_id"]
|
||||
return post_id
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a message (or multiple chunks) to a channel."""
|
||||
if not content:
|
||||
return SendResult(success=True)
|
||||
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, MAX_POST_LENGTH)
|
||||
|
||||
last_id = None
|
||||
for chunk in chunks:
|
||||
payload: Dict[str, Any] = {
|
||||
"channel_id": chat_id,
|
||||
"message": chunk,
|
||||
}
|
||||
# Thread support: reply_to is the root post ID.
|
||||
if reply_to and self._reply_mode == "thread":
|
||||
# Ensure root_id points to the thread root, not a reply.
|
||||
# Mattermost rejects non-root post IDs as root_id.
|
||||
resolved_root = await self._resolve_root_id(reply_to)
|
||||
payload["root_id"] = resolved_root
|
||||
|
||||
data = await self._api_post("posts", payload)
|
||||
if not data or "id" not in data:
|
||||
return SendResult(success=False, error="Failed to create post")
|
||||
last_id = data["id"]
|
||||
|
||||
return SendResult(success=True, message_id=last_id)
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return channel name and type."""
|
||||
data = await self._api_get(f"channels/{chat_id}")
|
||||
if not data:
|
||||
return {"name": chat_id, "type": "channel"}
|
||||
|
||||
ch_type = _CHANNEL_TYPE_MAP.get(data.get("type", "O"), "channel")
|
||||
display_name = data.get("display_name") or data.get("name") or chat_id
|
||||
return {"name": display_name, "type": ch_type}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Optional overrides
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_typing(
|
||||
self, chat_id: str, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Send a typing indicator."""
|
||||
await self._api_post(
|
||||
f"users/{self._bot_user_id}/typing",
|
||||
{"channel_id": chat_id},
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self, chat_id: str, message_id: str, content: str, *, finalize: bool = False
|
||||
) -> SendResult:
|
||||
"""Edit an existing post."""
|
||||
formatted = self.format_message(content)
|
||||
data = await self._api_put(
|
||||
f"posts/{message_id}/patch",
|
||||
{"message": formatted},
|
||||
)
|
||||
if not data or "id" not in data:
|
||||
return SendResult(success=False, error="Failed to edit post")
|
||||
return SendResult(success=True, message_id=data["id"])
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Download an image and upload it as a file attachment."""
|
||||
return await self._send_url_as_file(
|
||||
chat_id, image_url, caption, reply_to, "image"
|
||||
)
|
||||
|
||||
async def send_image_file(
|
||||
self,
|
||||
chat_id: str,
|
||||
image_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Upload a local image file."""
|
||||
return await self._send_local_file(
|
||||
chat_id, image_path, caption, reply_to
|
||||
)
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: str,
|
||||
file_path: str,
|
||||
caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Upload a local file as a document."""
|
||||
return await self._send_local_file(
|
||||
chat_id, file_path, caption, reply_to, file_name
|
||||
)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: str,
|
||||
audio_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Upload an audio file."""
|
||||
return await self._send_local_file(
|
||||
chat_id, audio_path, caption, reply_to
|
||||
)
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: str,
|
||||
video_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Upload a video file."""
|
||||
return await self._send_local_file(
|
||||
chat_id, video_path, caption, reply_to
|
||||
)
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Mattermost uses standard Markdown — mostly pass through.
|
||||
|
||||
Strip image markdown into plain links (files are uploaded separately).
|
||||
"""
|
||||
# Convert  to just the URL — Mattermost renders
|
||||
# image URLs as inline previews automatically.
|
||||
content = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", content)
|
||||
return content
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _send_url_as_file(
|
||||
self,
|
||||
chat_id: str,
|
||||
url: str,
|
||||
caption: Optional[str],
|
||||
reply_to: Optional[str],
|
||||
kind: str = "file",
|
||||
) -> SendResult:
|
||||
"""Download a URL and upload it as a file attachment."""
|
||||
from tools.url_safety import is_safe_url
|
||||
if not is_safe_url(url):
|
||||
logger.warning("Mattermost: blocked unsafe URL (SSRF protection)")
|
||||
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
|
||||
|
||||
import aiohttp
|
||||
|
||||
file_data = None
|
||||
ct = "application/octet-stream"
|
||||
fname = url.rsplit("/", 1)[-1].split("?")[0] or f"{kind}.png"
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
async with self._session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
if resp.status >= 500 or resp.status == 429:
|
||||
if attempt < 2:
|
||||
logger.debug("Mattermost download retry %d/2 for %s (status %d)",
|
||||
attempt + 1, url[:80], resp.status)
|
||||
await asyncio.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
if resp.status >= 400:
|
||||
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
|
||||
file_data = await resp.read()
|
||||
ct = resp.content_type or "application/octet-stream"
|
||||
break
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
logger.warning("Mattermost: failed to download %s after %d attempts: %s", url, attempt + 1, exc)
|
||||
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
|
||||
|
||||
if file_data is None:
|
||||
logger.warning("Mattermost: download returned no data for %s", url)
|
||||
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
|
||||
|
||||
file_id = await self._upload_file(chat_id, file_data, fname, ct)
|
||||
if not file_id:
|
||||
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"channel_id": chat_id,
|
||||
"message": caption or "",
|
||||
"file_ids": [file_id],
|
||||
}
|
||||
if reply_to and self._reply_mode == "thread":
|
||||
payload["root_id"] = await self._resolve_root_id(reply_to)
|
||||
|
||||
data = await self._api_post("posts", payload)
|
||||
if not data or "id" not in data:
|
||||
return SendResult(success=False, error="Failed to post with file")
|
||||
return SendResult(success=True, message_id=data["id"])
|
||||
|
||||
async def _send_local_file(
|
||||
self,
|
||||
chat_id: str,
|
||||
file_path: str,
|
||||
caption: Optional[str],
|
||||
reply_to: Optional[str],
|
||||
file_name: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
"""Upload a local file and attach it to a post."""
|
||||
import mimetypes
|
||||
|
||||
p = Path(file_path)
|
||||
if not p.exists():
|
||||
logger.warning(
|
||||
"Mattermost: local file not found, skipping: %s", file_path
|
||||
)
|
||||
return SendResult(success=True, message_id=None)
|
||||
|
||||
fname = file_name or p.name
|
||||
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
|
||||
file_data = p.read_bytes()
|
||||
|
||||
file_id = await self._upload_file(chat_id, file_data, fname, ct)
|
||||
if not file_id:
|
||||
return SendResult(success=False, error="File upload failed")
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"channel_id": chat_id,
|
||||
"message": caption or "",
|
||||
"file_ids": [file_id],
|
||||
}
|
||||
if reply_to and self._reply_mode == "thread":
|
||||
payload["root_id"] = await self._resolve_root_id(reply_to)
|
||||
|
||||
data = await self._api_post("posts", payload)
|
||||
if not data or "id" not in data:
|
||||
return SendResult(success=False, error="Failed to post with file")
|
||||
return SendResult(success=True, message_id=data["id"])
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images as a single Mattermost post with multiple attachments.
|
||||
|
||||
Mattermost supports up to 5 ``file_ids`` per post. Each image is
|
||||
uploaded individually (Mattermost's file API is one-at-a-time),
|
||||
then a single post is created referencing all uploaded file_ids
|
||||
at once. Batches larger than 5 are chunked. Falls back to the
|
||||
base per-image loop on total failure.
|
||||
"""
|
||||
if not images:
|
||||
return
|
||||
|
||||
import mimetypes
|
||||
import aiohttp
|
||||
from urllib.parse import unquote as _unquote
|
||||
|
||||
CHUNK = 5 # Mattermost post file_ids cap
|
||||
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
|
||||
file_ids: List[str] = []
|
||||
caption_parts: List[str] = []
|
||||
try:
|
||||
for image_url, alt_text in chunk:
|
||||
if alt_text:
|
||||
caption_parts.append(alt_text)
|
||||
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
p = Path(local_path)
|
||||
if not p.exists():
|
||||
logger.warning("Mattermost: skipping missing image %s", local_path)
|
||||
continue
|
||||
fname = p.name
|
||||
ct = mimetypes.guess_type(fname)[0] or "image/png"
|
||||
file_data = p.read_bytes()
|
||||
else:
|
||||
from tools.url_safety import is_safe_url
|
||||
if not is_safe_url(image_url):
|
||||
logger.warning("Mattermost: blocked unsafe image URL in batch")
|
||||
continue
|
||||
try:
|
||||
async with self._session.get(
|
||||
image_url, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
logger.warning(
|
||||
"Mattermost: failed to download image (HTTP %d): %s",
|
||||
resp.status, image_url[:80],
|
||||
)
|
||||
continue
|
||||
file_data = await resp.read()
|
||||
ct = resp.content_type or "image/png"
|
||||
except Exception as dl_err:
|
||||
logger.warning("Mattermost: download failed for %s: %s", image_url[:80], dl_err)
|
||||
continue
|
||||
fname = image_url.rsplit("/", 1)[-1].split("?")[0] or f"image_{len(file_ids)}.png"
|
||||
|
||||
fid = await self._upload_file(chat_id, file_data, fname, ct)
|
||||
if fid:
|
||||
file_ids.append(fid)
|
||||
|
||||
if not file_ids:
|
||||
continue
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"channel_id": chat_id,
|
||||
"message": "\n".join(caption_parts),
|
||||
"file_ids": file_ids,
|
||||
}
|
||||
logger.info(
|
||||
"Mattermost: sending %d image(s) as single post (chunk %d/%d)",
|
||||
len(file_ids), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
data = await self._api_post("posts", payload)
|
||||
if not data or "id" not in data:
|
||||
logger.warning("Mattermost: multi-image post failed, falling back")
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Mattermost: multi-image send failed (chunk %d/%d), falling back: %s",
|
||||
chunk_idx + 1, len(chunks), e, exc_info=True,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _ws_loop(self) -> None:
|
||||
"""Connect to the WebSocket and listen for events, reconnecting on failure."""
|
||||
delay = _RECONNECT_BASE_DELAY
|
||||
while not self._closing:
|
||||
try:
|
||||
await self._ws_connect_and_listen()
|
||||
# Clean disconnect — reset delay.
|
||||
delay = _RECONNECT_BASE_DELAY
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as exc:
|
||||
if self._closing:
|
||||
return
|
||||
# Detect permanent auth/permission failures that will never
|
||||
# succeed on retry — stop reconnecting instead of looping forever.
|
||||
import aiohttp
|
||||
err_str = str(exc).lower()
|
||||
if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in {401, 403}:
|
||||
logger.error("Mattermost WS auth failed (HTTP %d) — stopping reconnect", exc.status)
|
||||
return
|
||||
if "401" in err_str or "403" in err_str or "unauthorized" in err_str:
|
||||
logger.error("Mattermost WS permanent error: %s — stopping reconnect", exc)
|
||||
return
|
||||
logger.warning("Mattermost WS error: %s — reconnecting in %.0fs", exc, delay)
|
||||
|
||||
if self._closing:
|
||||
return
|
||||
|
||||
# Exponential backoff with jitter.
|
||||
import random
|
||||
jitter = delay * _RECONNECT_JITTER * random.random()
|
||||
await asyncio.sleep(delay + jitter)
|
||||
delay = min(delay * 2, _RECONNECT_MAX_DELAY)
|
||||
|
||||
async def _ws_connect_and_listen(self) -> None:
|
||||
"""Single WebSocket session: connect, authenticate, process events."""
|
||||
# Build WS URL: https:// → wss://, http:// → ws://
|
||||
ws_url = re.sub(r"^http", "ws", self._base_url) + "/api/v4/websocket"
|
||||
logger.info("Mattermost: connecting to %s", ws_url)
|
||||
|
||||
self._ws = await self._session.ws_connect(ws_url, heartbeat=30.0)
|
||||
|
||||
# Authenticate via the WebSocket.
|
||||
auth_msg = {
|
||||
"seq": 1,
|
||||
"action": "authentication_challenge",
|
||||
"data": {"token": self._token},
|
||||
}
|
||||
await self._ws.send_json(auth_msg)
|
||||
logger.info("Mattermost: WebSocket connected and authenticated")
|
||||
|
||||
async for raw_msg in self._ws:
|
||||
if self._closing:
|
||||
return
|
||||
|
||||
if raw_msg.type in {
|
||||
raw_msg.type.TEXT,
|
||||
raw_msg.type.BINARY,
|
||||
}:
|
||||
try:
|
||||
event = json.loads(raw_msg.data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
await self._handle_ws_event(event)
|
||||
elif raw_msg.type in {
|
||||
raw_msg.type.ERROR,
|
||||
raw_msg.type.CLOSE,
|
||||
raw_msg.type.CLOSING,
|
||||
raw_msg.type.CLOSED,
|
||||
}:
|
||||
logger.info("Mattermost: WebSocket closed (%s)", raw_msg.type)
|
||||
break
|
||||
|
||||
async def _handle_ws_event(self, event: Dict[str, Any]) -> None:
|
||||
"""Process a single WebSocket event."""
|
||||
event_type = event.get("event")
|
||||
if event_type != "posted":
|
||||
return
|
||||
|
||||
data = event.get("data", {})
|
||||
raw_post_str = data.get("post")
|
||||
if not raw_post_str:
|
||||
return
|
||||
|
||||
try:
|
||||
post = json.loads(raw_post_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
|
||||
# Ignore own messages.
|
||||
if post.get("user_id") == self._bot_user_id:
|
||||
return
|
||||
|
||||
# Ignore system posts.
|
||||
if post.get("type"):
|
||||
return
|
||||
|
||||
post_id = post.get("id", "")
|
||||
|
||||
# Dedup.
|
||||
if self._dedup.is_duplicate(post_id):
|
||||
return
|
||||
|
||||
# Build message event.
|
||||
channel_id = post.get("channel_id", "")
|
||||
channel_type_raw = data.get("channel_type", "O")
|
||||
chat_type = _CHANNEL_TYPE_MAP.get(channel_type_raw, "channel")
|
||||
|
||||
# For DMs, user_id is sufficient. For channels, check for @mention.
|
||||
message_text = post.get("message", "")
|
||||
|
||||
# Mention-gating for non-DM channels.
|
||||
# Config (config.yaml `mattermost.*` with env-var fallback):
|
||||
# require_mention / MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true)
|
||||
# free_response_channels / MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention
|
||||
# allowed_channels / MATTERMOST_ALLOWED_CHANNELS: If set, bot ONLY responds in these channels (whitelist)
|
||||
if channel_type_raw != "D":
|
||||
# allowed_channels check (whitelist — must pass before other gating).
|
||||
# When set, messages from channels NOT in this list are silently
|
||||
# ignored, even if @mentioned. DMs are already excluded above.
|
||||
allowed_raw = self.config.extra.get("allowed_channels") if self.config.extra else None
|
||||
if allowed_raw is None:
|
||||
allowed_raw = os.getenv("MATTERMOST_ALLOWED_CHANNELS", "")
|
||||
if isinstance(allowed_raw, list):
|
||||
allowed_channels = {str(c).strip() for c in allowed_raw if str(c).strip()}
|
||||
else:
|
||||
allowed_channels = {
|
||||
c.strip() for c in str(allowed_raw).split(",") if c.strip()
|
||||
}
|
||||
if allowed_channels and channel_id not in allowed_channels:
|
||||
logger.debug(
|
||||
"Mattermost: ignoring message in non-allowed channel: %s",
|
||||
channel_id,
|
||||
)
|
||||
return
|
||||
|
||||
require_mention = os.getenv(
|
||||
"MATTERMOST_REQUIRE_MENTION", "true"
|
||||
).lower() not in {"false", "0", "no"}
|
||||
|
||||
free_channels_raw = os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS", "")
|
||||
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
|
||||
is_free_channel = channel_id in free_channels
|
||||
|
||||
mention_patterns = [
|
||||
f"@{self._bot_username}",
|
||||
f"@{self._bot_user_id}",
|
||||
]
|
||||
has_mention = any(
|
||||
pattern.lower() in message_text.lower()
|
||||
for pattern in mention_patterns
|
||||
)
|
||||
|
||||
if require_mention and not is_free_channel and not has_mention:
|
||||
logger.debug(
|
||||
"Mattermost: skipping non-DM message without @mention (channel=%s)",
|
||||
channel_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Strip @mention from the message text so the agent sees clean input.
|
||||
if has_mention:
|
||||
for pattern in mention_patterns:
|
||||
message_text = re.sub(
|
||||
re.escape(pattern), "", message_text, flags=re.IGNORECASE
|
||||
).strip()
|
||||
|
||||
# Resolve sender info.
|
||||
sender_id = post.get("user_id", "")
|
||||
sender_name = data.get("sender_name", "").lstrip("@") or sender_id
|
||||
|
||||
# Thread support: if the post is in a thread, use root_id.
|
||||
thread_id = post.get("root_id") or None
|
||||
|
||||
# Determine message type.
|
||||
file_ids = post.get("file_ids") or []
|
||||
msg_type = MessageType.TEXT
|
||||
if message_text.startswith("/"):
|
||||
msg_type = MessageType.COMMAND
|
||||
|
||||
# Download file attachments immediately (URLs require auth headers
|
||||
# that downstream tools won't have).
|
||||
media_urls: List[str] = []
|
||||
media_types: List[str] = []
|
||||
for fid in file_ids:
|
||||
try:
|
||||
file_info = await self._api_get(f"files/{fid}/info")
|
||||
fname = file_info.get("name", f"file_{fid}")
|
||||
ext = Path(fname).suffix or ""
|
||||
mime = file_info.get("mime_type", "application/octet-stream")
|
||||
|
||||
import aiohttp
|
||||
dl_url = f"{self._base_url}/api/v4/files/{fid}"
|
||||
async with self._session.get(
|
||||
dl_url,
|
||||
headers={"Authorization": f"Bearer {self._token}"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as resp:
|
||||
if resp.status < 400:
|
||||
file_data = await resp.read()
|
||||
from gateway.platforms.base import cache_image_from_bytes, cache_document_from_bytes
|
||||
if mime.startswith("image/"):
|
||||
local_path = cache_image_from_bytes(file_data, ext or ".png")
|
||||
media_urls.append(local_path)
|
||||
media_types.append(mime)
|
||||
elif mime.startswith("audio/"):
|
||||
from gateway.platforms.base import cache_audio_from_bytes
|
||||
local_path = cache_audio_from_bytes(file_data, ext or ".ogg")
|
||||
media_urls.append(local_path)
|
||||
media_types.append(mime)
|
||||
else:
|
||||
local_path = cache_document_from_bytes(file_data, fname)
|
||||
media_urls.append(local_path)
|
||||
media_types.append(mime)
|
||||
else:
|
||||
logger.warning("Mattermost: failed to download file %s: HTTP %s", fid, resp.status)
|
||||
except Exception as exc:
|
||||
logger.warning("Mattermost: error downloading file %s: %s", fid, exc)
|
||||
|
||||
# Set message type based on downloaded media types.
|
||||
if media_types and msg_type == MessageType.TEXT:
|
||||
if any(m.startswith("image/") for m in media_types):
|
||||
msg_type = MessageType.PHOTO
|
||||
elif any(m.startswith("audio/") for m in media_types):
|
||||
msg_type = MessageType.VOICE
|
||||
elif media_types:
|
||||
msg_type = MessageType.DOCUMENT
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=channel_id,
|
||||
chat_type=chat_type,
|
||||
user_id=sender_id,
|
||||
user_name=sender_name,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
# Per-channel ephemeral prompt
|
||||
from gateway.platforms.base import resolve_channel_prompt
|
||||
_channel_prompt = resolve_channel_prompt(
|
||||
self.config.extra, channel_id, None,
|
||||
)
|
||||
|
||||
msg_event = MessageEvent(
|
||||
text=message_text,
|
||||
message_type=msg_type,
|
||||
source=source,
|
||||
raw_message=post,
|
||||
message_id=post_id,
|
||||
media_urls=media_urls if media_urls else None,
|
||||
media_types=media_types if media_types else None,
|
||||
channel_prompt=_channel_prompt,
|
||||
)
|
||||
|
||||
await self.handle_message(msg_event)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from gateway.platforms.base import (
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
is_network_accessible,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -132,7 +133,25 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler]) -> None:
|
||||
self._notification_scheduler = scheduler
|
||||
|
||||
def _source_allowlist_required_but_missing(self) -> bool:
|
||||
return is_network_accessible(self._host) and not self._allowed_source_networks
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._client_state is None:
|
||||
logger.error(
|
||||
"[msgraph_webhook] Refusing to start without extra.client_state configured"
|
||||
)
|
||||
return False
|
||||
if self._source_allowlist_required_but_missing():
|
||||
logger.error(
|
||||
"[msgraph_webhook] Refusing to start: binding to %s requires "
|
||||
"extra.allowed_source_cidrs. Configure the Microsoft Graph "
|
||||
"source CIDRs or bind to loopback (127.0.0.1/::1) behind a "
|
||||
"tunnel or reverse proxy.",
|
||||
self._host,
|
||||
)
|
||||
return False
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get(self._health_path, self._handle_health)
|
||||
app.router.add_get(self._webhook_path, self._handle_validation)
|
||||
@@ -171,6 +190,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
return {"name": chat_id, "type": "webhook"}
|
||||
|
||||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||||
if not self._source_ip_allowed(request):
|
||||
return web.Response(status=403)
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
@@ -265,9 +286,12 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
def _source_ip_allowed(self, request: "web.Request") -> bool:
|
||||
"""Return True if the request's source IP is in the configured allowlist.
|
||||
|
||||
When ``allowed_source_cidrs`` is empty (the default), everything is
|
||||
allowed — preserves behavior for dev tunnels / localhost setups.
|
||||
Loopback-only binds may omit ``allowed_source_cidrs`` for local reverse
|
||||
proxies and dev tunnels. Network-accessible binds fail closed until an
|
||||
explicit CIDR allowlist is configured.
|
||||
"""
|
||||
if self._source_allowlist_required_but_missing():
|
||||
return False
|
||||
if not self._allowed_source_networks:
|
||||
return True
|
||||
peer = request.remote or ""
|
||||
@@ -310,7 +334,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
expected = self._client_state
|
||||
if expected is None:
|
||||
return True
|
||||
return False
|
||||
provided = self._string_or_none(notification.get("clientState"))
|
||||
if provided is None:
|
||||
return False
|
||||
|
||||
@@ -126,7 +126,6 @@ from gateway.platforms.qqbot.chunked_upload import (
|
||||
)
|
||||
from gateway.platforms.qqbot.keyboards import (
|
||||
ApprovalRequest,
|
||||
ApprovalSender,
|
||||
InlineKeyboard,
|
||||
InteractionEvent,
|
||||
build_approval_keyboard,
|
||||
@@ -270,6 +269,11 @@ class QQAdapter(BasePlatformAdapter):
|
||||
def name(self) -> str:
|
||||
return "QQBot"
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""QQBot gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
@@ -534,9 +538,30 @@ class QQAdapter(BasePlatformAdapter):
|
||||
self._mark_transport_disconnected()
|
||||
self._fail_pending("Connection closed")
|
||||
|
||||
# Stop reconnecting for fatal codes
|
||||
if code in {4914, 4915}:
|
||||
desc = "offline/sandbox-only" if code == 4914 else "banned"
|
||||
# Stop reconnecting for fatal codes (unrecoverable errors)
|
||||
if code in {
|
||||
4001, # Invalid opcode
|
||||
4002, # Invalid payload
|
||||
4010, # Invalid shard
|
||||
4011, # Sharding required
|
||||
4012, # Invalid API version
|
||||
4013, # Invalid intent
|
||||
4014, # Intent not authorized
|
||||
4914, # Offline/sandbox-only
|
||||
4915, # Banned
|
||||
}:
|
||||
fatal_descriptions = {
|
||||
4001: "invalid opcode",
|
||||
4002: "invalid payload",
|
||||
4010: "invalid shard",
|
||||
4011: "sharding required",
|
||||
4012: "invalid API version",
|
||||
4013: "invalid intent",
|
||||
4014: "intent not authorized",
|
||||
4914: "offline/sandbox-only",
|
||||
4915: "banned",
|
||||
}
|
||||
desc = fatal_descriptions.get(code, f"fatal error (code={code})")
|
||||
logger.error(
|
||||
"[%s] Bot is %s. Check QQ Open Platform.", self._log_tag, desc
|
||||
)
|
||||
@@ -573,10 +598,11 @@ class QQAdapter(BasePlatformAdapter):
|
||||
self._token_expires_at = 0.0
|
||||
|
||||
# Session invalid → clear session, will re-identify on next Hello
|
||||
# Note: 4009 (connection timeout) is NOT included here — it is
|
||||
# resumable per the QQ protocol and should preserve session state.
|
||||
if code in {
|
||||
4006,
|
||||
4007,
|
||||
4009,
|
||||
4900,
|
||||
4901,
|
||||
4902,
|
||||
@@ -655,6 +681,12 @@ class QQAdapter(BasePlatformAdapter):
|
||||
"""Read WebSocket frames until connection closes."""
|
||||
if not self._ws:
|
||||
raise RuntimeError("WebSocket not connected")
|
||||
if self._ws.closed:
|
||||
# A closed-but-non-None ws makes the while-condition false on entry,
|
||||
# so this would return normally — which _listen_loop treats as a
|
||||
# clean read and immediately retries with backoff reset to 0,
|
||||
# producing a 100% CPU spin. Raise so the reconnect/backoff path runs.
|
||||
raise RuntimeError("WebSocket closed")
|
||||
|
||||
while self._running and self._ws and not self._ws.closed:
|
||||
msg = await self._ws.receive()
|
||||
@@ -705,9 +737,8 @@ class QQAdapter(BasePlatformAdapter):
|
||||
"token": f"QQBot {token}",
|
||||
"intents": (1 << 25)
|
||||
| (1 << 30)
|
||||
| (
|
||||
1 << 12
|
||||
), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE
|
||||
| (1 << 12)
|
||||
| (1 << 26), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE + INTERACTION
|
||||
"shard": [0, 1],
|
||||
"properties": {
|
||||
"$os": "macOS",
|
||||
@@ -826,6 +857,32 @@ class QQAdapter(BasePlatformAdapter):
|
||||
if op == 11:
|
||||
return
|
||||
|
||||
# op 7 = Server Reconnect — server asks client to reconnect (e.g.
|
||||
# load-balancing, maintenance). Close the WS so _read_events raises
|
||||
# and the outer loop triggers a reconnect with Resume.
|
||||
if op == 7:
|
||||
logger.info("[%s] Server requested reconnect (op 7)", self._log_tag)
|
||||
if self._ws and not self._ws.closed:
|
||||
self._create_task(self._ws.close())
|
||||
return
|
||||
|
||||
# op 9 = Invalid Session — d=True means session is resumable,
|
||||
# d=False means we must re-identify from scratch.
|
||||
if op == 9:
|
||||
resumable = bool(d) if d is not None else False
|
||||
if not resumable:
|
||||
logger.info(
|
||||
"[%s] Invalid session (op 9, not resumable), clearing session",
|
||||
self._log_tag,
|
||||
)
|
||||
self._session_id = None
|
||||
self._last_seq = None
|
||||
else:
|
||||
logger.info("[%s] Invalid session (op 9, resumable)", self._log_tag)
|
||||
if self._ws and not self._ws.closed:
|
||||
self._create_task(self._ws.close())
|
||||
return
|
||||
|
||||
logger.debug("[%s] Unknown op: %s", self._log_tag, op)
|
||||
|
||||
def _handle_ready(self, d: Any) -> None:
|
||||
@@ -1007,6 +1064,46 @@ class QQAdapter(BasePlatformAdapter):
|
||||
"deny": "deny",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_gateway_session_key(session_key: str) -> Optional[Dict[str, str]]:
|
||||
"""Parse ``agent:main:<platform>:<chat_type>:<chat_id>[:<user_id>]``."""
|
||||
parts = str(session_key or "").split(":")
|
||||
if len(parts) < 5 or parts[0] != "agent" or parts[1] != "main":
|
||||
return None
|
||||
parsed = {
|
||||
"platform": parts[2],
|
||||
"chat_type": parts[3],
|
||||
"chat_id": parts[4],
|
||||
}
|
||||
if len(parts) > 5:
|
||||
parsed["user_id"] = parts[5]
|
||||
return parsed
|
||||
|
||||
def _is_authorized_interaction_for_session(
|
||||
self,
|
||||
event: InteractionEvent,
|
||||
session_key: str,
|
||||
) -> bool:
|
||||
"""Authorize approval/update interactions against session + operator."""
|
||||
parsed = self._parse_gateway_session_key(session_key)
|
||||
operator = str(event.operator_openid or "").strip()
|
||||
if not parsed or parsed.get("platform") != "qqbot" or not operator:
|
||||
return False
|
||||
|
||||
chat_type = parsed.get("chat_type", "")
|
||||
chat_id = parsed.get("chat_id", "")
|
||||
if chat_type == "c2c":
|
||||
return bool(chat_id) and operator == chat_id
|
||||
|
||||
if chat_type in {"group", "guild"}:
|
||||
event_chat = str(event.group_openid or event.guild_id or "").strip()
|
||||
if not event_chat or event_chat != chat_id:
|
||||
return False
|
||||
session_user = str(parsed.get("user_id", "")).strip()
|
||||
return bool(session_user) and operator == session_user
|
||||
|
||||
return False
|
||||
|
||||
async def _default_interaction_dispatch(
|
||||
self,
|
||||
event: InteractionEvent,
|
||||
@@ -1040,6 +1137,13 @@ class QQAdapter(BasePlatformAdapter):
|
||||
self._log_tag, decision, session_key,
|
||||
)
|
||||
return
|
||||
if not self._is_authorized_interaction_for_session(event, session_key):
|
||||
logger.warning(
|
||||
"[%s] Rejected unauthorized approval click for session %s "
|
||||
"(operator=%s)",
|
||||
self._log_tag, session_key, event.operator_openid,
|
||||
)
|
||||
return
|
||||
try:
|
||||
# Import lazily to keep the adapter importable in tests that
|
||||
# don't exercise the approval subsystem.
|
||||
@@ -1060,6 +1164,13 @@ class QQAdapter(BasePlatformAdapter):
|
||||
|
||||
update_answer = parse_update_prompt_button_data(button_data)
|
||||
if update_answer is not None:
|
||||
update_session_key = f"agent:main:qqbot:{event.scene}:{event.group_openid or event.guild_id or event.user_openid}"
|
||||
if not self._is_authorized_interaction_for_session(event, update_session_key):
|
||||
logger.warning(
|
||||
"[%s] Rejected unauthorized update prompt click (operator=%s)",
|
||||
self._log_tag, event.operator_openid,
|
||||
)
|
||||
return
|
||||
self._write_update_response(update_answer, event.operator_openid)
|
||||
return
|
||||
|
||||
@@ -1607,7 +1718,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
elif ct.startswith("image/"):
|
||||
# Image: download and cache locally.
|
||||
try:
|
||||
cached_path = await self._download_and_cache(url, ct)
|
||||
cached_path = await self._download_and_cache(url, ct, filename)
|
||||
if cached_path and os.path.isfile(cached_path):
|
||||
image_urls.append(cached_path)
|
||||
image_media_types.append(ct or "image/jpeg")
|
||||
@@ -1620,11 +1731,15 @@ class QQAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.debug("[%s] Failed to cache image: %s", self._log_tag, exc)
|
||||
else:
|
||||
# Other attachments (video, file, etc.): record as text.
|
||||
# Other attachments (video, file, etc.): download and record with path.
|
||||
try:
|
||||
cached_path = await self._download_and_cache(url, ct)
|
||||
cached_path = await self._download_and_cache(url, ct, filename)
|
||||
if cached_path:
|
||||
other_attachments.append(f"[Attachment: {filename or ct}]")
|
||||
name = filename or ct
|
||||
if ct.startswith("video/"):
|
||||
other_attachments.append(f"[video: {name} ({cached_path})]")
|
||||
else:
|
||||
other_attachments.append(f"[file: {name} ({cached_path})]")
|
||||
except Exception as exc:
|
||||
logger.debug("[%s] Failed to cache attachment: %s", self._log_tag, exc)
|
||||
|
||||
@@ -1636,8 +1751,14 @@ class QQAdapter(BasePlatformAdapter):
|
||||
"attachment_info": attachment_info,
|
||||
}
|
||||
|
||||
async def _download_and_cache(self, url: str, content_type: str) -> Optional[str]:
|
||||
"""Download a URL and cache it locally."""
|
||||
async def _download_and_cache(
|
||||
self, url: str, content_type: str, original_name: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Download a URL and cache it locally.
|
||||
|
||||
:param original_name: Preferred filename from attachment metadata.
|
||||
Falls back to the URL path basename if empty.
|
||||
"""
|
||||
from tools.url_safety import is_safe_url
|
||||
|
||||
if not is_safe_url(url):
|
||||
@@ -1668,7 +1789,11 @@ class QQAdapter(BasePlatformAdapter):
|
||||
# Convert to .wav using ffmpeg so STT engines can process it.
|
||||
return await self._convert_audio_to_wav(data, url)
|
||||
else:
|
||||
filename = Path(urlparse(url).path).name or "qq_attachment"
|
||||
filename = (
|
||||
original_name
|
||||
or Path(urlparse(url).path).name
|
||||
or "qq_attachment"
|
||||
)
|
||||
return cache_document_from_bytes(data, filename)
|
||||
|
||||
@staticmethod
|
||||
@@ -1881,7 +2006,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
@staticmethod
|
||||
def _guess_ext_from_data(data: bytes) -> str:
|
||||
"""Guess file extension from magic bytes."""
|
||||
if data[:9] == b"#!SILK_V3" or data[:5] == b"#!SILK":
|
||||
if data[:9] == b"#!SILK_V3" or data[:6] == b"#!SILK":
|
||||
return ".silk"
|
||||
if data[:2] == b"\x02!":
|
||||
return ".silk"
|
||||
@@ -1901,7 +2026,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
@staticmethod
|
||||
def _looks_like_silk(data: bytes) -> bool:
|
||||
"""Check if bytes look like a SILK audio file."""
|
||||
return data[:4] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3"
|
||||
return data[:6] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3"
|
||||
|
||||
async def _convert_silk_to_wav(self, src_path: str, wav_path: str) -> Optional[str]:
|
||||
"""Convert audio file to WAV using the pilk library.
|
||||
|
||||
@@ -37,7 +37,7 @@ import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
@@ -498,19 +498,9 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if not data_message:
|
||||
return
|
||||
|
||||
# Check for group message.
|
||||
# Modern Signal groups surface on dataMessage.groupV2.id; legacy V1
|
||||
# groups still arrive under dataMessage.groupInfo.groupId. signal-cli
|
||||
# versions differ in which field they expose for V2 groups — some
|
||||
# forward the underlying libsignal envelope verbatim (groupV2), others
|
||||
# normalize everything into groupInfo. Read groupV2 first and fall
|
||||
# back to groupInfo so V2-only groups aren't misrouted as DMs.
|
||||
# Check for group message
|
||||
group_info = data_message.get("groupInfo")
|
||||
group_v2 = data_message.get("groupV2")
|
||||
group_id = (
|
||||
(group_v2.get("id") if isinstance(group_v2, dict) else None)
|
||||
or (group_info.get("groupId") if isinstance(group_info, dict) else None)
|
||||
)
|
||||
group_id = group_info.get("groupId") if group_info else None
|
||||
is_group = bool(group_id)
|
||||
|
||||
# Group message filtering — derived from SIGNAL_GROUP_ALLOWED_USERS:
|
||||
@@ -597,7 +587,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
# Build session source
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
chat_name=(group_info.get("groupName") if isinstance(group_info, dict) else None) or sender_name,
|
||||
chat_name=group_info.get("groupName") if group_info else sender_name,
|
||||
chat_type=chat_type,
|
||||
user_id=sender,
|
||||
user_name=sender_name or sender,
|
||||
|
||||
+793
-182
File diff suppressed because it is too large
Load Diff
+642
-119
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ Security:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -308,11 +310,37 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
data = json.loads(subs_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
# Merge: static routes take precedence over dynamic ones
|
||||
self._dynamic_routes = {
|
||||
k: v for k, v in data.items()
|
||||
if k not in self._static_routes
|
||||
}
|
||||
# Merge: static routes take precedence over dynamic ones.
|
||||
# Reject any dynamic route whose effective secret is empty —
|
||||
# an empty secret would cause _handle_webhook to skip HMAC
|
||||
# validation entirely, letting unauthenticated callers in.
|
||||
new_dynamic: Dict[str, dict] = {}
|
||||
for k, v in data.items():
|
||||
if k in self._static_routes:
|
||||
continue
|
||||
effective_secret = v.get("secret", self._global_secret)
|
||||
if not effective_secret:
|
||||
logger.warning(
|
||||
"[webhook] Dynamic route '%s' skipped: 'secret' is "
|
||||
"missing or empty. Set a valid HMAC secret, or use "
|
||||
"'%s' to explicitly disable auth (testing only).",
|
||||
k,
|
||||
_INSECURE_NO_AUTH,
|
||||
)
|
||||
continue
|
||||
if (
|
||||
effective_secret == _INSECURE_NO_AUTH
|
||||
and not _is_loopback_host(self._host)
|
||||
):
|
||||
logger.warning(
|
||||
"[webhook] Dynamic route '%s' skipped: INSECURE_NO_AUTH "
|
||||
"is only allowed on loopback hosts. Current host: '%s'.",
|
||||
k,
|
||||
self._host,
|
||||
)
|
||||
continue
|
||||
new_dynamic[k] = v
|
||||
self._dynamic_routes = new_dynamic
|
||||
self._routes = {**self._dynamic_routes, **self._static_routes}
|
||||
self._dynamic_routes_mtime = mtime
|
||||
logger.info(
|
||||
@@ -336,6 +364,15 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
{"error": f"Unknown route: {route_name}"}, status=404
|
||||
)
|
||||
|
||||
# Disabled routes are kept in the subscriptions file (so the dashboard
|
||||
# can re-enable them) but reject incoming events. Default-enabled:
|
||||
# only an explicit ``enabled: false`` turns a route off, matching the
|
||||
# mcp_servers ``enabled`` semantics.
|
||||
if route_config.get("enabled", True) is False:
|
||||
return web.json_response(
|
||||
{"error": f"Route disabled: {route_name}"}, status=403
|
||||
)
|
||||
|
||||
# ── Auth-before-body ─────────────────────────────────────
|
||||
# Check Content-Length before reading the full payload.
|
||||
content_length = request.content_length or 0
|
||||
@@ -351,9 +388,21 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
logger.error("[webhook] Failed to read body: %s", e)
|
||||
return web.json_response({"error": "Bad request"}, status=400)
|
||||
|
||||
# Validate HMAC signature FIRST (skip for INSECURE_NO_AUTH testing mode)
|
||||
# Validate HMAC signature FIRST (skip only for the explicit local-test
|
||||
# INSECURE_NO_AUTH mode). Missing/empty secrets must fail closed here,
|
||||
# not only during connect(), so direct handler reuse cannot turn a
|
||||
# network webhook route into an unauthenticated agent-dispatch surface.
|
||||
secret = route_config.get("secret", self._global_secret)
|
||||
if secret and secret != _INSECURE_NO_AUTH:
|
||||
if not secret:
|
||||
logger.error(
|
||||
"[webhook] Route %s has no HMAC secret; refusing request",
|
||||
route_name,
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": "Webhook route is missing an HMAC secret"},
|
||||
status=403,
|
||||
)
|
||||
if secret != _INSECURE_NO_AUTH:
|
||||
if not self._validate_signature(request, raw_body, secret):
|
||||
logger.warning(
|
||||
"[webhook] Invalid signature for route %s", route_name
|
||||
@@ -393,6 +442,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
request.headers.get("X-GitHub-Event", "")
|
||||
or request.headers.get("X-GitLab-Event", "")
|
||||
or payload.get("event_type", "")
|
||||
or payload.get("type", "")
|
||||
or "unknown"
|
||||
)
|
||||
allowed_events = route_config.get("events", [])
|
||||
@@ -445,7 +495,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
# Build a unique delivery ID
|
||||
delivery_id = request.headers.get(
|
||||
"X-GitHub-Delivery",
|
||||
request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
|
||||
request.headers.get(
|
||||
"svix-id",
|
||||
request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
|
||||
),
|
||||
)
|
||||
|
||||
# ── Idempotency ─────────────────────────────────────────
|
||||
@@ -590,7 +643,32 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
def _validate_signature(
|
||||
self, request: "web.Request", body: bytes, secret: str
|
||||
) -> bool:
|
||||
"""Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256)."""
|
||||
"""Validate webhook signature (GitHub, GitLab, Svix, generic HMAC-SHA256)."""
|
||||
def _header(name: str) -> str:
|
||||
return (
|
||||
request.headers.get(name, "")
|
||||
or request.headers.get(name.lower(), "")
|
||||
or request.headers.get(name.upper(), "")
|
||||
)
|
||||
|
||||
# Svix / AgentMail:
|
||||
# svix-id: msg_...
|
||||
# svix-timestamp: unix seconds
|
||||
# svix-signature: v1,<base64-hmac> [v1,<base64-hmac> ...]
|
||||
# Signed content is: "{id}.{timestamp}.{raw_body}". Svix secrets
|
||||
# usually start with "whsec_" and the remainder is base64-encoded.
|
||||
svix_id = _header("svix-id")
|
||||
svix_timestamp = _header("svix-timestamp")
|
||||
svix_signature = _header("svix-signature")
|
||||
if svix_id or svix_timestamp or svix_signature:
|
||||
return self._validate_svix_signature(
|
||||
body=body,
|
||||
secret=secret,
|
||||
msg_id=svix_id,
|
||||
timestamp=svix_timestamp,
|
||||
signature_header=svix_signature,
|
||||
)
|
||||
|
||||
# GitHub: X-Hub-Signature-256 = sha256=<hex>
|
||||
gh_sig = request.headers.get("X-Hub-Signature-256", "")
|
||||
if gh_sig:
|
||||
@@ -618,6 +696,56 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return False
|
||||
|
||||
def _validate_svix_signature(
|
||||
self,
|
||||
body: bytes,
|
||||
secret: str,
|
||||
msg_id: str,
|
||||
timestamp: str,
|
||||
signature_header: str,
|
||||
tolerance_seconds: int = 300,
|
||||
) -> bool:
|
||||
"""Validate Svix-compatible signatures used by AgentMail webhooks."""
|
||||
if not (msg_id and timestamp and signature_header and secret):
|
||||
return False
|
||||
|
||||
try:
|
||||
ts = int(timestamp)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if abs(int(time.time()) - ts) > tolerance_seconds:
|
||||
logger.warning("[webhook] Svix signature timestamp outside replay window")
|
||||
return False
|
||||
|
||||
if secret.startswith("whsec_"):
|
||||
encoded_secret = secret.removeprefix("whsec_")
|
||||
try:
|
||||
key = base64.b64decode(encoded_secret, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
logger.debug("[webhook] Invalid whsec_ Svix signing secret")
|
||||
return False
|
||||
else:
|
||||
# Be permissive for providers that document Svix-style headers but
|
||||
# hand out raw shared secrets rather than whsec_ base64 secrets.
|
||||
logger.debug("[webhook] Validating Svix-style signature with raw secret")
|
||||
key = secret.encode()
|
||||
|
||||
signed_content = msg_id.encode() + b"." + timestamp.encode() + b"." + body
|
||||
expected = base64.b64encode(
|
||||
hmac.new(key, signed_content, hashlib.sha256).digest()
|
||||
).decode()
|
||||
|
||||
# Svix can send multiple signatures separated by spaces during secret
|
||||
# rotation. Each entry is formatted as "vN,<base64>".
|
||||
for part in signature_header.split():
|
||||
try:
|
||||
version, signature = part.split(",", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
if version == "v1" and hmac.compare_digest(signature, expected):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Prompt rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -161,7 +161,15 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
).strip() or DEFAULT_WS_URL
|
||||
|
||||
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "open")).strip().lower()
|
||||
self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom"))
|
||||
# dm_policy already honors WECOM_DM_POLICY, so the allowlist must honor
|
||||
# WECOM_ALLOWED_USERS too. Without the env fallback an env-only setup
|
||||
# (dm_policy=allowlist via env, no config extra) runs with an empty
|
||||
# allowlist and drops every authorized DM at intake.
|
||||
self._allow_from = _coerce_list(
|
||||
extra.get("allow_from")
|
||||
or extra.get("allowFrom")
|
||||
or os.getenv("WECOM_ALLOWED_USERS", "")
|
||||
)
|
||||
|
||||
self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "open")).strip().lower()
|
||||
self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
|
||||
@@ -616,6 +624,18 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
# Guard against the cancel-delivery race: when the sleep timer
|
||||
# fires just before cancel() is called, CPython sets
|
||||
# Task._must_cancel but cannot cancel the already-done sleep
|
||||
# future, so CancelledError is delivered at the *next* await
|
||||
# (handle_message) rather than here. By that point this task
|
||||
# has already popped the merged event, so the superseding task
|
||||
# sees an empty batch and silently drops the message.
|
||||
# This check is synchronous — no await between the sleep and
|
||||
# the pop — so no other coroutine can modify the task registry
|
||||
# in between.
|
||||
if self._pending_text_batch_tasks.get(key) is not current_task:
|
||||
return
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
@@ -835,6 +855,11 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
# Policy helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""WeCom gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
if self._dm_policy == "disabled":
|
||||
return False
|
||||
|
||||
@@ -17,7 +17,17 @@ import logging
|
||||
import socket as _socket
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
# Security: parse untrusted, pre-auth request bodies (WeCom callbacks) with
|
||||
# defusedxml to block billion-laughs / entity-expansion (and XXE) DoS. The
|
||||
# parsing API (fromstring) is a drop-in for the stdlib calls used below;
|
||||
# response-building XML lives in wecom_crypto.py and is not parsed here.
|
||||
try:
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
DEFUSEDXML_AVAILABLE = True
|
||||
except ImportError:
|
||||
ET = None # type: ignore[assignment]
|
||||
DEFUSEDXML_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
@@ -49,7 +59,7 @@ MESSAGE_DEDUP_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def check_wecom_callback_requirements() -> bool:
|
||||
return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE
|
||||
return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE and DEFUSEDXML_AVAILABLE
|
||||
|
||||
|
||||
class WecomCallbackAdapter(BasePlatformAdapter):
|
||||
@@ -187,7 +197,6 @@ class WecomCallbackAdapter(BasePlatformAdapter):
|
||||
app = self._resolve_app_for_chat(chat_id)
|
||||
touser = chat_id.split(":", 1)[1] if ":" in chat_id else chat_id
|
||||
try:
|
||||
token = await self._get_access_token(app)
|
||||
payload = {
|
||||
"touser": touser,
|
||||
"msgtype": "text",
|
||||
@@ -195,18 +204,31 @@ class WecomCallbackAdapter(BasePlatformAdapter):
|
||||
"text": {"content": content[:2048]},
|
||||
"safe": 0,
|
||||
}
|
||||
resp = await self._http_client.post(
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}",
|
||||
json=payload,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errcode") != 0:
|
||||
return SendResult(success=False, error=str(data))
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=str(data.get("msgid", "")),
|
||||
raw_response=data,
|
||||
)
|
||||
for _attempt in range(2):
|
||||
token = await self._get_access_token(app)
|
||||
resp = await self._http_client.post(
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}",
|
||||
json=payload,
|
||||
)
|
||||
data = resp.json()
|
||||
errcode = data.get("errcode")
|
||||
if errcode in {40001, 42001} and _attempt == 0:
|
||||
# WeCom rejected the token — evict the cached entry so
|
||||
# the next _get_access_token call forces a fresh fetch.
|
||||
logger.warning(
|
||||
"[WecomCallback] Token rejected for app '%s' (errcode=%s), refreshing",
|
||||
app.get("name", "default"), errcode,
|
||||
)
|
||||
self._access_tokens.pop(app["name"], None)
|
||||
continue
|
||||
if errcode != 0:
|
||||
return SendResult(success=False, error=str(data))
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=str(data.get("msgid", "")),
|
||||
raw_response=data,
|
||||
)
|
||||
return SendResult(success=False, error="send failed after token refresh")
|
||||
except Exception as exc:
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
|
||||
+250
-61
@@ -378,12 +378,16 @@ async def _api_post(
|
||||
) -> Dict[str, Any]:
|
||||
body = _json_dumps({**payload, "base_info": _base_info()})
|
||||
url = f"{base_url.rstrip('/')}/{endpoint}"
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
|
||||
async with session.post(url, data=body, headers=_headers(token, body), timeout=timeout) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
|
||||
# "Timeout context manager should be used inside a task" errors when
|
||||
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
|
||||
async def _do() -> Dict[str, Any]:
|
||||
async with session.post(url, data=body, headers=_headers(token, body)) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
|
||||
|
||||
|
||||
async def _api_get(
|
||||
@@ -398,12 +402,16 @@ async def _api_get(
|
||||
"iLink-App-Id": ILINK_APP_ID,
|
||||
"iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
|
||||
async with session.get(url, headers=headers, timeout=timeout) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
|
||||
# "Timeout context manager should be used inside a task" errors when
|
||||
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
|
||||
async def _do() -> Dict[str, Any]:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
|
||||
|
||||
|
||||
async def _get_updates(
|
||||
@@ -658,52 +666,6 @@ def _split_table_row(line: str) -> List[str]:
|
||||
return [cell.strip() for cell in row.split("|")]
|
||||
|
||||
|
||||
def _rewrite_headers_for_weixin(line: str) -> str:
|
||||
match = _HEADER_RE.match(line)
|
||||
if not match:
|
||||
return line.rstrip()
|
||||
level = len(match.group(1))
|
||||
title = match.group(2).strip()
|
||||
if level == 1:
|
||||
return f"【{title}】"
|
||||
return f"**{title}**"
|
||||
|
||||
|
||||
def _rewrite_table_block_for_weixin(lines: List[str]) -> str:
|
||||
if len(lines) < 2:
|
||||
return "\n".join(lines)
|
||||
headers = _split_table_row(lines[0])
|
||||
body_rows = [_split_table_row(line) for line in lines[2:] if line.strip()]
|
||||
if not headers or not body_rows:
|
||||
return "\n".join(lines)
|
||||
|
||||
formatted_rows: List[str] = []
|
||||
for row in body_rows:
|
||||
pairs = []
|
||||
for idx, header in enumerate(headers):
|
||||
if idx >= len(row):
|
||||
break
|
||||
label = header or f"Column {idx + 1}"
|
||||
value = row[idx].strip()
|
||||
if value:
|
||||
pairs.append((label, value))
|
||||
if not pairs:
|
||||
continue
|
||||
if len(pairs) == 1:
|
||||
label, value = pairs[0]
|
||||
formatted_rows.append(f"- {label}: {value}")
|
||||
continue
|
||||
if len(pairs) == 2:
|
||||
label, value = pairs[0]
|
||||
other_label, other_value = pairs[1]
|
||||
formatted_rows.append(f"- {label}: {value}")
|
||||
formatted_rows.append(f" {other_label}: {other_value}")
|
||||
continue
|
||||
summary = " | ".join(f"{label}: {value}" for label, value in pairs)
|
||||
formatted_rows.append(f"- {summary}")
|
||||
return "\n".join(formatted_rows) if formatted_rows else "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize_markdown_blocks(content: str) -> str:
|
||||
lines = content.splitlines()
|
||||
result: List[str] = []
|
||||
@@ -1176,6 +1138,8 @@ async def qr_login(
|
||||
class WeixinAdapter(BasePlatformAdapter):
|
||||
"""Native Hermes adapter for Weixin personal accounts."""
|
||||
|
||||
supports_code_blocks = True # Weixin renders fenced code blocks
|
||||
|
||||
MAX_MESSAGE_LENGTH = 2000
|
||||
|
||||
# WeChat does not support editing sent messages — streaming must use the
|
||||
@@ -1210,6 +1174,24 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
extra.get("send_chunk_retry_delay_seconds")
|
||||
or os.getenv("WEIXIN_SEND_CHUNK_RETRY_DELAY_SECONDS", "1.0")
|
||||
)
|
||||
self._send_text_gate = asyncio.Lock()
|
||||
self._rate_limit_circuit_threshold = max(
|
||||
1,
|
||||
int(
|
||||
extra.get("rate_limit_circuit_threshold")
|
||||
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_THRESHOLD", "1")
|
||||
),
|
||||
)
|
||||
self._rate_limit_circuit_window_seconds = float(
|
||||
extra.get("rate_limit_circuit_window_seconds")
|
||||
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_WINDOW_SECONDS", "30.0")
|
||||
)
|
||||
self._rate_limit_circuit_open_seconds = float(
|
||||
extra.get("rate_limit_circuit_open_seconds")
|
||||
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_OPEN_SECONDS", "30.0")
|
||||
)
|
||||
self._rate_limit_circuit_until = 0.0
|
||||
self._rate_limit_events: List[float] = []
|
||||
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WEIXIN_DM_POLICY", "open")).strip().lower()
|
||||
self._group_policy = str(extra.get("group_policy") or os.getenv("WEIXIN_GROUP_POLICY", "disabled")).strip().lower()
|
||||
allow_from = extra.get("allow_from")
|
||||
@@ -1226,12 +1208,48 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Text debounce batching (mirrors Telegram adapter pattern).
|
||||
# iLink delivers messages individually, so rapid multi-message
|
||||
# bursts (forwarded batches, paste-splits) each trigger a
|
||||
# separate agent invocation. Default 3s delay / 5s split delay
|
||||
# are tuned for iLink's typical delivery cadence. Tunable via
|
||||
# config.yaml under
|
||||
# ``gateway.platforms.weixin.extra.text_batch_delay_seconds`` /
|
||||
# ``text_batch_split_delay_seconds``.
|
||||
self._text_batch_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_delay_seconds", 3.0
|
||||
)
|
||||
self._text_batch_split_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_split_delay_seconds", 5.0
|
||||
)
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
if self._account_id and not self._token:
|
||||
persisted = load_weixin_account(hermes_home, self._account_id)
|
||||
if persisted:
|
||||
self._token = str(persisted.get("token") or "").strip()
|
||||
self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/")
|
||||
|
||||
def _coerce_float_extra(self, key: str, default: float) -> float:
|
||||
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
|
||||
|
||||
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
|
||||
unparseable values fall back to ``default``.
|
||||
"""
|
||||
import math
|
||||
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
return float(default)
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not math.isfinite(parsed) or parsed < 0:
|
||||
return float(default)
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _coerce_list(value: Any) -> List[str]:
|
||||
if value is None:
|
||||
@@ -1293,6 +1311,11 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
async def disconnect(self) -> None:
|
||||
_LIVE_ADAPTERS.pop(self._token, None)
|
||||
self._running = False
|
||||
for task in self._pending_text_batch_tasks.values():
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
self._pending_text_batches.clear()
|
||||
self._pending_text_batch_tasks.clear()
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
@@ -1441,7 +1464,10 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
|
||||
await self.handle_message(event)
|
||||
if event.message_type == MessageType.TEXT:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
if self._dm_policy == "disabled":
|
||||
@@ -1450,6 +1476,76 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
return sender_id in self._allow_from
|
||||
return True
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text debounce batching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When users forward multiple messages or send rapid-fire texts
|
||||
via WeChat, each arrives as a separate iLink message. This
|
||||
concatenates them and waits for a short quiet period before
|
||||
dispatching the combined message.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for quiet period then dispatch aggregated text."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
if self._pending_text_batch_tasks.get(key) is not current_task:
|
||||
return
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None:
|
||||
item_type = item.get("type")
|
||||
if item_type == ITEM_IMAGE:
|
||||
@@ -1569,6 +1665,37 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages,
|
||||
)
|
||||
|
||||
def _rate_limit_cooldown_remaining(self) -> float:
|
||||
return max(0.0, self._rate_limit_circuit_until - time.monotonic())
|
||||
|
||||
def _rate_limit_error(self) -> RuntimeError:
|
||||
return RuntimeError(
|
||||
f"iLink sendmessage rate limited; cooldown active for {self._rate_limit_cooldown_remaining():.1f}s"
|
||||
)
|
||||
|
||||
def _open_rate_limit_circuit(self) -> None:
|
||||
if self._rate_limit_circuit_open_seconds <= 0:
|
||||
return
|
||||
self._rate_limit_circuit_until = max(
|
||||
self._rate_limit_circuit_until,
|
||||
time.monotonic() + self._rate_limit_circuit_open_seconds,
|
||||
)
|
||||
|
||||
def _record_rate_limit_event(self) -> bool:
|
||||
"""Record a genuine iLink rate limit and return True if breaker opened."""
|
||||
now = time.monotonic()
|
||||
window_start = now - self._rate_limit_circuit_window_seconds
|
||||
self._rate_limit_events = [ts for ts in self._rate_limit_events if ts >= window_start]
|
||||
self._rate_limit_events.append(now)
|
||||
if len(self._rate_limit_events) >= self._rate_limit_circuit_threshold:
|
||||
self._open_rate_limit_circuit()
|
||||
return self._rate_limit_cooldown_remaining() > 0
|
||||
return False
|
||||
|
||||
def _reset_rate_limit_circuit(self) -> None:
|
||||
self._rate_limit_events.clear()
|
||||
self._rate_limit_circuit_until = 0.0
|
||||
|
||||
async def _send_text_chunk(
|
||||
self,
|
||||
*,
|
||||
@@ -1584,9 +1711,28 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
degraded fallback, which keeps cron-initiated push messages working
|
||||
even when no user message has refreshed the session recently.
|
||||
"""
|
||||
async with self._send_text_gate:
|
||||
await self._send_text_chunk_locked(
|
||||
chat_id=chat_id,
|
||||
chunk=chunk,
|
||||
context_token=context_token,
|
||||
client_id=client_id,
|
||||
)
|
||||
|
||||
async def _send_text_chunk_locked(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
chunk: str,
|
||||
context_token: Optional[str],
|
||||
client_id: str,
|
||||
) -> None:
|
||||
"""Send a text chunk while holding the adapter-wide outbound text gate."""
|
||||
last_error: Optional[Exception] = None
|
||||
retried_without_token = False
|
||||
for attempt in range(self._send_chunk_retries + 1):
|
||||
if self._rate_limit_cooldown_remaining() > 0:
|
||||
raise self._rate_limit_error()
|
||||
try:
|
||||
resp = await _send_message(
|
||||
self._send_session,
|
||||
@@ -1632,6 +1778,9 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
last_error = RuntimeError(
|
||||
f"iLink sendmessage rate limited: ret={ret} errcode={errcode} errmsg={errmsg}"
|
||||
)
|
||||
if self._record_rate_limit_event():
|
||||
last_error = self._rate_limit_error()
|
||||
break
|
||||
if attempt >= self._send_chunk_retries:
|
||||
break
|
||||
wait = self._send_chunk_retry_delay_seconds * 3 # 3x backoff for rate limit
|
||||
@@ -1645,6 +1794,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
raise RuntimeError(
|
||||
f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg}"
|
||||
)
|
||||
self._reset_rate_limit_circuit()
|
||||
return
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
@@ -1679,8 +1829,10 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
|
||||
# Extract MEDIA: tags and bare local file paths before text delivery.
|
||||
media_files, cleaned_content = self.extract_media(content)
|
||||
media_files = self.filter_media_delivery_paths(media_files)
|
||||
_, image_cleaned = self.extract_images(cleaned_content)
|
||||
local_files, final_content = self.extract_local_files(image_cleaned)
|
||||
local_files = self.filter_local_delivery_paths(local_files)
|
||||
|
||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||||
@@ -1730,10 +1882,47 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
logger.error("[%s] send failed to=%s: %s", self.name, _safe_id(chat_id), exc)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def _ensure_typing_ticket(self, chat_id: str) -> Optional[str]:
|
||||
"""Return a valid typing ticket, refreshing from getConfig if expired.
|
||||
|
||||
The iLink typing ticket has a 600-second TTL. When a long-running
|
||||
session exceeds that window the cached ticket evicts, and both
|
||||
``send_typing`` and ``stop_typing`` silently no-op — leaving the
|
||||
WeChat client stuck showing the typing indicator forever. This
|
||||
method transparently refreshes the ticket so the stop signal can
|
||||
always be delivered.
|
||||
"""
|
||||
ticket = self._typing_cache.get(chat_id)
|
||||
if ticket:
|
||||
return ticket
|
||||
if not self._send_session or not self._token:
|
||||
return None
|
||||
# Ticket expired or never fetched — refresh via getConfig.
|
||||
# Use the most recent context_token for this peer if available.
|
||||
context_token = self._token_store.get(self._account_id, chat_id)
|
||||
try:
|
||||
response = await _get_config(
|
||||
self._send_session,
|
||||
base_url=self._base_url,
|
||||
token=self._token,
|
||||
user_id=chat_id,
|
||||
context_token=context_token,
|
||||
)
|
||||
typing_ticket = str(response.get("typing_ticket") or "")
|
||||
if typing_ticket:
|
||||
self._typing_cache.set(chat_id, typing_ticket)
|
||||
return typing_ticket
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"[%s] typing ticket refresh failed for %s: %s",
|
||||
self.name, _safe_id(chat_id), exc,
|
||||
)
|
||||
return None
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
if not self._send_session or not self._token:
|
||||
return
|
||||
typing_ticket = self._typing_cache.get(chat_id)
|
||||
typing_ticket = await self._ensure_typing_ticket(chat_id)
|
||||
if not typing_ticket:
|
||||
return
|
||||
try:
|
||||
@@ -1751,7 +1940,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
async def stop_typing(self, chat_id: str) -> None:
|
||||
if not self._send_session or not self._token:
|
||||
return
|
||||
typing_ticket = self._typing_cache.get(chat_id)
|
||||
typing_ticket = await self._ensure_typing_ticket(chat_id)
|
||||
if not typing_ticket:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -276,6 +276,43 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
# notification before the normal "✓ whatsapp disconnected" fires.
|
||||
self._shutting_down: bool = False
|
||||
|
||||
# Text debounce batching (mirrors Telegram adapter pattern).
|
||||
# WhatsApp often delivers multiple messages in rapid succession
|
||||
# (e.g. forwarded batches, paste-splits) — without debounce each
|
||||
# message triggers a separate agent invocation, wasting tokens and
|
||||
# flooding the user with reply fragments. Default 5s delay /
|
||||
# 10s split delay are conservative for WhatsApp's delivery cadence.
|
||||
# Tunable via config.yaml under
|
||||
# ``gateway.platforms.whatsapp.extra.text_batch_delay_seconds`` /
|
||||
# ``text_batch_split_delay_seconds``.
|
||||
self._text_batch_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_delay_seconds", 5.0
|
||||
)
|
||||
self._text_batch_split_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_split_delay_seconds", 10.0
|
||||
)
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
def _coerce_float_extra(self, key: str, default: float) -> float:
|
||||
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
|
||||
|
||||
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
|
||||
unparseable values fall back to ``default``.
|
||||
"""
|
||||
import math
|
||||
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
return float(default)
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not math.isfinite(parsed) or parsed < 0:
|
||||
return float(default)
|
||||
return parsed
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""
|
||||
Start the WhatsApp bridge.
|
||||
@@ -873,7 +910,10 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
for msg_data in messages:
|
||||
event = await self._build_message_event(msg_data)
|
||||
if event:
|
||||
await self.handle_message(event)
|
||||
if event.message_type == MessageType.TEXT:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -885,7 +925,67 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
await asyncio.sleep(1) # Poll interval
|
||||
|
||||
|
||||
# ── Text debounce batching ──────────────────────────────────────
|
||||
|
||||
_SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K chars; generous threshold
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When WhatsApp delivers rapid-fire messages (e.g. forwarded
|
||||
batches), this concatenates them and waits for a short quiet
|
||||
period before dispatching the combined message.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for quiet period then dispatch aggregated text."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
|
||||
"""Build a MessageEvent from bridge message data, downloading images to cache."""
|
||||
try:
|
||||
|
||||
@@ -52,9 +52,15 @@ class WhatsAppBehaviorMixin:
|
||||
# WhatsApp message limits — practical UX limit, not protocol max.
|
||||
# WhatsApp allows ~65K but long messages are unreadable on mobile.
|
||||
MAX_MESSAGE_LENGTH: int = 4096
|
||||
supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
|
||||
|
||||
DEFAULT_REPLY_PREFIX: str = "⚕ *Hermes Agent*\n────────────\n"
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ config
|
||||
def _effective_reply_prefix(self) -> str:
|
||||
"""Return the prefix to add to outgoing replies in self-chat mode.
|
||||
|
||||
+359
-176
@@ -120,6 +120,16 @@ AUTH_TIMEOUT_SECONDS = 10.0
|
||||
MAX_RECONNECT_ATTEMPTS = 100
|
||||
DEFAULT_SEND_TIMEOUT = 30.0 # WS biz request timeout
|
||||
|
||||
# Upper bound on the WS close handshake during teardown (#40383). The
|
||||
# websockets connection's own close_timeout (5s) blocks until the server
|
||||
# echoes the close frame; an idle/unresponsive server never replies, stalling
|
||||
# gateway shutdown by the full timeout. Bounding the close await here keeps
|
||||
# teardown fast — a responsive server completes the handshake in well under a
|
||||
# second, so this only caps the pathological hang. Also bounds the reconnect /
|
||||
# connect-failure cleanup paths that reuse _cleanup_ws(), where a graceful
|
||||
# close is unnecessary anyway (the socket is being discarded to redial).
|
||||
WS_CLOSE_TIMEOUT_S = 1.0
|
||||
|
||||
# Close codes that indicate permanent errors — do NOT reconnect.
|
||||
NO_RECONNECT_CLOSE_CODES = {4012, 4013, 4014, 4018, 4019, 4021}
|
||||
|
||||
@@ -147,6 +157,12 @@ _YB_RES_REF_RE = re.compile(
|
||||
r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]"
|
||||
)
|
||||
|
||||
# Patched local-media anchors once an inbound resource has been downloaded to the local cache.
|
||||
# [image: /opt/data/image_cache/img_xxx.bmp]
|
||||
# [file: report.pdf → /opt/data/.../report.pdf]
|
||||
# (and any future kind, e.g. [video: /opt/.../clip.mp4])
|
||||
_YB_LOCAL_MEDIA_RE = re.compile(r"\[(\w+):[^\]]*?(/[^\]]+?)\s*\]")
|
||||
|
||||
# Media kinds that can be resolved and injected into the model context
|
||||
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
|
||||
|
||||
@@ -930,7 +946,11 @@ class InboundContext:
|
||||
reply_to_text: Optional[str] = None
|
||||
quote_media_refs: list = dc_field(default_factory=list) # List of (rid, kind, filename)
|
||||
|
||||
# Populated by MediaResolveMiddleware
|
||||
# Populated by MediaResolveMiddleware. Combined list of resolved local
|
||||
# paths from up to three sources (deduped, in this order):
|
||||
# 1) media carried by the current message (always),
|
||||
# 2) media from the quoted message (when reply_to_message_id is set),
|
||||
# 3) recent group-observed media (only when chat_type == "group" and no quote is present).
|
||||
media_urls: list = dc_field(default_factory=list)
|
||||
media_types: list = dc_field(default_factory=list)
|
||||
|
||||
@@ -1675,10 +1695,10 @@ class ExtractContentMiddleware(InboundMiddleware):
|
||||
"""Extract plain text content from MsgBody.
|
||||
|
||||
- TIMTextElem -> text field
|
||||
- TIMImageElem -> "[image]"
|
||||
- TIMFileElem -> "[file: {filename}]"
|
||||
- TIMSoundElem -> "[voice]"
|
||||
- TIMVideoFileElem -> "[video]"
|
||||
- TIMImageElem -> "[image]" / "[image|ybres:RID]"
|
||||
- TIMFileElem -> "[file: {filename}]" / "[file:{name}|ybres:RID]"
|
||||
- TIMSoundElem -> "[voice]" / "[voice|ybres:RID]"
|
||||
- TIMVideoFileElem -> "[video]" / "[video|ybres:RID]"
|
||||
- TIMFaceElem -> "[emoji: {name}]" or "[emoji]"
|
||||
- TIMCustomElem -> try to extract data field, otherwise "[custom message]"
|
||||
- Multiple elems joined with spaces
|
||||
@@ -2177,51 +2197,72 @@ class QuoteContextMiddleware(InboundMiddleware):
|
||||
|
||||
name = "quote-context"
|
||||
|
||||
@staticmethod
|
||||
def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str], list]:
|
||||
"""Extract quote context, mapping to MessageEvent.reply_to_*.
|
||||
|
||||
Returns:
|
||||
(reply_to_message_id, reply_to_text, quote_media_refs)
|
||||
where quote_media_refs is a list of (rid, kind, filename) tuples
|
||||
def _extract_quote_context(self, cloud_custom_data: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Extract quote text context, mapping to MessageEvent.reply_to_*.
|
||||
"""
|
||||
if not cloud_custom_data:
|
||||
return None, None, []
|
||||
return None, None
|
||||
try:
|
||||
parsed = json.loads(cloud_custom_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None, None, []
|
||||
return None, None
|
||||
|
||||
quote = parsed.get("quote") if isinstance(parsed, dict) else None
|
||||
if not isinstance(quote, dict):
|
||||
return None, None, []
|
||||
|
||||
# type=2 corresponds to image reference; desc may be empty, provide a placeholder.
|
||||
quote_type = int(quote.get("type") or 0)
|
||||
desc = str(quote.get("desc") or "").strip()
|
||||
if quote_type == 2 and not desc:
|
||||
desc = "[image]"
|
||||
if not desc:
|
||||
return None, None, []
|
||||
return None, None
|
||||
|
||||
quote_id = str(quote.get("id") or "").strip() or None
|
||||
desc = str(quote.get("desc") or "").strip()
|
||||
sender = str(quote.get("sender_nickname") or quote.get("sender_id") or "").strip()
|
||||
quote_text = f"{sender}: {desc}" if sender else desc
|
||||
quote_text = (f"{sender}: {desc}" if sender else desc) if desc else None
|
||||
|
||||
# Extract media references from desc using _YB_RES_REF_RE regex
|
||||
media_refs: list = []
|
||||
for m in _YB_RES_REF_RE.finditer(desc):
|
||||
head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
media_refs.append((rid, kind, filename.strip()))
|
||||
return quote_id, quote_text
|
||||
|
||||
return quote_id, quote_text, media_refs
|
||||
async def _extract_media_refs_from_transcript(
|
||||
self, ctx: InboundContext
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
"""Look up the quoted message in the transcript history and return any
|
||||
``[kind|ybres:RID]`` anchors found in its content as
|
||||
``(rid, kind, filename)`` tuples.
|
||||
|
||||
Returns ``[]`` when ``ctx.reply_to_message_id`` is unset, when the
|
||||
transcript store / source is unavailable, or when the quoted message
|
||||
carries no resolvable media anchors.
|
||||
"""
|
||||
if ctx.reply_to_message_id is None:
|
||||
return []
|
||||
adapter = ctx.adapter
|
||||
media_refs: List[Tuple[str, str, str]] = []
|
||||
try:
|
||||
store = getattr(adapter, "_session_store", None)
|
||||
if not store or ctx.source is None:
|
||||
return []
|
||||
session_entry = store.get_or_create_session(ctx.source)
|
||||
history = store.load_transcript(session_entry.session_id)
|
||||
for msg in reversed(history or []):
|
||||
mid = msg.get("message_id", "")
|
||||
if not mid or mid != ctx.reply_to_message_id:
|
||||
continue
|
||||
_content = msg.get("content", "")
|
||||
if isinstance(_content, str) and "|ybres:" in _content:
|
||||
for m in _YB_RES_REF_RE.finditer(_content):
|
||||
head = m.group(1)
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind in _RESOLVABLE_MEDIA_KINDS:
|
||||
media_refs.append((rid, kind, filename.strip()))
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] quote transcript lookup failed: %s",
|
||||
getattr(adapter, "name", "yuanbao"), exc,
|
||||
)
|
||||
return media_refs
|
||||
|
||||
async def handle(self, ctx: InboundContext, next_fn) -> None:
|
||||
ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data)
|
||||
|
||||
ctx.reply_to_message_id, ctx.reply_to_text = self._extract_quote_context(ctx.cloud_custom_data)
|
||||
ctx.quote_media_refs = await self._extract_media_refs_from_transcript(ctx)
|
||||
await next_fn()
|
||||
|
||||
|
||||
@@ -2230,6 +2271,45 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
|
||||
name = "media-resolve"
|
||||
|
||||
# --- Resource download cache (keyed by resourceId) ---
|
||||
# Avoids redundant downloads of the same resource within the TTL window.
|
||||
# The same resourceId can be referenced multiple times in a session (own
|
||||
# attachment, then quoted again, then observed in a group backfill); each
|
||||
# reference otherwise triggers a fresh token exchange + download.
|
||||
_resource_cache: ClassVar[Dict[str, Tuple[str, str, float]]] = {} # rid -> (local_path, mime, ts)
|
||||
_RESOURCE_CACHE_TTL_S: ClassVar[int] = 24 * 60 * 60 # 24 hours
|
||||
_RESOURCE_CACHE_MAX_SIZE: ClassVar[int] = 256
|
||||
|
||||
@classmethod
|
||||
def _get_cached_resource(cls, resource_id: str) -> Optional[Tuple[str, str]]:
|
||||
"""Return cached ``(local_path, mime)`` if still valid and file exists, else None."""
|
||||
if not resource_id:
|
||||
return None
|
||||
entry = cls._resource_cache.get(resource_id)
|
||||
if entry is None:
|
||||
return None
|
||||
local_path, mime, ts = entry
|
||||
if time.time() - ts > cls._RESOURCE_CACHE_TTL_S:
|
||||
cls._resource_cache.pop(resource_id, None)
|
||||
return None
|
||||
# Verify the cached file still exists on disk (cache dir may be swept).
|
||||
if not os.path.isfile(local_path):
|
||||
cls._resource_cache.pop(resource_id, None)
|
||||
return None
|
||||
return local_path, mime
|
||||
|
||||
@classmethod
|
||||
def _put_cached_resource(cls, resource_id: str, local_path: str, mime: str) -> None:
|
||||
"""Store download result in cache. Evicts oldest entries when over capacity."""
|
||||
if not resource_id:
|
||||
return
|
||||
if len(cls._resource_cache) >= cls._RESOURCE_CACHE_MAX_SIZE:
|
||||
# Drop the oldest 25% of entries by timestamp.
|
||||
sorted_keys = sorted(cls._resource_cache, key=lambda k: cls._resource_cache[k][2])
|
||||
for k in sorted_keys[: cls._RESOURCE_CACHE_MAX_SIZE // 4]:
|
||||
cls._resource_cache.pop(k, None)
|
||||
cls._resource_cache[resource_id] = (local_path, mime, time.time())
|
||||
|
||||
@staticmethod
|
||||
def _guess_image_ext_from_url(url: str) -> str:
|
||||
"""Guess image extension from URL path."""
|
||||
@@ -2327,8 +2407,23 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
async def _download_and_cache(
|
||||
cls, adapter, *, fetch_url: str, kind: str,
|
||||
file_name: Optional[str] = None, log_tag: str = "",
|
||||
resource_id: str = "",
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Download a Yuanbao resource and cache locally. Returns ``(local_path, mime)`` or ``None``."""
|
||||
"""Download a Yuanbao resource and cache locally. Returns ``(local_path, mime)`` or ``None``.
|
||||
|
||||
When *resource_id* is provided, an in-memory cache keyed by resourceId
|
||||
is consulted first to skip redundant downloads of the same resource
|
||||
within the TTL window.
|
||||
"""
|
||||
if resource_id:
|
||||
hit = cls._get_cached_resource(resource_id)
|
||||
if hit is not None:
|
||||
logger.debug(
|
||||
"[%s] resource cache hit: rid=%s path=%s",
|
||||
adapter.name, resource_id, hit[0],
|
||||
)
|
||||
return hit
|
||||
|
||||
try:
|
||||
file_bytes, content_type = await media_download_url(
|
||||
fetch_url, max_size_mb=adapter.MEDIA_MAX_SIZE_MB,
|
||||
@@ -2353,6 +2448,7 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
mime = guess_mime_type(f"image{ext}")
|
||||
if not mime.startswith("image/"):
|
||||
mime = content_type if content_type.startswith("image/") else "image/jpeg"
|
||||
cls._put_cached_resource(resource_id, local_path, mime)
|
||||
return local_path, mime
|
||||
|
||||
# kind == "file"
|
||||
@@ -2368,13 +2464,9 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
)
|
||||
return None
|
||||
mime = guess_mime_type(file_name) or content_type or "application/octet-stream"
|
||||
cls._put_cached_resource(resource_id, local_path, mime)
|
||||
return local_path, mime
|
||||
|
||||
@classmethod
|
||||
async def _resolve_by_resource_id(cls, adapter, resource_id: str) -> str:
|
||||
"""Exchange a Yuanbao ``resourceId`` for a short-lived direct download URL. Raises on failure."""
|
||||
return await cls._fetch_resource_url(adapter, resource_id)
|
||||
|
||||
@classmethod
|
||||
async def _resolve_media_urls(
|
||||
cls, adapter, media_refs: List[Dict[str, str]]
|
||||
@@ -2390,9 +2482,13 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
for ref in media_refs:
|
||||
kind = str(ref.get("kind") or "").strip().lower()
|
||||
url = str(ref.get("url") or "").strip()
|
||||
filename = str(ref.get("name") or "").strip()
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
|
||||
continue
|
||||
|
||||
# Extract resourceId from the placeholder URL for cache dedup.
|
||||
rid = ExtractContentMiddleware._parse_resource_id(url)
|
||||
|
||||
try:
|
||||
fetch_url = await cls._resolve_download_url(adapter, url)
|
||||
except Exception as exc:
|
||||
@@ -2406,8 +2502,9 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
adapter,
|
||||
fetch_url=fetch_url,
|
||||
kind=kind,
|
||||
file_name=str(ref.get("name") or "").strip() or None,
|
||||
file_name=filename or None,
|
||||
log_tag=f"placeholder_url={url[:80]}",
|
||||
resource_id=rid,
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
@@ -2417,6 +2514,44 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
|
||||
return media_urls, media_types
|
||||
|
||||
@classmethod
|
||||
async def _resolve_ybres_refs(
|
||||
cls,
|
||||
adapter,
|
||||
refs: List[Tuple[str, str, str]],
|
||||
*,
|
||||
log_prefix: str,
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Resolve a list of ``(rid, kind, filename)`` ybres tuples to local paths.
|
||||
"""
|
||||
media_paths: List[str] = []
|
||||
mimes: List[str] = []
|
||||
for rid, kind, filename in refs:
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS:
|
||||
continue
|
||||
try:
|
||||
fresh_url = await cls._fetch_resource_url(adapter, rid)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
|
||||
adapter.name, log_prefix, rid, kind, exc,
|
||||
)
|
||||
continue
|
||||
cached = await cls._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fresh_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"{log_prefix} rid={rid}",
|
||||
resource_id=rid,
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
path, mime = cached
|
||||
media_paths.append(path)
|
||||
mimes.append(mime)
|
||||
return media_paths, mimes
|
||||
|
||||
@classmethod
|
||||
async def _collect_observed_media(
|
||||
cls, adapter, source,
|
||||
@@ -2463,41 +2598,178 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
if not order:
|
||||
return [], []
|
||||
|
||||
media_paths: List[str] = []
|
||||
return await cls._resolve_ybres_refs(
|
||||
adapter, order, log_prefix="observed-media",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _resolve_quote_media(
|
||||
cls, adapter, quote_media_refs: List[Tuple[str, str, str]],
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Resolve media anchors carried by the quoted message.
|
||||
|
||||
``quote_media_refs`` is a list of ``(rid, kind, filename)`` tuples
|
||||
produced by :class:`QuoteContextMiddleware` from the transcript.
|
||||
"""
|
||||
return await cls._resolve_ybres_refs(
|
||||
adapter, quote_media_refs, log_prefix="quote",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _collect_quote_local_media(ctx: InboundContext) -> Tuple[List[str], List[str]]:
|
||||
"""Private-chat fallback for recovering already-local quoted media.
|
||||
|
||||
Only already-local media is handled here: by the time a turn is cached,
|
||||
``PatchAnchorsMiddleware`` has rewritten resolved ``|ybres:`` anchors to
|
||||
``[image: /path]`` / ``[file: name → /path]``. Unresolved anchors are an
|
||||
original-turn resolution failure and belong to that turn's handling, not
|
||||
this quote fallback — so no re-download happens here.
|
||||
|
||||
Returns ``(local_paths, mimes)`` for media already downloaded to the
|
||||
local cache on its original turn, ready to inject as-is.
|
||||
"""
|
||||
paths: List[str] = []
|
||||
mimes: List[str] = []
|
||||
for rid, kind, filename in order:
|
||||
try:
|
||||
fresh_url = await cls._resolve_by_resource_id(adapter, rid)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] observed-media resolve failed: rid=%s kind=%s err=%s",
|
||||
adapter.name, rid, kind, exc,
|
||||
)
|
||||
rid_key = ctx.reply_to_message_id
|
||||
if not rid_key:
|
||||
return paths, mimes
|
||||
cache = getattr(ctx.adapter, "_msg_content_cache", None)
|
||||
if not cache:
|
||||
return paths, mimes
|
||||
text = cache.get(rid_key)
|
||||
if not isinstance(text, str) or not text:
|
||||
return paths, mimes
|
||||
|
||||
# Already-local media paths written by PatchAnchorsMiddleware. The
|
||||
# generic anchor regex covers every kind _patch emits (image/file today,
|
||||
# video/audio if they later become resolvable) without per-kind upkeep.
|
||||
seen: set = set()
|
||||
for m in _YB_LOCAL_MEDIA_RE.finditer(text):
|
||||
kind = (m.group(1) or "").strip().lower()
|
||||
path = (m.group(2) or "").strip()
|
||||
if not path or path in seen:
|
||||
continue
|
||||
cached = await cls._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fresh_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"rid={rid}",
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
seen.add(path)
|
||||
mime = guess_mime_type(os.path.basename(path)) or (
|
||||
"image/jpeg" if kind == "image" else "application/octet-stream"
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
path, mime = cached
|
||||
media_paths.append(path)
|
||||
paths.append(path)
|
||||
mimes.append(mime)
|
||||
return media_paths, mimes
|
||||
|
||||
return paths, mimes
|
||||
|
||||
async def handle(self, ctx: InboundContext, next_fn) -> None:
|
||||
# NOTE: Reaching this middleware in a group chat implies the message has
|
||||
# @-mentioned the bot (or is an owner command). GroupAtGuardMiddleware
|
||||
# short-circuits non-@bot group messages earlier in the pipeline, so we
|
||||
# don't need to re-check @bot status here before downloading media.
|
||||
adapter = ctx.adapter
|
||||
ctx.media_urls, ctx.media_types = await self._resolve_media_urls(adapter, ctx.media_refs)
|
||||
# Re-check placeholder after media resolution
|
||||
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, len(ctx.media_urls)):
|
||||
|
||||
urls: List[str] = []
|
||||
types: List[str] = []
|
||||
seen: set = set()
|
||||
|
||||
def _add_unique_pairs(pair_lists: Tuple[List[str], List[str]]) -> None:
|
||||
u_list, m_list = pair_lists
|
||||
for u, m in zip(u_list, m_list):
|
||||
if not u or u in seen:
|
||||
continue
|
||||
seen.add(u)
|
||||
urls.append(u)
|
||||
types.append(m)
|
||||
|
||||
# 1) Media carried by the current message itself.
|
||||
own_pairs = await self._resolve_media_urls(adapter, ctx.media_refs)
|
||||
own_count = sum(1 for u in own_pairs[0] if u)
|
||||
_add_unique_pairs(own_pairs)
|
||||
|
||||
# 2) Second source — quoted media takes priority; otherwise fall back
|
||||
# to observed-media backfill in groups only (DMs already had their
|
||||
# media resolved on the turn it was sent).
|
||||
if ctx.reply_to_message_id is not None:
|
||||
if ctx.quote_media_refs:
|
||||
_add_unique_pairs(await self._resolve_quote_media(adapter, ctx.quote_media_refs))
|
||||
else:
|
||||
# DM quote fallback: no transcript message_id match (DM user rows
|
||||
# carry no platform message_id), so recover already-local media
|
||||
# from the adapter msg cache. Patched on its original turn — no
|
||||
# re-download needed, inject as-is.
|
||||
_add_unique_pairs(self._collect_quote_local_media(ctx))
|
||||
elif ctx.chat_type == "group":
|
||||
# Group chats: only @-bot turns reach this middleware
|
||||
# (see GroupAtGuardMiddleware note at top of handle()),
|
||||
# so unconditional observed-media hydration is safe here.
|
||||
try:
|
||||
_add_unique_pairs(await self._collect_observed_media(adapter, ctx.source))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] observed-image hydration raised, continuing anyway: %s",
|
||||
adapter.name, exc,
|
||||
)
|
||||
|
||||
ctx.media_urls = urls
|
||||
ctx.media_types = types
|
||||
|
||||
# Re-check placeholder after media resolution.
|
||||
# Use ``own_count`` (not ``len(urls)``) to preserve the original
|
||||
# semantics: a placeholder text accompanied only by quote/observed
|
||||
# media (i.e. no fresh attachment of its own) is still skippable.
|
||||
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, own_count):
|
||||
logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
|
||||
return # Stop pipeline
|
||||
await next_fn()
|
||||
|
||||
|
||||
class PatchAnchorsMiddleware(InboundMiddleware):
|
||||
"""Replace ``[kind|ybres:RID]`` anchors in ``ctx.raw_text`` with local paths.
|
||||
|
||||
Runs after :class:`MediaResolveMiddleware` so that ``ctx.media_urls`` /
|
||||
``ctx.media_types`` are already populated with downloaded resources
|
||||
(own media + quote media or group-observed media). The transcript
|
||||
written downstream then records usable local paths for the model
|
||||
instead of opaque ``ybres:`` references.
|
||||
|
||||
Only resolved media (paths starting with ``/``) are substituted; any
|
||||
anchor without a corresponding local resource is left untouched.
|
||||
"""
|
||||
|
||||
name = "patch-anchors"
|
||||
|
||||
@staticmethod
|
||||
def _patch(text: str, urls: List[str], types: List[str]) -> str:
|
||||
if not text or not urls:
|
||||
return text
|
||||
patched = text
|
||||
for u, m in zip(urls, types):
|
||||
if not u.startswith("/"):
|
||||
continue
|
||||
anchor_match = _YB_RES_REF_RE.search(patched)
|
||||
if not anchor_match:
|
||||
break
|
||||
head = anchor_match.group(1)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind == "image" and m.startswith("image/"):
|
||||
replacement = f"[image: {u}]"
|
||||
elif kind == "file":
|
||||
label = filename.strip() or os.path.basename(u)
|
||||
replacement = f"[file: {label} → {u}]"
|
||||
else:
|
||||
continue
|
||||
patched = (
|
||||
patched[: anchor_match.start()]
|
||||
+ replacement
|
||||
+ patched[anchor_match.end():]
|
||||
)
|
||||
return patched
|
||||
|
||||
async def handle(self, ctx: InboundContext, next_fn) -> None:
|
||||
ctx.raw_text = self._patch(ctx.raw_text, ctx.media_urls, ctx.media_types)
|
||||
await next_fn()
|
||||
|
||||
|
||||
class DispatchMiddleware(InboundMiddleware):
|
||||
"""Build MessageEvent and dispatch to AI handler."""
|
||||
|
||||
@@ -2513,123 +2785,18 @@ class DispatchMiddleware(InboundMiddleware):
|
||||
)
|
||||
|
||||
async def _dispatch_inbound_event() -> None:
|
||||
media_urls = list(ctx.media_urls)
|
||||
media_types = list(ctx.media_types)
|
||||
|
||||
# If user quoted a message (reply_to_message_id is set), resolve only
|
||||
# quote_media_refs to avoid injecting unrelated history media.
|
||||
# Otherwise, backfill observed media from recent transcript history.
|
||||
if ctx.reply_to_message_id is not None:
|
||||
# Fallback: if desc didn't contain ybres refs, look up transcript
|
||||
if not ctx.quote_media_refs:
|
||||
try:
|
||||
store = getattr(adapter, "_session_store", None)
|
||||
if store:
|
||||
session_entry = store.get_or_create_session(ctx.source)
|
||||
history = store.load_transcript(session_entry.session_id)
|
||||
for msg in reversed(history or []):
|
||||
mid = msg.get("message_id", "")
|
||||
if mid and mid == ctx.reply_to_message_id:
|
||||
_content = msg.get("content", "")
|
||||
if isinstance(_content, str) and "|ybres:" in _content:
|
||||
for m in _YB_RES_REF_RE.finditer(_content):
|
||||
head = m.group(1)
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind in _RESOLVABLE_MEDIA_KINDS:
|
||||
ctx.quote_media_refs.append((rid, kind, filename.strip()))
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] quote transcript lookup failed: %s",
|
||||
adapter.name, exc,
|
||||
)
|
||||
# User quoted a message — resolve only media from the quote
|
||||
for rid, kind, filename in ctx.quote_media_refs:
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS:
|
||||
continue
|
||||
try:
|
||||
fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] quote media resolve failed: rid=%s kind=%s err=%s",
|
||||
adapter.name, rid, kind, exc,
|
||||
)
|
||||
continue
|
||||
cached = await MediaResolveMiddleware._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fresh_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"quote rid={rid}",
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
path, mime = cached
|
||||
# Avoid duplicates
|
||||
if path not in media_urls:
|
||||
media_urls.append(path)
|
||||
media_types.append(mime)
|
||||
else:
|
||||
# No quote — backfill observed media from recent transcript history
|
||||
extra_img_urls: List[str] = []
|
||||
extra_img_mimes: List[str] = []
|
||||
try:
|
||||
extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media(
|
||||
adapter, ctx.source,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] observed-image hydration raised, continuing anyway: %s",
|
||||
adapter.name, exc,
|
||||
)
|
||||
if extra_img_urls:
|
||||
current = set(media_urls)
|
||||
for u, m in zip(extra_img_urls, extra_img_mimes):
|
||||
if u in current:
|
||||
continue
|
||||
media_urls.append(u)
|
||||
media_types.append(m)
|
||||
current.add(u)
|
||||
|
||||
# Replace [kind|ybres:xxx] anchors with local cache paths so
|
||||
# the transcript records usable paths for the model.
|
||||
_patched_event_text = ctx.raw_text
|
||||
for u, m in zip(media_urls, media_types):
|
||||
if not u.startswith("/"):
|
||||
continue
|
||||
anchor_match = _YB_RES_REF_RE.search(_patched_event_text)
|
||||
if not anchor_match:
|
||||
continue
|
||||
head = anchor_match.group(1)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind == "image" and m.startswith("image/"):
|
||||
replacement = f"[image: {u}]"
|
||||
elif kind == "file":
|
||||
label = filename.strip() or os.path.basename(u)
|
||||
replacement = f"[file: {label} → {u}]"
|
||||
else:
|
||||
continue
|
||||
_patched_event_text = (
|
||||
_patched_event_text[:anchor_match.start()]
|
||||
+ replacement
|
||||
+ _patched_event_text[anchor_match.end():]
|
||||
)
|
||||
|
||||
event = MessageEvent(
|
||||
text=_patched_event_text,
|
||||
text=ctx.raw_text,
|
||||
message_type=(
|
||||
MessageType.DOCUMENT
|
||||
if any(mt.startswith(("application/", "text/")) for mt in media_types)
|
||||
if any(mt.startswith(("application/", "text/")) for mt in ctx.media_types)
|
||||
else ctx.msg_type
|
||||
),
|
||||
source=ctx.source,
|
||||
message_id=ctx.msg_id or None,
|
||||
raw_message=ctx.push,
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
media_urls=list(ctx.media_urls),
|
||||
media_types=list(ctx.media_types),
|
||||
reply_to_message_id=ctx.reply_to_message_id,
|
||||
reply_to_text=ctx.reply_to_text,
|
||||
channel_prompt=ctx.channel_prompt,
|
||||
@@ -2723,6 +2890,7 @@ class InboundPipelineBuilder:
|
||||
ClassifyMessageTypeMiddleware,
|
||||
QuoteContextMiddleware,
|
||||
MediaResolveMiddleware,
|
||||
PatchAnchorsMiddleware,
|
||||
DispatchMiddleware,
|
||||
]
|
||||
|
||||
@@ -3383,12 +3551,22 @@ class ConnectionManager:
|
||||
return False
|
||||
|
||||
async def _cleanup_ws(self) -> None:
|
||||
"""Close and clear the WebSocket connection."""
|
||||
"""Close and clear the WebSocket connection, bounded by
|
||||
``WS_CLOSE_TIMEOUT_S`` so an unresponsive server can't stall teardown
|
||||
(see the constant's definition for the full rationale)."""
|
||||
ws = self._ws
|
||||
self._ws = None
|
||||
if ws is not None:
|
||||
try:
|
||||
await ws.close()
|
||||
await asyncio.wait_for(ws.close(), timeout=WS_CLOSE_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
# Server never echoed the close frame within the bound; drop the
|
||||
# connection. websockets force-closes the transport on cancel,
|
||||
# and at shutdown the loop is tearing down anyway.
|
||||
logger.debug(
|
||||
"[%s] WS close handshake exceeded %.1fs — dropping connection",
|
||||
self._adapter.name, WS_CLOSE_TIMEOUT_S,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -4629,6 +4807,11 @@ class YuanbaoAdapter(BasePlatformAdapter):
|
||||
# Abstract method implementations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Yuanbao gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Yuanbao WS gateway and authenticate.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user