from __future__ import annotations import asyncio import json import os import random import time from collections.abc import AsyncIterator from typing import Any import httpx from mattermostdriver import Driver from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelException, ChannelNotConnectedError, DeliveryFailedError, ) from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.meta import ChannelMeta from yuxi.channels.models import ( ChannelAccountSnapshot, ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, EventType, HealthStatus, MentionsInfo, MessageType, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger from .agent_route import resolve_agent_route from .approval import ApprovalManager, ApprovalRequest from .cache import MattermostChannelCache, SentMessageCache from .config_writes import ConfigWritesManager from .debounce import InboundDebouncer from .monitor import MentionGate from .normalizer import ( check_bot_mentioned, detect_message_type, extract_attachments, extract_mentions, extract_urls, parse_channel_json, parse_post_json, ) from .pairing import MattermostPairingManager from .reply import ReplyManager from .security import MattermostSecurity from .send import build_patch_options, build_post_options, chunk_text_for_outbound from .session import resolve_chat_id, resolve_chat_type STREAM_UPDATE_MIN_INTERVAL_MS = 500 STREAM_BUFFER_MAX_CHARS = 100_000 STREAM_MIN_CHARS_DEFAULT = 50 STREAM_IDLE_MS_DEFAULT = 1000 BLOCK_STREAM_MIN_CHARS_DEFAULT = 1500 BLOCK_STREAM_IDLE_MS_DEFAULT = 1000 WS_CONNECT_TIMEOUT_S = 30.0 WS_RECONNECT_BASE_DELAY_S = 2.0 WS_RECONNECT_MAX_DELAY_S = 120.0 WS_RECONNECT_MAX_ATTEMPTS = 10 WS_RECONNECT_JITTER = 0.2 WS_PING_INTERVAL_S = 30.0 WS_PONG_TIMEOUT_S = 10.0 SEEN_POSTS_MAX = 2000 SEEN_POSTS_TTL_S = 300 AUTH_401_RETRY_BASE_S = 30.0 AUTH_401_RETRY_MAX_S = 600.0 AUTH_401_RETRY_COUNT = 10 DEBOUNCE_TTL_MS = 1500 DEBOUNCE_MAX_ENTRIES = 3000 WS_EVENT_CAPTURE_MAX = 200 def _parse_driver_scheme_port(server_url: str) -> tuple[str, int]: """浠?server_url 鎺ㄦ柇 scheme 鍜?port""" from urllib.parse import urlparse parsed = urlparse(server_url) scheme = parsed.scheme or "https" if parsed.port is not None: return scheme, parsed.port return scheme, 443 if scheme == "https" else 80 def _parse_json_field(data: dict, field: str) -> dict: value = data.get(field) if isinstance(value, dict): return value if isinstance(value, str) and value.strip(): try: return json.loads(value) except (json.JSONDecodeError, TypeError): pass return {} async def probe_mattermost(server_url: str, bot_token: str, timeout_s: float = 30.0) -> dict: """独立探测 Mattermost 服务器连通性和 Bot Token 有效性。 返回状态字典: - ok: 连接正常,返回 bot 信息 - error: 连接失败,返回错误信息 """ if not server_url or not bot_token: return {"status": "error", "message": "Missing server_url or bot_token"} server_url = server_url.rstrip("/") scheme, port = _parse_driver_scheme_port(server_url) driver = Driver( { "url": server_url, "token": bot_token, "scheme": scheme, "port": port, "verify": True, "timeout": int(timeout_s), } ) try: await driver.login() auth_user = driver.users.get_user(user_id="me") await driver.logout() return { "status": "ok", "bot_id": auth_user["id"], "bot_username": auth_user["username"], "server_url": server_url, } except Exception as e: return {"status": "error", "message": str(e)} @register_builtin_adapter class MattermostAdapter(BaseChannelAdapter): channel_id = "mattermost" channel_type = ChannelType.MATTERMOST webhook_path = None text_chunk_limit = 4000 supports_markdown = True supports_streaming = True streaming_modes = ["off", "partial", "block", "progress"] max_media_size_mb = 100 silence_send = False capabilities = ChannelCapabilities( chat_types=["direct", "group", "channel", "thread"], polls=True, reactions=True, edit=True, unsend=True, reply=True, media=True, threads=True, block_streaming=True, native_commands=True, supports_markdown=True, supports_streaming=True, streaming_modes=["off", "partial", "block", "progress"], text_chunk_limit=4000, max_media_size_mb=100, ) meta = ChannelMeta(id="mattermost", label="Mattermost") def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._driver: Driver | None = None self._ws_task: asyncio.Task | None = None self._connected_event = asyncio.Event() self._connected_at: float | None = None self._bot_user_id: str = "" self._bot_username: str = "" self._server_url: str = "" self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60) self._stream_min_chars = STREAM_MIN_CHARS_DEFAULT self._stream_idle_ms = STREAM_IDLE_MS_DEFAULT self._block_stream_min_chars = BLOCK_STREAM_MIN_CHARS_DEFAULT self._block_stream_idle_ms = BLOCK_STREAM_IDLE_MS_DEFAULT if config: self.text_chunk_limit = int(config.get("text_chunk_limit", self.text_chunk_limit)) self.silence_send = bool(config.get("silence_send", False)) self._stream_min_chars = int(config.get("stream_min_chars", STREAM_MIN_CHARS_DEFAULT)) self._stream_idle_ms = int(config.get("stream_idle_ms", STREAM_IDLE_MS_DEFAULT)) coalesce = config.get("blockStreamingCoalesce", {}) self._block_stream_min_chars = int(coalesce.get("minChars", BLOCK_STREAM_MIN_CHARS_DEFAULT)) self._block_stream_idle_ms = int(coalesce.get("idleMs", BLOCK_STREAM_IDLE_MS_DEFAULT)) self._streaming_messages: dict[str, dict[str, Any]] = {} self._streaming_lock = asyncio.Lock() self._ws_reconnect_attempt = 0 self._ws_reconnect_task: asyncio.Task | None = None self._seen_posts: dict[str, float] = {} self._security = MattermostSecurity(config) self._mention_gate = MentionGate(config) self._reply_manager = ReplyManager(config) self._pairing_manager = MattermostPairingManager() self._message_queue: asyncio.Queue[ChannelMessage] = asyncio.Queue() self._last_error: str | None = None self._last_disconnect: dict | None = None self._token_source: str = "" self._last_pong_at: float = 0.0 self._ws_heartbeat_task: asyncio.Task | None = None self._bot_health_task: asyncio.Task | None = None self._auth_401_retry_count = 0 self._sent_cache = SentMessageCache() self._channel_cache = MattermostChannelCache() self._inbound_debouncer = InboundDebouncer( ttl_ms=DEBOUNCE_TTL_MS, max_entries=DEBOUNCE_MAX_ENTRIES, ) self._config_writes = ConfigWritesManager(config) self._approval_manager = ApprovalManager(config) self._ws_event_capture: list[dict] = [] self._ws_event_capture_enabled = False self._debug_proxy_url = os.getenv("OPENCLAW_DEBUG_PROXY", "") self._proxy_applied = False def _is_connected(self) -> bool: return self._status == ChannelStatus.CONNECTED and self._driver is not None async def connect(self) -> None: if self._status == ChannelStatus.CONNECTED: return self._status = ChannelStatus.CONNECTING self._connected_event.clear() logger.info(f"[Mattermost] Starting channel '{self.config.get('name', self.channel_id)}'") server_url = self._resolve_server_url() bot_token = self._resolve_bot_token() if not server_url or not bot_token: raise ChannelAuthenticationError( "Mattermost server_url and bot_token must be configured.\n" " server_url: e.g. https://mattermost.example.com\n" " bot_token: MATTERMOST_BOT_TOKEN env or config" ) self._server_url = server_url.rstrip("/") scheme, port = _parse_driver_scheme_port(self._server_url) driver_kwargs: dict[str, Any] = { "url": self._server_url, "token": bot_token, "scheme": scheme, "port": port, "verify": True, "timeout": 30, } if self._debug_proxy_url and not self._proxy_applied: driver_kwargs["proxy"] = self._debug_proxy_url self._proxy_applied = True logger.info(f"[Mattermost] Debug proxy enabled: {self._debug_proxy_url}") self._driver = Driver(driver_kwargs) try: await self._driver.login() except Exception as e: raise ChannelAuthenticationError(f"Mattermost login failed: {e}") from e auth_user = self._driver.users.get_user(user_id="me") if not auth_user: raise ChannelAuthenticationError("Failed to verify Bot Token") self._bot_user_id = auth_user["id"] self._bot_username = auth_user["username"] logger.info(f"[Mattermost] Bot verified: @{self._bot_username} (ID: {self._bot_user_id}), server: {server_url}") self._ws_reconnect_attempt = 0 self._seen_posts.clear() await self._start_ws_monitor() self._ws_heartbeat_task = asyncio.create_task(self._ws_heartbeat_monitor()) try: await asyncio.wait_for( self._connected_event.wait(), timeout=WS_CONNECT_TIMEOUT_S, ) except TimeoutError: self._status = ChannelStatus.ERROR raise ChannelException( "Mattermost WebSocket connection timed out", retryable=True, retry_after_ms=5000, ) self._status = ChannelStatus.CONNECTED self._connected_at = time.time() logger.info(f"[Mattermost] Channel started, bot: @{self._bot_username}, server: {server_url}") self._bot_health_task = asyncio.create_task(self._check_bot_health_periodically()) slash_cfg = self.config.get("slash_commands", {}) if slash_cfg.get("auto_register"): from .slash_commands import register_slash_commands_across_teams asyncio.create_task( register_slash_commands_across_teams( self._driver, self._bot_user_id, callback_url=slash_cfg.get("callback_url", ""), auto_register=True, ) ) async def disconnect(self) -> None: if self._status == ChannelStatus.DISCONNECTED: return logger.info(f"[Mattermost] Stopping channel '{self.config.get('name', self.channel_id)}'") self._status = ChannelStatus.DISCONNECTED self._connected_event.clear() if self._ws_reconnect_task and not self._ws_reconnect_task.done(): self._ws_reconnect_task.cancel() self._ws_reconnect_task = None if hasattr(self, "_ws_heartbeat_task") and self._ws_heartbeat_task and not self._ws_heartbeat_task.done(): self._ws_heartbeat_task.cancel() self._ws_heartbeat_task = None if self._bot_health_task and not self._bot_health_task.done(): self._bot_health_task.cancel() self._bot_health_task = None self._streaming_messages.clear() self._seen_posts.clear() self._sent_cache.clear() self._channel_cache.clear() self._inbound_debouncer.clear() self._ws_event_capture.clear() self._ws_reconnect_attempt = 0 if self._ws_task and not self._ws_task.done(): self._ws_task.cancel() try: await self._ws_task except (asyncio.CancelledError, Exception): pass self._ws_task = None if self._driver: try: await self._driver.logout() except Exception: pass self._driver = None self._connected_at = None logger.info("[Mattermost] Channel stopped") async def send(self, response: ChannelResponse) -> DeliveryResult: if not self._is_connected(): return DeliveryResult(success=False, error="Not connected") text = response.content if len(text) <= self.text_chunk_limit: return await self._send_single(response) chunks = chunk_text_for_outbound(text, self.text_chunk_limit) last_id: str | None = None for chunk in chunks: chunk_response = ChannelResponse( identity=response.identity, message_type=response.message_type, content=chunk, attachments=response.attachments if chunk is chunks[-1] else [], reply_to_message_id=response.reply_to_message_id, metadata=response.metadata, timestamp=response.timestamp, ) result = await self._send_single(chunk_response) if result.success and result.message_id: last_id = result.message_id return DeliveryResult(success=True, message_id=last_id) async def _send_single(self, response: ChannelResponse) -> DeliveryResult: options = build_post_options(response) async def _do_send(): return self._driver.posts.create_post(options=options) try: result = await self._circuit_breaker.call(_do_send) msg_id = result.get("id", "") if msg_id: self._sent_cache.record( msg_id=msg_id, chat_id=response.identity.channel_chat_id, channel_id=response.identity.channel_chat_id, thread_id=response.reply_to_message_id or "", ) return DeliveryResult(success=True, message_id=msg_id) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: raise DeliveryFailedError(str(e)) from e async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: if not self._is_connected(): return DeliveryResult(success=False, error="Not connected") filename = self.config.get("media_filename", "file") try: async def _do_upload(): return self._driver.files.upload_file( channel_id=chat_id, files={filename: data}, ) result = await self._circuit_breaker.call(_do_upload) file_ids = [f["id"] for f in (result.get("file_infos", []) if isinstance(result, dict) else [])] return DeliveryResult(success=True, metadata={"file_ids": file_ids}) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: logger.warning(f"[Mattermost] Media upload failed, falling back to URL text: {e}") url = getattr(data, "url", None) or (data.get("url") if isinstance(data, dict) else None) if url: return DeliveryResult( success=True, message_id=None, metadata={"file_ids": [], "fallback_url": url}, ) raise DeliveryFailedError(str(e)) from e async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: if not self._is_connected(): return DeliveryResult(success=False, error="Not connected") async def _do_edit(): return self._driver.posts.update_post( post_id=msg_id, options={"message": content, "id": msg_id}, ) try: await self._circuit_breaker.call(_do_edit) return DeliveryResult(success=True, message_id=msg_id) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: logger.error(f"[Mattermost] Edit message failed for {msg_id}: {e}") return DeliveryResult(success=False, error=str(e)) async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._is_connected(): return DeliveryResult(success=False, error="Not connected") async def _do_delete(): return self._driver.posts.delete_post(post_id=msg_id) try: await self._circuit_breaker.call(_do_delete) return DeliveryResult(success=True, message_id=msg_id) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: logger.error(f"[Mattermost] Delete message failed for {msg_id}: {e}") return DeliveryResult(success=False, error=str(e)) async def send_reaction(self, chat_id: str, msg_id: str, emoji: str, remove: bool = False) -> DeliveryResult: if not self._is_connected(): return DeliveryResult(success=False, error="Not connected") if remove: return await self._remove_reaction(chat_id, msg_id, emoji) async def _do_react(): return self._driver.reactions.create_reaction( options={ "user_id": self._bot_user_id, "post_id": msg_id, "emoji_name": emoji.strip(":"), } ) try: await self._circuit_breaker.call(_do_react) return DeliveryResult(success=True, message_id=msg_id) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: logger.error(f"[Mattermost] Send reaction failed for {msg_id} ({emoji}): {e}") return DeliveryResult(success=False, error=str(e)) async def _remove_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: """Remove a reaction: DELETE /users/{bot_id}/posts/{post_id}/reactions/{emoji}""" async def _do_remove(): return self._driver.reactions.remove_reaction( user_id=self._bot_user_id, post_id=msg_id, emoji_name=emoji.strip(":"), ) try: await self._circuit_breaker.call(_do_remove) return DeliveryResult(success=True, message_id=msg_id) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: logger.error(f"[Mattermost] Remove reaction failed for {msg_id} ({emoji}): {e}") return DeliveryResult(success=False, error=str(e)) async def send_typing(self, chat_id: str) -> DeliveryResult: """Send typing indicator: POST /users/me/typing""" if not self._is_connected(): return DeliveryResult(success=False, error="Not connected") async def _do_typing(): return self._driver.users.create_user_typing( options={ "channel_id": chat_id, } ) try: await self._circuit_breaker.call(_do_typing) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: if not self._is_connected(): return DeliveryResult(success=False, error="Not connected") min_chars = self._stream_min_chars idle_ms = self._stream_idle_ms now = time.monotonic() async with self._streaming_lock: entry = self._streaming_messages.get(chat_id, {}) draft_mode = entry.get("_draft_mode", False) is_draft = draft_mode and not finished should_send = finished or self._should_flush_stream(entry, chunk, now, min_chars, idle_ms) if not should_send: entry["_pending"] = (entry.get("_pending", "") + chunk)[:STREAM_BUFFER_MAX_CHARS] entry["_pending_chars"] = entry.get("_pending_chars", 0) + len(chunk) self._streaming_messages[chat_id] = entry return DeliveryResult(success=True, message_id=msg_id) pending = entry.pop("_pending", "") full_text = pending + chunk if pending else chunk entry.pop("_pending_chars", None) if is_draft: display_text = self._format_stream_display(entry, full_text, finished) else: display_text = self._format_stream_display(entry, full_text, finished) entry["_last_update"] = now entry["_last_flush_chars"] = len(full_text) if self._can_finalize_in_place(entry, finished): display_text = full_text if not msg_id: resp = ChannelResponse( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id="", channel_chat_id=chat_id, ), content=display_text, metadata={"streaming": True, "finished": finished}, ) result = await self.send(resp) if result.success and result.message_id: self._streaming_messages[chat_id] = { "_last_update": now, "msg_id": result.message_id, "_draft_mode": draft_mode, } return result try: self._driver.posts.patch_post( post_id=msg_id, options=build_patch_options(display_text), ) except Exception as e: if hasattr(e, "response") and getattr(e.response, "status_code", None) == 401: logger.warning(f"[Mattermost] Stream patch got 401 for {msg_id}, triggering backoff") await self._handle_401_backoff() logger.error(f"[Mattermost] Stream patch failed for {msg_id}: {e}") return DeliveryResult(success=False, error=str(e)) if finished: if not is_draft: self._streaming_messages.pop(chat_id, None) else: entry.pop("_pending", None) entry["_last_update"] = now entry["_draft_finalized"] = True self._streaming_messages[chat_id] = entry else: self._streaming_messages[chat_id] = entry return DeliveryResult(success=True, message_id=msg_id) async def send_stream_chunk_with_status( self, chat_id: str, msg_id: str, chunk: str, finished: bool, tool_name: str = "", reasoning: bool = False, draft_preview: bool = False, ) -> DeliveryResult: """带状态提示的流式输出。 支持 Tool Status 预览和 Reasoning 预览。 """ if not self._is_connected(): return DeliveryResult(success=False, error="Not connected") async with self._streaming_lock: entry = self._streaming_messages.get(chat_id, {}) entry["_tool_name"] = tool_name entry["_reasoning"] = reasoning entry["_draft_mode"] = draft_preview or reasoning self._streaming_messages[chat_id] = entry return await self.send_stream_chunk(chat_id, msg_id, chunk, finished) async def _create_dm_channel_with_retry(self, user_id: str, max_retries: int = 3) -> str | None: retry_delay_s = float(self.config.get("dmChannelRetry", {}).get("baseDelayMs", 1000) / 1000.0) max_retries_config = int(self.config.get("dmChannelRetry", {}).get("maxRetries", max_retries)) retries = min(max_retries, max_retries_config) for attempt in range(retries): try: channel = self._driver.channels.create_direct_channel(options=[self._bot_user_id, user_id]) channel_id = channel.get("id", "") if channel_id: return channel_id except Exception as e: logger.warning(f"[Mattermost] DM channel creation attempt {attempt + 1}/{retries} failed: {e}") if attempt < retries - 1: await asyncio.sleep(retry_delay_s * (2**attempt)) return None def _apply_response_prefix(self, text: str) -> str: prefix = self.config.get("responsePrefix", "") if prefix and text: return prefix + text return text @staticmethod def _format_stream_display(entry: dict, text: str, finished: bool) -> str: """格式化流式显示文本,支持状态提示前缀。""" tool_name = entry.get("_tool_name", "") reasoning = entry.get("_reasoning", False) if finished and (tool_name or reasoning): return text prefix = "" if reasoning: prefix = "🤔 Thinking…\n\n" elif tool_name: prefix = f"🔧 Running {tool_name}…\n\n" if prefix and text: return prefix + text return text def _should_flush_stream( self, entry: dict, chunk: str, now: float, min_chars: int, idle_ms: int, ) -> bool: """判断是否需要刷新流式缓冲。 刷新条件: 1. 距上次刷新超过 STREAM_UPDATE_MIN_INTERVAL_MS 2. 待刷新字符数超过 min_chars 3. 距上次活动超过 idle_ms(缓冲有内容时) """ last_update = entry.get("_last_update", 0) if last_update == 0: return True elapsed_ms = (now - last_update) * 1000 if elapsed_ms >= STREAM_UPDATE_MIN_INTERVAL_MS: return True pending_chars = entry.get("_pending_chars", 0) + len(chunk) if pending_chars >= min_chars: return True if pending_chars > 0 and elapsed_ms >= idle_ms: return True return False @staticmethod def _can_finalize_in_place(entry: dict, finished: bool) -> bool: """判断是否可以在当前位置原地最终化流式消息。 判定条件: 1. 必须是最终块 (finished=True) 2. 处于 draft 模式且已被标记为 draft_finalized 3. 内容的 replyToId 未发生变更 """ if not finished: return False is_draft = entry.get("_draft_mode", False) is_finalized = entry.get("_draft_finalized", False) has_error = entry.get("_error", False) has_media = entry.get("_has_media", False) if has_error and not is_draft: return True if has_media: return False return is_draft and is_finalized def _record_ws_event(self, event_name: str, data: dict) -> None: """记录 WebSocket 事件帧用于调试。""" if not self._ws_event_capture_enabled: return entry = { "event": event_name, "at": time.time(), "data_keys": list(data.keys()) if isinstance(data, dict) else None, } self._ws_event_capture.append(entry) if len(self._ws_event_capture) > WS_EVENT_CAPTURE_MAX: self._ws_event_capture = self._ws_event_capture[-WS_EVENT_CAPTURE_MAX:] def enable_ws_event_capture(self) -> None: self._ws_event_capture_enabled = True def disable_ws_event_capture(self) -> None: self._ws_event_capture_enabled = False def get_ws_event_capture(self) -> list[dict]: return list(self._ws_event_capture) async def receive(self) -> AsyncIterator[ChannelMessage]: while True: try: msg = await self._message_queue.get() yield msg except asyncio.CancelledError: break def normalize_inbound(self, raw: Any) -> ChannelMessage: event = raw.get("event", "") data = raw.get("data", {}) broadcast = raw.get("broadcast", {}) if event in ("reaction_added", "reaction_removed"): return self._normalize_reaction(event, data, broadcast) post_data = parse_post_json(data) channel_data = parse_channel_json(data) user_id = post_data.get("user_id") or "" message_text = post_data.get("message") or data.get("message") or "" msg_id = post_data.get("id") or "" bot_mentioned = check_bot_mentioned(message_text, self._bot_username) chat_type = resolve_chat_type(post_data, channel_data) chat_id = resolve_chat_id(post_data, channel_data) event_type = EventType.MESSAGE_RECEIVED if event == "post_edited": event_type = EventType.MESSAGE_UPDATED elif event == "post_deleted": event_type = EventType.MESSAGE_DELETED message_type = detect_message_type(post_data) if message_type == MessageType.TEXT and message_text.strip().startswith("/"): message_type = MessageType.COMMAND return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=user_id, channel_chat_id=chat_id, channel_message_id=msg_id, ), event_type=event_type, message_type=message_type, chat_type=chat_type, content=message_text, attachments=extract_attachments(post_data), mentions=MentionsInfo( mentioned_user_ids=extract_mentions(message_text), is_bot_mentioned=bot_mentioned, ), extracted_urls=extract_urls(message_text), reply_to_message_id=post_data.get("root_id") or None, metadata={ "root_id": post_data.get("root_id"), "channel_name": channel_data.get("display_name") or channel_data.get("name"), "team_id": broadcast.get("team_id") or "", }, ) def _normalize_reaction(self, event: str, data: dict, broadcast: dict) -> ChannelMessage: reaction_data = _parse_json_field(data, "reaction") user_id = reaction_data.get("user_id") or "" post_id = reaction_data.get("post_id") or "" emoji_name = reaction_data.get("emoji_name") or "" channel_id = broadcast.get("channel_id") or "" return ChannelMessage( identity=ChannelIdentity( channel_id=self.channel_id, channel_type=self.channel_type, channel_user_id=user_id, channel_chat_id=f"channel_{channel_id}", channel_message_id=post_id, ), event_type=EventType.SYSTEM_EVENT, message_type=MessageType.TEXT, content=f":{emoji_name}:", metadata={ "reaction_event": event, "emoji_name": emoji_name, "post_id": post_id, "channel_id": channel_id, "team_id": broadcast.get("team_id") or "", }, ) def format_outbound(self, response: ChannelResponse) -> dict[str, Any]: return build_post_options(response) def resolve_debug_proxy(self) -> str: """返回 Debug Proxy URL(如果配置了 OPENCLAW_DEBUG_PROXY 环境变量)。""" return self._debug_proxy_url async def _check_bot_health_periodically(self, interval_s: float = 300.0) -> None: """定期检查 Bot 是否被禁用/重新启用。 通过 getBotUpdateAt 检查 update_at 变更, 检测到变更后触发 401 退避重新认证。 """ last_update_at: int = 0 while self._status == ChannelStatus.CONNECTED: await asyncio.sleep(interval_s) if self._status != ChannelStatus.CONNECTED or not self._driver: break try: bot_user = self._driver.users.get_user(user_id=self._bot_user_id) current_update_at = bot_user.get("update_at", 0) if last_update_at == 0: last_update_at = current_update_at continue if current_update_at != last_update_at: logger.info( f"[Mattermost] Bot update_at changed: {last_update_at} → {current_update_at}, checking auth" ) last_update_at = current_update_at alive = await self._driver.client.get("/api/v4/users/me") if hasattr(alive, "status_code") and alive.status_code == 401: logger.warning("[Mattermost] Bot appears disabled, triggering 401 backoff") await self._handle_401_backoff() except Exception as e: logger.debug(f"[Mattermost] Bot health check error: {e}") if hasattr(e, "response") and getattr(e.response, "status_code", None) == 401: await self._handle_401_backoff() async def health_check(self) -> HealthStatus: if not self._is_connected(): return HealthStatus( status="unhealthy", last_error="Not connected", ) try: start = time.monotonic() await asyncio.get_event_loop().run_in_executor(None, self._driver.system.ping) latency_ms = (time.monotonic() - start) * 1000 return HealthStatus( status="healthy", latency_ms=latency_ms, last_connected_at=utc_now_naive(), metadata={ "bot_id": self._bot_user_id, "bot_username": self._bot_username, "server_url": self._server_url, "bot_token_source": self._token_source, "last_disconnect": self._last_disconnect, "last_error": self._last_error, "reconnect_attempts": self._ws_reconnect_attempt, }, ) except Exception as e: return HealthStatus(status="unhealthy", last_error=str(e)) async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: if not self._is_connected(): return {} try: return self._driver.users.get_user(user_id=channel_user_id) except Exception: return {} async def download_media(self, file_id: str) -> bytes: if not self._is_connected(): raise ChannelNotConnectedError() try: link_data = self._driver.files.get_file_link(file_id=file_id) url = link_data.get("link", "") if not url: raise ChannelException("No file link returned") async with httpx.AsyncClient() as client: resp = await client.get(url, timeout=30) resp.raise_for_status() return resp.content except Exception as e: raise ChannelException(f"Failed to download media: {e}") from e async def pre_connect(self) -> dict: server_url = self._resolve_server_url() bot_token = self._resolve_bot_token() if not server_url or not bot_token: return {"status": "error", "message": "Missing server_url or bot_token"} scheme, port = _parse_driver_scheme_port(server_url.rstrip("/")) driver = Driver( { "url": server_url.rstrip("/"), "token": bot_token, "scheme": scheme, "port": port, "verify": True, "timeout": 30, } ) try: await driver.login() auth_user = driver.users.get_user(user_id="me") await driver.logout() return { "status": "ok", "bot_id": auth_user["id"], "bot_username": auth_user["username"], "server_url": server_url, } except Exception as e: return {"status": "error", "message": str(e)} async def _refresh_token_if_needed(self) -> bool: return True async def _handle_401_backoff(self) -> bool: """处理 401 错误:指数退避重试认证。 Bot 禁用后重新启用时,通过此机制自动恢复连接。 返回 True 表示重连成功。 """ self._auth_401_retry_count += 1 if self._auth_401_retry_count > AUTH_401_RETRY_COUNT: logger.error(f"[Mattermost] 401 backoff max retries ({AUTH_401_RETRY_COUNT}) reached, giving up") self._status = ChannelStatus.ERROR self._last_error = "401 backoff max retries reached" return False delay = min( AUTH_401_RETRY_BASE_S * (2 ** (self._auth_401_retry_count - 1)), AUTH_401_RETRY_MAX_S, ) jitter = delay * 0.2 * (2 * random.random() - 1) delay = max(1.0, delay + jitter) logger.info( f"[Mattermost] 401 backoff: retrying in {delay:.0f}s " f"(attempt {self._auth_401_retry_count}/{AUTH_401_RETRY_COUNT})" ) await asyncio.sleep(delay) try: await self._driver.login() self._auth_401_retry_count = 0 logger.info("[Mattermost] 401 backoff: re-authenticated successfully") return True except Exception as e: logger.warning(f"[Mattermost] 401 backoff: re-login failed: {e}") return await self._handle_401_backoff() def _resolve_server_url(self) -> str: url = self.config.get("server_url", "") or os.getenv("MATTERMOST_SERVER_URL", "") if "MATTERMOST_URL" not in os.environ and not self.config.get("server_url"): url = url or os.getenv("MATTERMOST_URL", "") return url def _resolve_bot_token(self) -> str: token = self.config.get("bot_token", "") if token: self._token_source = "config" return token token = os.getenv("MATTERMOST_BOT_TOKEN", "") if token: self._token_source = "env" return token self._token_source = "none" return "" @staticmethod def _parse_ws_post(data: dict) -> dict | None: post_str = data.get("post", "") if isinstance(post_str, str): try: return json.loads(post_str) except json.JSONDecodeError: return None return post_str if isinstance(post_str, dict) else None def _should_ignore_post(self, post: dict) -> bool: if post.get("user_id") == self._bot_user_id: return True if post.get("type") in ("system_add_remove", "system_join_leave"): return True return False def _dedup_check(self, post_id: str) -> bool: if not post_id: return False now = time.monotonic() if post_id in self._seen_posts: if now - self._seen_posts[post_id] < SEEN_POSTS_TTL_S: return True self._seen_posts[post_id] = now if len(self._seen_posts) > SEEN_POSTS_MAX: cutoff = now - SEEN_POSTS_TTL_S self._seen_posts = {k: v for k, v in self._seen_posts.items() if v > cutoff} return False async def _handle_ws_message(self, event_name: str, data: dict) -> None: self._record_ws_event(event_name, data) post = self._parse_ws_post(data) if post is None or self._should_ignore_post(post): return if event_name in ("posted", "post_edited") and self._dedup_check(post.get("id", "")): return channel_id = post.get("channel_id", "") thread_id = post.get("root_id", "") post_text = post.get("message", "") if not self._inbound_debouncer.should_process(channel_id, thread_id, post_text): return user_id = post.get("user_id", "") channel_data = _parse_json_field(data, "channel") chat_type_raw = channel_data.get("type", "") if chat_type_raw == "D": security_result = self._security.check_dm(user_id) else: security_result = self._security.check_group(user_id) if not security_result.allowed: logger.info(f"[Mattermost] Blocked message from {user_id}: {security_result.reason}") return bot_mentioned = check_bot_mentioned(post_text, self._bot_username) chat_type = resolve_chat_type( {"root_id": post.get("root_id"), "user_id": user_id, "channel_id": post.get("channel_id")}, channel_data, ) mention_result = self._mention_gate.check( str(chat_type.value) if hasattr(chat_type, "value") else str(chat_type), post_text, bot_mentioned, ) if not mention_result.should_respond: logger.debug(f"[Mattermost] Mention gate blocked: {mention_result.reason}") return try: msg = self.normalize_inbound( { "event": event_name, "data": data, "broadcast": data.get("broadcast", {}), } ) await self._handle_message(msg) self._message_queue.put_nowait(msg) except Exception as e: logger.error(f"[Mattermost] Error handling {event_name} event: {e}") async def _handle_reaction_event(self, event_name: str, data: dict) -> None: self._record_ws_event(event_name, data) post = self._parse_ws_post(data) if post is None or self._should_ignore_post(post): return try: msg = self.normalize_inbound( { "event": event_name, "data": data, "broadcast": data.get("broadcast", {}), } ) await self._handle_message(msg) self._message_queue.put_nowait(msg) except Exception as e: logger.error(f"[Mattermost] Error handling {event_name} event: {e}") async def _start_ws_monitor(self) -> None: if not self._driver: return async def on_posted(data: dict): await self._handle_ws_message("posted", data) async def on_post_edited(data: dict): await self._handle_ws_message("post_edited", data) async def on_post_deleted(data: dict): await self._handle_ws_message("post_deleted", data) async def on_reaction_added(data: dict): await self._handle_reaction_event("reaction_added", data) async def on_reaction_removed(data: dict): await self._handle_reaction_event("reaction_removed", data) async def on_error(data: dict): logger.error(f"[Mattermost] WebSocket error: {data}") async def on_close(data: dict): logger.warning(f"[Mattermost] WebSocket closed: {data}") self._last_disconnect = { "reason": data.get("reason", "unknown"), "code": data.get("code", 0), "at": time.time(), } if self._status == ChannelStatus.CONNECTED and ( self._ws_reconnect_task is None or self._ws_reconnect_task.done() ): self._ws_reconnect_task = asyncio.create_task(self._ws_reconnect()) async def on_connected(data: dict): logger.info("[Mattermost] WebSocket connected") self._ws_reconnect_attempt = 0 self._last_pong_at = time.monotonic() self._connected_event.set() self._ws_task = asyncio.create_task( self._driver.init_websocket( event_handler={ "posted": on_posted, "post_edited": on_post_edited, "post_deleted": on_post_deleted, "reaction_added": on_reaction_added, "reaction_removed": on_reaction_removed, "error": on_error, "close": on_close, "hello": on_connected, } ) ) async def _ws_heartbeat_monitor(self) -> None: """独立 WebSocket 心跳检测:每隔 PING_INTERVAL_S 检查最后 pong 时间。 如果超过 PONG_TIMEOUT_S 未收到 pong,触发重连。 """ while self._status == ChannelStatus.CONNECTED: await asyncio.sleep(WS_PING_INTERVAL_S) if self._status != ChannelStatus.CONNECTED: break elapsed = time.monotonic() - self._last_pong_at if elapsed > WS_PING_INTERVAL_S + WS_PONG_TIMEOUT_S: logger.warning(f"[Mattermost] WS heartbeat timeout: last pong {elapsed:.0f}s ago, reconnecting") self._last_error = f"WS heartbeat timeout after {elapsed:.0f}s" if self._ws_reconnect_task is None or self._ws_reconnect_task.done(): self._ws_reconnect_task = asyncio.create_task(self._ws_reconnect()) async def _ws_reconnect(self) -> None: if self._status != ChannelStatus.CONNECTED: self._ws_reconnect_task = None return self._ws_reconnect_attempt += 1 if self._ws_reconnect_attempt > WS_RECONNECT_MAX_ATTEMPTS: logger.error(f"[Mattermost] WS reconnect max attempts ({WS_RECONNECT_MAX_ATTEMPTS}) reached, giving up") self._status = ChannelStatus.ERROR self._ws_reconnect_task = None return delay = min(WS_RECONNECT_BASE_DELAY_S * (2 ** (self._ws_reconnect_attempt - 1)), WS_RECONNECT_MAX_DELAY_S) jitter = delay * WS_RECONNECT_JITTER * (2 * random.random() - 1) delay = max(0.5, delay + jitter) logger.info( f"[Mattermost] WebSocket reconnecting in {delay:.1f}s " f"(attempt {self._ws_reconnect_attempt}/{WS_RECONNECT_MAX_ATTEMPTS})" ) await asyncio.sleep(delay) self._connected_event.clear() try: await self._start_ws_monitor() await asyncio.wait_for(self._connected_event.wait(), timeout=WS_CONNECT_TIMEOUT_S) logger.info("[Mattermost] WebSocket reconnected successfully") self._ws_reconnect_task = None except Exception as e: logger.error(f"[Mattermost] WebSocket reconnect failed: {e}") if self._status == ChannelStatus.CONNECTED: self._ws_reconnect_task = asyncio.create_task(self._ws_reconnect()) else: self._ws_reconnect_task = None # ─── ChannelMessageActionProtocol ─────────────────────────────────────── def supports_action(self, action: str) -> bool: return action in ("send", "react", "edit", "delete") def resolve_execution_mode(self, action: str) -> str: return "queue" async def handle_action(self, ctx) -> DeliveryResult: action = ctx.action if action == "react": return await self.send_reaction(ctx.chat_id, ctx.msg_id, ctx.get("emoji", "")) if action == "edit": return await self.edit_message(ctx.chat_id, ctx.msg_id, ctx.get("content", "")) if action == "delete": return await self.delete_message(ctx.chat_id, ctx.msg_id) return DeliveryResult(success=False, error=f"Unknown action: {action}") def extract_target_from_args(self, args: dict) -> dict | None: return {"chat_id": args.get("chat_id")} async def handle_poll_vote(self, poll_id: str, vote: str, user_id: str) -> dict: """处理投票交互。 使用 PollResultTracker 跟踪投票结果并返回摘要。 """ if not self._is_connected(): return {"error": "Not connected"} from .poll_handler import get_poll_tracker tracker = get_poll_tracker() recorded = tracker.record_vote(poll_id, vote, user_id) if not recorded: return {"status": "ok", "warning": "Poll not found or already ended"} results = tracker.get_results(poll_id) summary = tracker.format_results_message(poll_id) if results else "" try: self._driver.reactions.create_reaction( options={ "user_id": self._bot_user_id, "post_id": poll_id, "emoji_name": vote, } ) except Exception as e: logger.error(f"[Mattermost] Poll vote reaction failed for {poll_id}: {e}") return { "status": "ok", "poll_id": poll_id, "vote": vote, "user_id": user_id, "summary": summary, } def describe_message_tool(self) -> dict: from .agent_tools import describe_mattermost_message_tool return describe_mattermost_message_tool() # ─── ChannelConfigProtocol ────────────────────────────────────────────── def resolve_account(self, account_id: str) -> dict | None: from .accounts import MultiAccountConfig ma = MultiAccountConfig.from_config(self.config) account = ma.resolve_account(account_id) if account is None: return None return { "account_id": account.account_id, "server_url": account.server_url, "enabled": account.enabled, "nick": account.nick, } def is_enabled(self) -> bool: from .accounts import MultiAccountConfig ma = MultiAccountConfig.from_config(self.config) return len(ma.list_configured_accounts()) > 0 def is_configured(self) -> bool: server_url = self._resolve_server_url() bot_token = self._resolve_bot_token() return bool(server_url and bot_token) def list_account_ids(self) -> list[str]: from .accounts import MultiAccountConfig ma = MultiAccountConfig.from_config(self.config) return ma.list_account_ids() def default_account_id(self) -> str: from .accounts import MultiAccountConfig ma = MultiAccountConfig.from_config(self.config) return ma.default_account_id def disabled_reason(self) -> str: if self._status == ChannelStatus.DISABLED: return self._last_error or "Channel is disabled by configuration" return "" def unconfigured_reason(self) -> str: if not self.is_configured(): return "Missing server_url or bot_token" return "" def describe_account(self) -> dict: return { "channel_id": self.channel_id, "channel_type": str(self.channel_type.value), "bot_username": self._bot_username, "server_url": self._server_url, "token_source": self._token_source, "dm_policy": self._security.dm_policy, "group_policy": self._security.group_policy, } def resolve_allow_from(self) -> list[str]: return list(self._security._config.allow_from) def has_configured_state(self) -> bool: return self.is_configured() def write_config(self, key: str, value: Any) -> bool: return self._config_writes.write_config(key, value) def add_to_allowlist(self, target: str, list_type: str = "dm") -> bool: return self._config_writes.add_to_allowlist(target, list_type) def remove_from_allowlist(self, target: str, list_type: str = "dm") -> bool: return self._config_writes.remove_from_allowlist(target, list_type) def get_writeable_config(self) -> dict[str, Any]: return self._config_writes.get_config() def resolve_agent_route( self, channel_id: str, team_id: str | None = None, user_id: str | None = None, ) -> dict[str, Any]: return resolve_agent_route(self.config, channel_id, team_id, user_id) def requires_approval(self, action: str) -> bool: return self._approval_manager.requires_approval(action) def create_approval_request( self, action: str, description: str, params: dict[str, Any], from_user_id: str = "", from_channel_id: str = "", ) -> ApprovalRequest: return self._approval_manager.create_request(action, description, params, from_user_id, from_channel_id) def process_approval(self, request_id: str, approved: bool) -> bool: if approved: return self._approval_manager.approve(request_id) return self._approval_manager.deny(request_id) def get_approval_request(self, request_id: str) -> ApprovalRequest | None: return self._approval_manager.get_request(request_id) # ─── ChannelDirectoryProtocol ─────────────────────────────────────────── async def self_info(self) -> dict: return { "user_id": self._bot_user_id, "username": self._bot_username, "server_url": self._server_url, } async def list_peers(self, cached: bool = True) -> list[dict]: if not self._is_connected(): return [] try: teams = self._driver.teams.get_user_teams(user_id=self._bot_user_id) peers: dict[str, dict] = {} for team in teams: team_id = team.get("id", "") try: users = self._driver.users.get_users(params={"in_team": team_id, "per_page": 200}) for u in users: uid = u.get("id", "") if uid and uid not in peers: peers[uid] = { "user_id": uid, "username": u.get("username", ""), "nickname": u.get("nickname", ""), "email": u.get("email", ""), "first_name": u.get("first_name", ""), "last_name": u.get("last_name", ""), } except Exception: pass return list(peers.values()) except Exception: return [] async def list_groups(self, cached: bool = True) -> list[dict]: if not self._is_connected(): return [] try: teams = self._driver.teams.get_user_teams(user_id=self._bot_user_id) groups: dict[str, dict] = {} for team in teams: team_id = team.get("id", "") try: channels = self._driver.channels.get_channels_for_user( user_id=self._bot_user_id, team_id=team_id, ) for ch in channels: cid = ch.get("id", "") if cid and cid not in groups: groups[cid] = { "channel_id": cid, "name": ch.get("name", ""), "display_name": ch.get("display_name", ""), "group_type": ch.get("type", "O"), "purpose": ch.get("purpose", ""), } except Exception: pass return list(groups.values()) except Exception: return [] async def list_group_members(self, group_id: str) -> list[dict]: if not self._is_connected(): return [] try: members = self._driver.channels.get_channel_members( channel_id=group_id, params={"per_page": 200}, ) result = [] for m in members: uid = m.get("user_id", "") if uid: try: user_info = self._driver.users.get_user(user_id=uid) result.append( { "user_id": uid, "username": user_info.get("username", ""), } ) except Exception: result.append({"user_id": uid, "username": ""}) return result except Exception: return [] # ─── ChannelMessagingProtocol ─────────────────────────────────────────── def normalize_target(self, raw: Any) -> str: from .target_resolution import parse_target return parse_target(raw) def resolve_inbound_conversation(self, from_: str, to: str, thread_id: str) -> dict: return { "from": from_, "to": to, "thread_id": thread_id, "chat_id": to or from_, } def resolve_delivery_target(self, conversation_id: str) -> dict: """解析投递目标 — 支持父/子会话路由。 如果 conversation_id 包含线程信息(parentConversationId), 则路由到父频道的线程回复。 """ result: dict[str, Any] = {"chat_id": conversation_id} parent_conv_id = self.config.get("parentConversationId", "") if self.config else "" if parent_conv_id and conversation_id.startswith("thread_"): result["chat_id"] = parent_conv_id result["thread_id"] = conversation_id return result def infer_target_chat_type(self, to: str) -> str: if to.startswith("user:"): return "direct" if to.startswith("channel:"): return "channel" if len(to) == 26: return "channel" return "direct" def parse_explicit_target(self, raw: str) -> dict: from .target_resolution import parse_target target = parse_target(raw) return {"chat_id": target, "raw": raw} if target else {} def transform_reply_payload(self, payload: dict) -> dict: return payload def resolve_outbound_session_route(self, target: dict) -> dict: return {"chat_id": target.get("chat_id", ""), "chat_type": target.get("chat_type", "direct")} # ─── ChannelSetupProtocol ─────────────────────────────────────────────── async def apply_account_config(self, config: dict) -> None: self.config.update(config) async def validate_account_input(self, input_: dict) -> list[str]: from .config_schema import validate_config_schema return validate_config_schema(input_) async def after_config_written(self, config: dict) -> None: pass # ─── ChannelStatusProtocol ────────────────────────────────────────────── def snapshot(self) -> dict[str, Any]: return { "channel_id": self.channel_id, "status": str(self._status.value) if hasattr(self._status, "value") else str(self._status), "connected": self._is_connected(), "bot_username": self._bot_username, "bot_id": self._bot_user_id, "server_url": self._server_url, "token_source": self._token_source, "last_error": self._last_error, "last_disconnect": self._last_disconnect, "reconnect_attempts": self._ws_reconnect_attempt, "dm_policy": self._security.dm_policy, "group_policy": self._security.group_policy, "allow_from_count": self._security.allow_from_count, "sent_cache_size": self._sent_cache.size(), "debouncer_entries": len(self._inbound_debouncer._entries), "ws_events_captured": len(self._ws_event_capture), "debug_proxy_enabled": bool(self._debug_proxy_url), "approval_enabled": self._approval_manager.enabled, "config_writes_enabled": self._config_writes.is_config_writes_enabled(), } @property def status(self) -> str: return str(self._status.value) if hasattr(self._status, "value") else str(self._status) async def probe_account(self, account: dict, timeout_ms: int) -> dict: server_url = account.get("server_url", self._server_url) bot_token = account.get("bot_token", self._resolve_bot_token()) if not server_url or not bot_token: return {"status": "error", "message": "Missing server_url or bot_token"} from .accounts import MattermostAccount acct = MattermostAccount.from_config_entry(account.get("account_id", "default"), account) return { "server_url": server_url, "configured": acct.configured, "enabled": acct.enabled, } async def build_account_snapshot(self, account: dict, probe: dict) -> ChannelAccountSnapshot: return ChannelAccountSnapshot( account_id=account.get("account_id", "default"), name=account.get("name", ""), configured=bool(account.get("server_url") and account.get("bot_token")), enabled=account.get("enabled", True), status_state="configured" if probe else "not-configured", bot_token_source=self._token_source, server_url=account.get("server_url", ""), dm_policy=account.get("dm_policy", "open"), group_policy=account.get("group_policy", "open"), ) def build_channel_summary(self, account: dict, snapshot: dict) -> dict: return { "channel": "mattermost", "account_id": account.get("account_id", "default"), "connected": self._is_connected(), "bot_username": self._bot_username, "server_url": self._server_url, } async def audit_account(self, account: dict, timeout_ms: int) -> dict: return { "server_url": self._server_url, "token_source": self._token_source, "dm_policy": self._security.dm_policy, "group_policy": self._security.group_policy, } def resolve_account_state(self, configured: bool, enabled: bool) -> str: if not configured: return "not-configured" if not enabled: return "disabled" return "ready" def collect_status_issues(self, accounts: list) -> list[str]: issues = [] if not accounts: issues.append("No accounts configured") if not self._is_connected(): issues.append(f"Not connected (status: {self._status})") if self._last_error: issues.append(f"Last error: {self._last_error}") return issues # ─── ChannelThreadingProtocol ─────────────────────────────────────────── def resolve_reply_mode(self, config: dict[str, Any], chat_type: str) -> str: return self._reply_manager.mode def resolve_thread_id(self, message: ChannelMessage) -> str | None: root_id = message.metadata.get("root_id") or message.reply_to_message_id if root_id: return f"thread_{root_id}" return None def build_tool_context(self, context: dict) -> dict: return {"reply_mode": self._reply_manager.mode, "thread_only": self._reply_manager.thread_only} def resolve_auto_thread_id(self, to: str, reply_to_id: str) -> str | None: if reply_to_id: return f"thread_{reply_to_id}" return None def resolve_reply_transport(self, thread_id: str, reply_to_id: str) -> dict: return {"thread_id": thread_id, "reply_to_message_id": reply_to_id} # ─── ChannelPairingProtocol ───────────────────────────────────────────── async def start_pairing(self, device_id: str) -> str: mgr = self._pairing_manager result = mgr.check_or_request(device_id) return result.code async def verify_pairing(self, device_id: str, code: str) -> bool: return self._pairing_manager.approve(device_id, code) async def revoke_pairing(self, device_id: str) -> None: self._pairing_manager.deny(device_id) async def list_paired_devices(self) -> list[dict]: approved = self._pairing_manager.list_approved() return [{"user_id": r.user_id, "code": r.code} for r in approved]