新增 Mattermost 渠道完整实现,包含适配器核心、消息处理、交互回调、命令支持、安全校验、多账号管理等功能,支持机器人消息发送、交互按钮、命令注册、投票功能以及配置动态修改等特性。
1709 lines
65 KiB
Python
1709 lines
65 KiB
Python
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]
|