ForcePilot/backend/package/yuxi/channels/adapters/mattermost/adapter.py
Kris 002d601d1b feat(mattermost): 实现完整的 Mattermost 适配器模块
新增 Mattermost 渠道完整实现,包含适配器核心、消息处理、交互回调、命令支持、安全校验、多账号管理等功能,支持机器人消息发送、交互按钮、命令注册、投票功能以及配置动态修改等特性。
2026-05-12 00:46:12 +08:00

1709 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]