From d7fe152daef43af111ca34286762edad1b92c4b8 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Wed, 13 May 2026 16:12:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(mattermost):=20=E5=AE=8C=E6=88=90=20Matter?= =?UTF-8?q?most=20=E9=80=82=E9=85=8D=E5=99=A8=E5=A4=9A=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=8E=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交对 Mattermost 适配器进行了全面升级: 1. 重构为多账号架构,支持同时管理多个 Mattermost 机器人账号 2. 更新安全策略默认配置为配对模式和白名单模式 3. 新增 WebSocket 心跳、重连配置项与连接监控 4. 扩展 Agent 工具支持 pin/unpin、获取反应、搜索消息等操作 5. 重构交互按钮构建逻辑,新增分页与提供商筛选功能 6. 优化 SSRF 防护代码,复用公共工具库实现 7. 新增配置兼容性迁移与可变白名单项检测 8. 完善错误处理与日志输出,添加重复消息去重统计 9. 新增发送临时消息(ephemeral)支持 10. 修复提及检测逻辑,正确处理用户名大小写 --- .../channels/adapters/mattermost/adapter.py | 410 ++++++++++++++---- .../adapters/mattermost/agent_tools.py | 114 ++++- .../channels/adapters/mattermost/client.py | 60 +-- .../adapters/mattermost/config_schema.py | 39 +- .../channels/adapters/mattermost/doctor.py | 120 ++++- .../adapters/mattermost/interactions.py | 36 +- .../adapters/mattermost/model_picker.py | 175 +++++++- .../adapters/mattermost/normalizer.py | 2 +- .../channels/adapters/mattermost/security.py | 12 +- .../channels/adapters/mattermost/slash.py | 101 +++++ 10 files changed, 907 insertions(+), 162 deletions(-) diff --git a/backend/package/yuxi/channels/adapters/mattermost/adapter.py b/backend/package/yuxi/channels/adapters/mattermost/adapter.py index fb2606b1..48eb16df 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/adapter.py +++ b/backend/package/yuxi/channels/adapters/mattermost/adapter.py @@ -38,6 +38,7 @@ 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 .accounts import MultiAccountConfig from .agent_route import resolve_agent_route from .approval import ApprovalManager, ApprovalRequest from .cache import MattermostChannelCache, SentMessageCache @@ -170,6 +171,11 @@ class MattermostAdapter(BaseChannelAdapter): threads=True, block_streaming=True, native_commands=True, + typing=True, + pin=True, + unpin=True, + list_pins=True, + send_ephemeral=True, supports_markdown=True, supports_streaming=True, streaming_modes=["off", "partial", "block", "progress"], @@ -181,8 +187,8 @@ class MattermostAdapter(BaseChannelAdapter): 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._drivers: dict[str, Driver] = {} + self._ws_tasks: dict[str, asyncio.Task] = {} self._connected_event = asyncio.Event() self._connected_at: float | None = None self._bot_user_id: str = "" @@ -202,15 +208,26 @@ class MattermostAdapter(BaseChannelAdapter): 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._ws_ping_interval_s = float(config.get("ws_ping_interval_s", WS_PING_INTERVAL_S)) + self._ws_pong_timeout_s = float(config.get("ws_pong_timeout_s", WS_PONG_TIMEOUT_S)) + self._ws_reconnect_jitter = float(config.get("ws_reconnect_jitter", WS_RECONNECT_JITTER)) + self._ws_max_reconnect_attempts = int(config.get("ws_max_reconnect_attempts", WS_RECONNECT_MAX_ATTEMPTS)) + self._ws_reconnect_max_delay_s = float(config.get("ws_reconnect_max_delay_s", WS_RECONNECT_MAX_DELAY_S)) + else: + self._ws_ping_interval_s = WS_PING_INTERVAL_S + self._ws_pong_timeout_s = WS_PONG_TIMEOUT_S + self._ws_reconnect_jitter = WS_RECONNECT_JITTER + self._ws_max_reconnect_attempts = WS_RECONNECT_MAX_ATTEMPTS + self._ws_reconnect_max_delay_s = WS_RECONNECT_MAX_DELAY_S 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._pairing_manager = MattermostPairingManager() self._security = MattermostSecurity(config, pairing_manager=self._pairing_manager) self._mention_gate = MentionGate(config) self._reply_manager = ReplyManager(config) - self._pairing_manager = MattermostPairingManager() self._poll_tracker = PollResultTracker() self._message_queue: asyncio.Queue[ChannelMessage] = asyncio.Queue() self._last_error: str | None = None @@ -234,64 +251,103 @@ class MattermostAdapter(BaseChannelAdapter): self._config_writes.register_observer(self._reply_manager.reload_config) self._ws_event_capture: list[dict] = [] self._ws_event_capture_enabled = False + self._dedup_hit_count = 0 + self._dedup_total_count = 0 self._debug_proxy_url = os.getenv("OPENCLAW_DEBUG_PROXY", "") self._proxy_applied = False + self._account_configs = MultiAccountConfig.from_config(config or {}) + self._account_route: dict[str, str] = {} + self._ready_by_account: dict[str, asyncio.Event] = {} + + @property + def _driver(self) -> Driver | None: + default_id = self._account_configs.default_account_id + return self._drivers.get(default_id) + + def _get_driver(self, account_id: str | None = None) -> Driver | None: + aid = account_id or self._account_configs.default_account_id + return self._drivers.get(aid) def _is_connected(self) -> bool: - return self._status == ChannelStatus.CONNECTED and self._driver is not None + return self._status == ChannelStatus.CONNECTED and len(self._drivers) > 0 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: + self._account_configs = MultiAccountConfig.from_config(self.config) + configured_accounts = self._account_configs.list_configured_accounts() + if not configured_accounts: raise ChannelAuthenticationError( - "Mattermost server_url and bot_token must be configured.\n" + "No enabled Mattermost accounts 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) + self._status = ChannelStatus.CONNECTING + self._connected_event.clear() + logger.info( + f"[Mattermost] Starting channel '{self.config.get('name', self.channel_id)}'" + f" with {len(configured_accounts)} account(s)" + ) - driver_kwargs: dict[str, Any] = { - "url": self._server_url, - "token": bot_token, - "scheme": scheme, - "port": port, - "verify": True, - "timeout": 30, - } + primary_account = configured_accounts[0] + self._server_url = primary_account.server_url.rstrip("/") + self._bot_username = primary_account.nick - 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}") + for account in configured_accounts: + if not account.configured: + logger.warning(f"[Mattermost] Account '{account.account_id}' not fully configured, skipping") + continue - self._driver = Driver(driver_kwargs) + server_url = account.server_url.rstrip("/") + scheme, port = _parse_driver_scheme_port(server_url) - try: - await self._driver.login() - except Exception as e: - raise ChannelAuthenticationError(f"Mattermost login failed: {e}") from e + driver_kwargs: dict[str, Any] = { + "url": server_url, + "token": account.bot_token, + "scheme": scheme, + "port": port, + "verify": True, + "timeout": 30, + } - 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"] + 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}") - logger.info(f"[Mattermost] Bot verified: @{self._bot_username} (ID: {self._bot_user_id}), server: {server_url}") + driver = Driver(driver_kwargs) + self._drivers[account.account_id] = driver + + try: + await driver.login() + except Exception as e: + logger.error(f"[Mattermost] Login failed for account '{account.account_id}': {e}") + continue + + auth_user = driver.users.get_user(user_id="me") + if not auth_user: + logger.error(f"[Mattermost] Failed to verify token for account '{account.account_id}'") + continue + + if account.account_id == self._account_configs.default_account_id: + self._bot_user_id = auth_user["id"] + self._bot_username = auth_user["username"] + + logger.info( + f"[Mattermost/{account.account_id}] Bot verified: @{auth_user.get('username', '?')} " + f"(ID: {auth_user['id']}), server: {server_url}" + ) + + self._ready_by_account[account.account_id] = asyncio.Event() + await self._start_ws_monitor_for_account(account.account_id, driver) + + if not self._drivers: + raise ChannelAuthenticationError("All Mattermost account logins failed") 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: @@ -309,13 +365,16 @@ class MattermostAdapter(BaseChannelAdapter): self._status = ChannelStatus.CONNECTED self._connected_at = time.time() - logger.info(f"[Mattermost] Channel started, bot: @{self._bot_username}, server: {server_url}") + logger.info( + f"[Mattermost] Channel started with {len(self._drivers)} account(s), " + f"primary: @{self._bot_username}, server: {self._server_url}" + ) self._bot_health_task = asyncio.create_task(self._check_bot_health_periodically()) self._streaming_cleanup_task = asyncio.create_task(self._cleanup_stale_streams_periodically()) slash_cfg = self.config.get("slash_commands", {}) - if slash_cfg.get("auto_register"): + if slash_cfg.get("auto_register") and self._driver: from .slash_commands import register_slash_commands_across_teams asyncio.create_task( @@ -358,22 +417,25 @@ class MattermostAdapter(BaseChannelAdapter): self._inbound_debouncer.clear() self._ws_event_capture.clear() self._ws_reconnect_attempt = 0 + self._dedup_hit_count = 0 + self._dedup_total_count = 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 + for account_id, task in list(self._ws_tasks.items()): + if task and not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + self._ws_tasks.clear() - if self._driver: + for account_id, driver in list(self._drivers.items()): try: - await self._driver.logout() + await driver.logout() except Exception: pass + self._drivers.clear() - self._driver = None self._connected_at = None logger.info("[Mattermost] Channel stopped") @@ -406,6 +468,12 @@ class MattermostAdapter(BaseChannelAdapter): async def _send_single(self, response: ChannelResponse) -> DeliveryResult: options = build_post_options(response) + ephemeral_user_id = response.metadata.get("ephemeral_user_id") if response.metadata else None + if ephemeral_user_id: + options.setdefault("props", {}) + options["props"]["ephemeral"] = True + options["props"]["ephemeral_user_id"] = ephemeral_user_id + async def _do_send(): return self._driver.posts.create_post(options=options) @@ -443,7 +511,7 @@ class MattermostAdapter(BaseChannelAdapter): return DeliveryResult(success=True, metadata={"file_ids": file_ids}) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") - except Exception as e: + except (TimeoutError, httpx.HTTPError, ConnectionError) 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: @@ -469,6 +537,9 @@ class MattermostAdapter(BaseChannelAdapter): return DeliveryResult(success=True, message_id=msg_id) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") + except (TimeoutError, httpx.HTTPError, ConnectionError) as e: + logger.error(f"[Mattermost] Edit message failed for {msg_id}: {e}") + return DeliveryResult(success=False, error=str(e)) except Exception as e: logger.error(f"[Mattermost] Edit message failed for {msg_id}: {e}") return DeliveryResult(success=False, error=str(e)) @@ -485,6 +556,9 @@ class MattermostAdapter(BaseChannelAdapter): return DeliveryResult(success=True, message_id=msg_id) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") + except (TimeoutError, httpx.HTTPError, ConnectionError) as e: + logger.error(f"[Mattermost] Delete message failed for {msg_id}: {e}") + return DeliveryResult(success=False, error=str(e)) except Exception as e: logger.error(f"[Mattermost] Delete message failed for {msg_id}: {e}") return DeliveryResult(success=False, error=str(e)) @@ -510,6 +584,9 @@ class MattermostAdapter(BaseChannelAdapter): return DeliveryResult(success=True, message_id=msg_id) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") + except (TimeoutError, httpx.HTTPError, ConnectionError) as e: + logger.error(f"[Mattermost] Send reaction failed for {msg_id} ({emoji}): {e}") + return DeliveryResult(success=False, error=str(e)) except Exception as e: logger.error(f"[Mattermost] Send reaction failed for {msg_id} ({emoji}): {e}") return DeliveryResult(success=False, error=str(e)) @@ -529,6 +606,9 @@ class MattermostAdapter(BaseChannelAdapter): return DeliveryResult(success=True, message_id=msg_id) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") + except (TimeoutError, httpx.HTTPError, ConnectionError) as e: + logger.error(f"[Mattermost] Remove reaction failed for {msg_id} ({emoji}): {e}") + return DeliveryResult(success=False, error=str(e)) except Exception as e: logger.error(f"[Mattermost] Remove reaction failed for {msg_id} ({emoji}): {e}") return DeliveryResult(success=False, error=str(e)) @@ -548,6 +628,8 @@ class MattermostAdapter(BaseChannelAdapter): try: await self._circuit_breaker.call(_do_typing) return DeliveryResult(success=True) + except (TimeoutError, httpx.HTTPError, ConnectionError) as e: + return DeliveryResult(success=False, error=str(e)) except Exception as e: return DeliveryResult(success=False, error=str(e)) @@ -1095,9 +1177,11 @@ class MattermostAdapter(BaseChannelAdapter): def _dedup_check(self, post_id: str) -> bool: if not post_id: return False + self._dedup_total_count += 1 now = time.monotonic() if post_id in self._seen_posts: if now - self._seen_posts[post_id] < SEEN_POSTS_TTL_S: + self._dedup_hit_count += 1 return True self._seen_posts[post_id] = now if len(self._seen_posts) > SEEN_POSTS_MAX: @@ -1105,7 +1189,7 @@ class MattermostAdapter(BaseChannelAdapter): 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: + async def _handle_ws_message(self, event_name: str, data: dict, account_id: str | None = None) -> None: self._record_ws_event(event_name, data) post = self._parse_ws_post(data) @@ -1155,13 +1239,14 @@ class MattermostAdapter(BaseChannelAdapter): "event": event_name, "data": data, "broadcast": data.get("broadcast", {}), + "__account_id": account_id, } ) 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: + async def _handle_reaction_event(self, event_name: str, data: dict, account_id: str | None = None) -> None: self._record_ws_event(event_name, data) post = self._parse_ws_post(data) @@ -1174,36 +1259,34 @@ class MattermostAdapter(BaseChannelAdapter): "event": event_name, "data": data, "broadcast": data.get("broadcast", {}), + "__account_id": account_id, } ) 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 _start_ws_monitor_for_account(self, account_id: str, driver: Driver) -> None: async def on_posted(data: dict): - await self._handle_ws_message("posted", data) + await self._handle_ws_message("posted", data, account_id=account_id) async def on_post_edited(data: dict): - await self._handle_ws_message("post_edited", data) + await self._handle_ws_message("post_edited", data, account_id=account_id) async def on_post_deleted(data: dict): - await self._handle_ws_message("post_deleted", data) + await self._handle_ws_message("post_deleted", data, account_id=account_id) async def on_reaction_added(data: dict): - await self._handle_reaction_event("reaction_added", data) + await self._handle_reaction_event("reaction_added", data, account_id=account_id) async def on_reaction_removed(data: dict): - await self._handle_reaction_event("reaction_removed", data) + await self._handle_reaction_event("reaction_removed", data, account_id=account_id) async def on_error(data: dict): - logger.error(f"[Mattermost] WebSocket error: {data}") + logger.error(f"[Mattermost/{account_id}] WebSocket error: {data}") async def on_close(data: dict): - logger.warning(f"[Mattermost] WebSocket closed: {data}") + logger.warning(f"[Mattermost/{account_id}] WebSocket closed: {data}") self._last_disconnect = { "reason": data.get("reason", "unknown"), "code": data.get("code", 0), @@ -1215,22 +1298,64 @@ class MattermostAdapter(BaseChannelAdapter): self._ws_reconnect_task = asyncio.create_task(self._ws_reconnect()) async def on_connected(data: dict): - logger.info("[Mattermost] WebSocket connected") + logger.info(f"[Mattermost/{account_id}] WebSocket connected") self._ws_reconnect_attempt = 0 self._last_pong_at = time.monotonic() self._connected_event.set() + ready_evt = self._ready_by_account.get(account_id) + if ready_evt: + ready_evt.set() async def on_pong(data: dict): self._last_pong_at = time.monotonic() - self._ws_task = asyncio.create_task( - self._driver.init_websocket( + async def on_typing(data: dict): + logger.debug( + f"[Mattermost/{account_id}] User typing: channel={data.get('channel_id')}, user={data.get('user_id')}" + ) + self._record_ws_event("typing", data) + + async def on_status_change(data: dict): + logger.debug( + f"[Mattermost/{account_id}] Status change: user={data.get('user_id')}, status={data.get('status')}" + ) + self._record_ws_event("status_change", data) + + async def on_user_added(data: dict): + logger.info(f"[Mattermost/{account_id}] User added: team={data.get('team_id')}, user={data.get('user_id')}") + self._record_ws_event("user_added", data) + + async def on_user_removed(data: dict): + logger.info( + f"[Mattermost/{account_id}] User removed: team={data.get('team_id')}, user={data.get('user_id')}" + ) + self._record_ws_event("user_removed", data) + + async def on_channel_created(data: dict): + logger.info( + f"[Mattermost/{account_id}] Channel created: " + f"channel_id={data.get('channel_id')}, name={data.get('channel_name')}" + ) + self._record_ws_event("channel_created", data) + + async def on_channel_deleted(data: dict): + logger.info(f"[Mattermost/{account_id}] Channel deleted: channel_id={data.get('channel_id')}") + self._record_ws_event("channel_deleted", data) + + self._ws_tasks[account_id] = asyncio.create_task( + 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, + "typing": on_typing, + "status_change": on_status_change, + "user_added": on_user_added, + "user_removed": on_user_removed, + "channel_created": on_channel_created, + "channel_deleted": on_channel_deleted, "error": on_error, "close": on_close, "hello": on_connected, @@ -1245,12 +1370,12 @@ class MattermostAdapter(BaseChannelAdapter): 如果超过 PONG_TIMEOUT_S 未收到 pong,触发重连。 """ while self._status == ChannelStatus.CONNECTED: - await asyncio.sleep(WS_PING_INTERVAL_S) + await asyncio.sleep(self._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: + if elapsed > self._ws_ping_interval_s + self._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(): @@ -1262,25 +1387,42 @@ class MattermostAdapter(BaseChannelAdapter): 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") + if self._ws_reconnect_attempt > self._ws_max_reconnect_attempts: + logger.error( + f"[Mattermost] WS reconnect max attempts ({self._ws_max_reconnect_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 = min(WS_RECONNECT_BASE_DELAY_S * (2 ** (self._ws_reconnect_attempt - 1)), self._ws_reconnect_max_delay_s) + jitter = delay * self._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})" + f"(attempt {self._ws_reconnect_attempt}/{self._ws_max_reconnect_attempts})" ) await asyncio.sleep(delay) self._connected_event.clear() try: - await self._start_ws_monitor() + for account_id, task in list(self._ws_tasks.items()): + if task and not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + self._ws_tasks.clear() + + for account in self._account_configs.list_configured_accounts(): + driver = self._drivers.get(account.account_id) + if driver is None: + logger.warning(f"[Mattermost] No driver for account '{account.account_id}', skipping reconnect") + continue + await self._start_ws_monitor_for_account(account.account_id, driver) + await asyncio.wait_for(self._connected_event.wait(), timeout=WS_CONNECT_TIMEOUT_S) logger.info("[Mattermost] WebSocket reconnected successfully") self._ws_reconnect_task = None @@ -1291,10 +1433,112 @@ class MattermostAdapter(BaseChannelAdapter): else: self._ws_reconnect_task = None + # ─── Pin / Unpin / List Pins ───────────────────────────────────────── + + async def pin_message(self, post_id: str) -> bool: + if not self._is_connected(): + return False + + async def _do_pin(): + return await self._driver.posts.pin_post(post_id) + + try: + await self._circuit_breaker.call(_do_pin) + return True + except Exception as e: + logger.error(f"[Mattermost] Pin message failed for {post_id}: {e}") + return False + + async def unpin_message(self, post_id: str) -> bool: + if not self._is_connected(): + return False + + async def _do_unpin(): + return await self._driver.posts.unpin_post(post_id) + + try: + await self._circuit_breaker.call(_do_unpin) + return True + except Exception as e: + logger.error(f"[Mattermost] Unpin message failed for {post_id}: {e}") + return False + + async def list_pinned_messages(self, channel_id: str) -> list[dict]: + if not self._is_connected(): + return [] + + async def _do_list(): + return await self._driver.posts.get_pinned_posts(channel_id) + + try: + return await self._circuit_breaker.call(_do_list) + except Exception as e: + logger.error(f"[Mattermost] List pinned messages failed for {channel_id}: {e}") + return [] + + # ─── Reaction 批量获取 ──────────────────────────────────────────────── + + async def get_reactions(self, post_id: str) -> list[dict]: + if not self._is_connected(): + return [] + + async def _do_get(): + return await self._driver.reactions.get_reactions(post_id) + + try: + return await self._circuit_breaker.call(_do_get) + except Exception as e: + logger.error(f"[Mattermost] Get reactions failed for {post_id}: {e}") + return [] + + # ─── 消息搜索 ───────────────────────────────────────────────────────── + + async def search_messages(self, team_id: str, terms: str, is_or_search: bool = False) -> list[dict]: + if not self._is_connected(): + return [] + + async def _do_search(): + return await self._driver.posts.search_posts( + team_id=team_id, + terms=terms, + is_or_search=is_or_search, + ) + + try: + result = await self._circuit_breaker.call(_do_search) + if isinstance(result, dict): + return result.get("posts", []) + return result if isinstance(result, list) else [] + except Exception as e: + logger.error(f"[Mattermost] Search messages failed: {e}") + return [] + + # ─── 线程历史获取 ───────────────────────────────────────────────────── + + async def get_thread_replies(self, post_id: str) -> list[dict]: + if not self._is_connected(): + return [] + + async def _do_get_thread(): + return await self._driver.posts.get_thread(post_id) + + try: + result = await self._circuit_breaker.call(_do_get_thread) + if isinstance(result, dict): + posts = result.get("posts", result.get("order", [])) + if isinstance(posts, list): + return posts + if isinstance(posts, dict): + return list(posts.values()) + return result if isinstance(result, list) else [] + except Exception as e: + logger.error(f"[Mattermost] Get thread replies failed for {post_id}: {e}") + return [] + # ─── ChannelMessageActionProtocol ─────────────────────────────────────── def supports_action(self, action: str) -> bool: - return action in ("send", "react", "edit", "delete") + return action in ("send", "react", "edit", "delete", "pin", "unpin") def resolve_execution_mode(self, action: str) -> str: return "queue" @@ -1307,6 +1551,12 @@ class MattermostAdapter(BaseChannelAdapter): 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) + if action == "pin": + result = await self.pin_message(ctx.msg_id) + return DeliveryResult(success=result, message_id=ctx.msg_id) + if action == "unpin": + result = await self.unpin_message(ctx.msg_id) + return DeliveryResult(success=result, message_id=ctx.msg_id) return DeliveryResult(success=False, error=f"Unknown action: {action}") def extract_target_from_args(self, args: dict) -> dict | None: @@ -1320,8 +1570,6 @@ class MattermostAdapter(BaseChannelAdapter): if not self._is_connected(): return {"error": "Not connected"} - from .poll_handler import get_poll_tracker - tracker = self._poll_tracker recorded = tracker.record_vote(poll_id, vote, user_id) @@ -1620,6 +1868,7 @@ class MattermostAdapter(BaseChannelAdapter): # ─── ChannelStatusProtocol ────────────────────────────────────────────── def snapshot(self) -> dict[str, Any]: + dedup_hit_rate = self._dedup_hit_count / self._dedup_total_count if self._dedup_total_count > 0 else 0.0 return { "channel_id": self.channel_id, "status": str(self._status.value) if hasattr(self._status, "value") else str(self._status), @@ -1640,6 +1889,11 @@ class MattermostAdapter(BaseChannelAdapter): "debug_proxy_enabled": bool(self._debug_proxy_url), "approval_enabled": self._approval_manager.enabled, "config_writes_enabled": self._config_writes.is_config_writes_enabled(), + "dedup_hit_rate": round(dedup_hit_rate, 4), + "dedup_hit_count": self._dedup_hit_count, + "dedup_total_count": self._dedup_total_count, + "ws_ping_interval_s": self._ws_ping_interval_s, + "ws_pong_timeout_s": self._ws_pong_timeout_s, } @property diff --git a/backend/package/yuxi/channels/adapters/mattermost/agent_tools.py b/backend/package/yuxi/channels/adapters/mattermost/agent_tools.py index abab3cbb..02d600a1 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/agent_tools.py +++ b/backend/package/yuxi/channels/adapters/mattermost/agent_tools.py @@ -8,13 +8,13 @@ def describe_mattermost_message_tool() -> dict: "type": "function", "function": { "name": "mattermost_send_message", - "description": "通过 Mattermost 渠道发送消息、编辑消息、删除消息或添加反应", + "description": "通过 Mattermost 渠道发送消息、编辑消息、删除消息、添加反应、固定/取消固定消息", "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["send", "react", "edit", "delete"], + "enum": ["send", "react", "edit", "delete", "pin", "unpin"], "description": "要执行的操作类型", }, "chat_id": { @@ -27,7 +27,7 @@ def describe_mattermost_message_tool() -> dict: }, "msg_id": { "type": "string", - "description": "消息 ID(edit、delete 和 react 操作需要)", + "description": "消息 ID(edit、delete、react、pin 和 unpin 操作需要)", }, "emoji": { "type": "string", @@ -40,16 +40,91 @@ def describe_mattermost_message_tool() -> dict: } +def describe_mattermost_get_reactions_tool() -> dict: + return { + "type": "function", + "function": { + "name": "mattermost_get_reactions", + "description": "获取指定消息的所有 Reaction 列表", + "parameters": { + "type": "object", + "properties": { + "msg_id": { + "type": "string", + "description": "要查询的消息 ID", + }, + }, + "required": ["msg_id"], + }, + }, + } + + +def describe_mattermost_list_pins_tool() -> dict: + return { + "type": "function", + "function": { + "name": "mattermost_list_pins", + "description": "获取频道中的固定消息列表", + "parameters": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "description": "频道 ID", + }, + }, + "required": ["channel_id"], + }, + }, + } + + +def describe_mattermost_search_messages_tool() -> dict: + return { + "type": "function", + "function": { + "name": "mattermost_search_messages", + "description": "在 Mattermost 中搜索消息", + "parameters": { + "type": "object", + "properties": { + "team_id": { + "type": "string", + "description": "Team ID", + }, + "terms": { + "type": "string", + "description": "搜索关键词", + }, + }, + "required": ["team_id", "terms"], + }, + }, + } + + class MattermostAgentToolFactory: def __init__(self, adapter: Any): self._adapter = adapter def list_tools(self) -> list[dict]: - return [describe_mattermost_message_tool()] + return [ + describe_mattermost_message_tool(), + describe_mattermost_get_reactions_tool(), + describe_mattermost_list_pins_tool(), + describe_mattermost_search_messages_tool(), + ] async def execute_tool(self, tool_name: str, params: dict) -> Any: if tool_name == "mattermost_send_message": return await self._execute_message_action(params) + if tool_name == "mattermost_get_reactions": + return await self._execute_get_reactions(params) + if tool_name == "mattermost_list_pins": + return await self._execute_list_pins(params) + if tool_name == "mattermost_search_messages": + return await self._execute_search_messages(params) raise ValueError(f"Unknown tool: {tool_name}") async def _execute_message_action(self, params: dict) -> dict: @@ -98,4 +173,35 @@ class MattermostAgentToolFactory: ) return {"success": result.success, "error": result.error} + if action == "pin": + result = await self._adapter.pin_message( + post_id=params.get("msg_id", ""), + ) + return {"success": result, "error": "" if result else "Pin failed"} + + if action == "unpin": + result = await self._adapter.unpin_message( + post_id=params.get("msg_id", ""), + ) + return {"success": result, "error": "" if result else "Unpin failed"} + return {"success": False, "error": f"Unknown action: {action}"} + + async def _execute_get_reactions(self, params: dict) -> dict: + result = await self._adapter.get_reactions( + post_id=params.get("msg_id", ""), + ) + return {"success": True, "reactions": result} + + async def _execute_list_pins(self, params: dict) -> dict: + result = await self._adapter.list_pinned_messages( + channel_id=params.get("channel_id", ""), + ) + return {"success": True, "pins": result} + + async def _execute_search_messages(self, params: dict) -> dict: + result = await self._adapter.search_messages( + team_id=params.get("team_id", ""), + terms=params.get("terms", ""), + ) + return {"success": True, "results": result} diff --git a/backend/package/yuxi/channels/adapters/mattermost/client.py b/backend/package/yuxi/channels/adapters/mattermost/client.py index 568cabf3..905b87ae 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/client.py +++ b/backend/package/yuxi/channels/adapters/mattermost/client.py @@ -1,57 +1,20 @@ from __future__ import annotations -import ipaddress -import socket from urllib.parse import urlparse -_PRIVATE_NETWORKS = [ - ipaddress.ip_network("10.0.0.0/8"), - ipaddress.ip_network("172.16.0.0/12"), - ipaddress.ip_network("192.168.0.0/16"), - ipaddress.ip_network("127.0.0.0/8"), - ipaddress.ip_network("169.254.0.0/16"), - ipaddress.ip_network("fc00::/7"), - ipaddress.ip_network("::1/128"), -] +from yuxi.channels.auth.ssrf_guard import ( + is_private_url as _is_private_url, +) +from yuxi.channels.auth.ssrf_guard import ( + validate_url_with_whitelist, +) def is_private_url(url: str) -> bool: - """检查 URL 是否指向内网地址 — SSRF 防护。""" - if not url: - return False - - try: - parsed = urlparse(url) - hostname = parsed.hostname - if not hostname: - return False - - if hostname in ("localhost", "localhost.localdomain"): - return True - - addr = ipaddress.ip_address(hostname) - for network in _PRIVATE_NETWORKS: - if addr in network: - return True - return False - except ValueError: - try: - resolved = socket.getaddrinfo(hostname, None) - for family, _, _, _, sockaddr in resolved: - ip = sockaddr[0] - addr = ipaddress.ip_address(ip) - for network in _PRIVATE_NETWORKS: - if addr in network: - return True - except socket.gaierror: - pass - return False - - return False + return _is_private_url(url) def validate_url_safety(url: str, dangerously_allow_private: bool = False) -> tuple[bool, str]: - """验证 URL 安全性。返回 (is_safe, reason)。""" if not url: return False, "URL is empty" @@ -65,6 +28,15 @@ def validate_url_safety(url: str, dangerously_allow_private: bool = False) -> tu return True, "ok" +def validate_mattermost_server_url(url: str, dangerously_allow_private: bool = False) -> str: + return validate_url_with_whitelist( + url, + allowed_hosts=None, + enforce_https=True, + allow_private=dangerously_allow_private, + ) + + def check_dangerously_allow_private_network(config: dict) -> bool: return bool(config.get("network", {}).get("dangerouslyAllowPrivateNetwork", False)) diff --git a/backend/package/yuxi/channels/adapters/mattermost/config_schema.py b/backend/package/yuxi/channels/adapters/mattermost/config_schema.py index e0015c2b..1e7a642d 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/config_schema.py +++ b/backend/package/yuxi/channels/adapters/mattermost/config_schema.py @@ -19,13 +19,13 @@ MATTERMOST_CONFIG_SCHEMA = { "dm_policy": { "type": "string", "enum": ["open", "allowlist", "pairing", "disabled"], - "default": "open", + "default": "pairing", "description": "DM 安全策略", }, "group_policy": { "type": "string", "enum": ["open", "allowlist", "disabled"], - "default": "open", + "default": "allowlist", "description": "群组安全策略", }, "allow_from": { @@ -156,6 +156,41 @@ MATTERMOST_CONFIG_SCHEMA = { }, "description": "网络配置", }, + "ws_ping_interval_s": { + "type": "number", + "minimum": 5.0, + "maximum": 300.0, + "default": 30.0, + "description": "WebSocket 心跳发送间隔 (秒)", + }, + "ws_pong_timeout_s": { + "type": "number", + "minimum": 3.0, + "maximum": 60.0, + "default": 10.0, + "description": "WebSocket Pong 超时阈值 (秒)", + }, + "ws_reconnect_jitter": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "default": 0.2, + "description": "重连退避抖动系数", + }, + "ws_max_reconnect_attempts": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10, + "description": "最大重连尝试次数", + }, + "ws_reconnect_max_delay_s": { + "type": "number", + "minimum": 5.0, + "maximum": 600.0, + "default": 120.0, + "description": "重连最大延迟 (秒)", + }, "configWrites": { "type": "boolean", "default": False, diff --git a/backend/package/yuxi/channels/adapters/mattermost/doctor.py b/backend/package/yuxi/channels/adapters/mattermost/doctor.py index dc2b1c8d..6af0ba9f 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/doctor.py +++ b/backend/package/yuxi/channels/adapters/mattermost/doctor.py @@ -1,8 +1,109 @@ from __future__ import annotations import os +import re +from dataclasses import dataclass, field from typing import Any +from yuxi.utils.logging_config import logger + +_EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") +_UUID_PATTERN = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +@dataclass +class LegacyConfigRule: + legacy_key: str + new_key: str + description: str + auto_migrate: bool = True + + +LEGACY_CONFIG_RULES: list[LegacyConfigRule] = [ + LegacyConfigRule("slash_commands", "commands", "slash_commands -> commands", True), + LegacyConfigRule("allowed_channels", "allowFrom", "allowed_channels -> allowFrom", True), + LegacyConfigRule("dm_allowed_users", "dmAllowFrom", "dm_allowed_users -> dmAllowFrom", True), + LegacyConfigRule("group_allowed_channels", "groupAllowFrom", "group_allowed_channels -> groupAllowFrom", True), + LegacyConfigRule("bot_name", "nick", "bot_name -> nick", True), +] + + +def is_mutable_allowlist_entry(entry: str) -> bool: + entry = entry.strip() + if not entry: + return False + if entry == "*": + return True + if _UUID_PATTERN.match(entry): + return False + if _EMAIL_PATTERN.match(entry): + return True + has_spaces = " " in entry + return has_spaces or len(entry) < 36 + + +def collect_mutable_allowlist_warnings( + allow_from: list[str] | None = None, + group_allow_from: list[str] | None = None, +) -> list[dict[str, Any]]: + warnings: list[dict[str, Any]] = [] + + for entry in allow_from or []: + if is_mutable_allowlist_entry(entry): + warnings.append( + { + "type": "mutable_allowlist", + "source": "allow_from", + "entry": entry, + "severity": "warning", + "message": f"Allowlist entry '{entry}' is mutable (display name/email). Consider using a stable ID.", + } + ) + + for entry in group_allow_from or []: + if is_mutable_allowlist_entry(entry): + warnings.append( + { + "type": "mutable_allowlist", + "source": "group_allow_from", + "entry": entry, + "severity": "warning", + "message": f"Group allowlist entry '{entry}' is mutable. Consider using a stable ID.", + } + ) + + if warnings: + logger.warning(f"Mattermost Doctor: {len(warnings)} mutable allowlist items detected") + + return warnings + + +def normalize_compatibility_config(config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + changes: list[str] = [] + normalized = dict(config) + + for rule in LEGACY_CONFIG_RULES: + if rule.legacy_key in normalized and rule.new_key not in normalized: + normalized[rule.new_key] = normalized[rule.legacy_key] + if rule.auto_migrate: + changes.append(f"Migrated {rule.description}") + + return normalized, changes + + +@dataclass +class DiagnoseResult: + status: str = "ok" + checks: dict[str, bool] = field(default_factory=dict) + issues: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + mutable_warnings: list[dict[str, Any]] = field(default_factory=list) + legacy_changes: list[str] = field(default_factory=list) + server_url: str = "" + configured: bool = False + connected: bool = False + snapshot: dict[str, Any] = field(default_factory=dict) + async def diagnose(adapter: Any) -> dict[str, Any]: issues: list[str] = [] @@ -44,18 +145,29 @@ async def diagnose(adapter: Any) -> dict[str, Any]: if not signing_secret: warnings.append("MATTERMOST_SIGNING_SECRET not set — interactive messages may fail") - dm_policy = config.get("dm_policy", "open") - group_policy = config.get("group_policy", "open") + dm_policy = config.get("dm_policy", "pairing") + group_policy = config.get("group_policy", "allowlist") if dm_policy == "disabled" and group_policy == "disabled": warnings.append("Both DM and group policies are disabled — bot will not respond") + allow_from = config.get("allow_from", config.get("allowFrom", [])) + group_allow_from = config.get("group_allow_from", config.get("groupAllowFrom", [])) + mutable_warnings = collect_mutable_allowlist_warnings(allow_from, group_allow_from) + + _, legacy_changes = normalize_compatibility_config(config) + if legacy_changes: + for change in legacy_changes: + warnings.append(change) + result = { "channel": "mattermost", "status": "error" if issues else ("warning" if warnings else "ok"), "checks": checks, "issues": issues, "warnings": warnings, + "mutable_warnings": mutable_warnings, + "legacy_changes": legacy_changes, "server_url": server_url, "configured": checks["server_url_configured"] and checks["bot_token_configured"], } @@ -93,4 +205,8 @@ async def migrate_config(adapter: Any) -> dict[str, Any]: config["blockStreamingCoalesce"] = {"minChars": 1500, "idleMs": 1000} changes.append("Added default block streaming coalesce config") + normalized_config, legacy_changes = normalize_compatibility_config(config) + changes.extend(legacy_changes) + config = normalized_config + return {"migrated": bool(changes), "changes": changes, "config": config} diff --git a/backend/package/yuxi/channels/adapters/mattermost/interactions.py b/backend/package/yuxi/channels/adapters/mattermost/interactions.py index 1e7f2d6c..8607acf3 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/interactions.py +++ b/backend/package/yuxi/channels/adapters/mattermost/interactions.py @@ -112,16 +112,28 @@ def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool: return hmac.compare_digest(expected, signature) -def build_interactive_post_props( +def build_button_props_pipeline( channel_id: str, - message: str, - attachments: list[dict] | None = None, - root_id: str = "", -) -> dict[str, Any]: - """构建带 interactive attachments 的 post 选项。""" - return { - "channel_id": channel_id, - "message": message, - "root_id": root_id, - "props": {"attachments": attachments or []}, - } + buttons: list[dict[str, Any]], + flatten: bool = True, +) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for item in buttons: + if flatten and isinstance(item, list): + for nested in item: + _bind_channel_to_button(nested, channel_id) + result.append(nested) + else: + _bind_channel_to_button(item, channel_id) + result.append(item) + return result + + +def _bind_channel_to_button(button: dict[str, Any], channel_id: str) -> None: + integration = button.get("integration", {}) + if integration: + context = integration.get("context", {}) + if context: + context["channel_id"] = channel_id + integration["context"] = context + button["integration"] = integration diff --git a/backend/package/yuxi/channels/adapters/mattermost/model_picker.py b/backend/package/yuxi/channels/adapters/mattermost/model_picker.py index 0be2a463..28c21d3e 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/model_picker.py +++ b/backend/package/yuxi/channels/adapters/mattermost/model_picker.py @@ -1,6 +1,17 @@ from __future__ import annotations +import hashlib from dataclasses import dataclass +from enum import StrEnum + +MODEL_PICKER_PAGE_SIZE = 8 + + +class PickerState(StrEnum): + PROVIDERS = "providers" + LIST = "list" + SELECT = "select" + BACK = "back" @dataclass @@ -10,6 +21,10 @@ class ModelOption: provider: str = "" description: str = "" + @property + def button_id(self) -> str: + return hashlib.sha256(self.model_id.encode()).hexdigest()[:8] + def get_default_model_options() -> list[ModelOption]: return [ @@ -20,19 +35,77 @@ def get_default_model_options() -> list[ModelOption]: ] +def get_available_providers(models: list[ModelOption] | None = None) -> list[str]: + if models is None: + models = get_default_model_options() + seen: dict[str, bool] = {} + result: list[str] = [] + for m in models: + if m.provider and m.provider not in seen: + seen[m.provider] = True + result.append(m.provider) + return result + + def build_model_picker_actions( models: list[ModelOption] | None = None, callback_id: str = "model_picker", + state: PickerState = PickerState.LIST, + page: int = 0, ) -> list[dict]: - """构建模型选择器 action 列表。""" if models is None: models = get_default_model_options() - actions = [] - for m in models: + actions: list[dict] = [] + + if state == PickerState.PROVIDERS: + providers = get_available_providers(models) + for provider in providers: + actions.append( + { + "id": f"provider_{provider}", + "name": f"查看 {provider} 模型", + "integration": { + "url": "", + "context": { + "action": "list_provider", + "provider": provider, + "callback_id": callback_id, + "state": PickerState.LIST.value, + }, + }, + "type": "button", + "text": f"🤖 {provider.upper()}", + "style": "", + } + ) actions.append( { - "id": f"model_{m.model_id}", + "id": "view_all", + "name": "查看全部模型", + "integration": { + "url": "", + "context": { + "action": "list_all", + "callback_id": callback_id, + "state": PickerState.LIST.value, + }, + }, + "type": "button", + "text": "📋 全部模型", + "style": "", + } + ) + return actions + + start = page * MODEL_PICKER_PAGE_SIZE + page_models = models[start : start + MODEL_PICKER_PAGE_SIZE] + total_pages = (len(models) + MODEL_PICKER_PAGE_SIZE - 1) // MODEL_PICKER_PAGE_SIZE + + for m in page_models: + actions.append( + { + "id": f"model_{m.button_id}", "name": f"选择 {m.display_name}", "integration": { "url": "", @@ -40,6 +113,7 @@ def build_model_picker_actions( "action": "select_model", "model_id": m.model_id, "callback_id": callback_id, + "state": PickerState.SELECT.value, }, }, "type": "button", @@ -48,22 +122,98 @@ def build_model_picker_actions( } ) + if total_pages > 1: + nav_actions: list[dict] = [] + if page > 0: + nav_actions.append( + { + "id": "prev_page", + "name": "上一页", + "integration": { + "url": "", + "context": { + "action": "page", + "page": str(page - 1), + "callback_id": callback_id, + "state": PickerState.LIST.value, + }, + }, + "type": "button", + "text": "⬅️ 上一页", + "style": "", + } + ) + if page < total_pages - 1: + nav_actions.append( + { + "id": "next_page", + "name": "下一页", + "integration": { + "url": "", + "context": { + "action": "page", + "page": str(page + 1), + "callback_id": callback_id, + "state": PickerState.LIST.value, + }, + }, + "type": "button", + "text": "➡️ 下一页", + "style": "", + } + ) + actions.extend(nav_actions) + + actions.append( + { + "id": "back_to_providers", + "name": "按提供商筛选", + "integration": { + "url": "", + "context": { + "action": "back", + "callback_id": callback_id, + "state": PickerState.PROVIDERS.value, + }, + }, + "type": "button", + "text": "🔙 按提供商筛选", + "style": "", + } + ) + return actions def build_model_picker_attachment( current_model: str = "", callback_id: str = "model_picker", + state: PickerState = PickerState.PROVIDERS, + page: int = 0, + provider_filter: str = "", ) -> dict: - """构建模型选择器 attachment 消息。""" models = get_default_model_options() - lines = [] - for m in models: - marker = " ✅" if m.model_id == current_model else "" - lines.append(f"• **{m.display_name}** ({m.provider}) — {m.description}{marker}") - text = "当前可用模型:\n\n" + "\n".join(lines) - actions = build_model_picker_actions(models, callback_id) + if provider_filter: + models = [m for m in models if m.provider == provider_filter] + + if state == PickerState.PROVIDERS: + providers = get_available_providers(models) + lines = [f"• **{p.upper()}**" for p in providers] + text = "按 AI 提供商筛选模型:\n\n" + "\n".join(lines) + else: + start = page * MODEL_PICKER_PAGE_SIZE + page_models = models[start : start + MODEL_PICKER_PAGE_SIZE] + total_pages = (len(models) + MODEL_PICKER_PAGE_SIZE - 1) // MODEL_PICKER_PAGE_SIZE + + lines = [] + for m in page_models: + marker = " ✅" if m.model_id == current_model else "" + lines.append(f"• **{m.display_name}** ({m.provider}) — {m.description}{marker}") + + text = f"当前可用模型 (页 {page + 1}/{total_pages}):\n\n" + "\n".join(lines) + + actions = build_model_picker_actions(models, callback_id, state, page) return { "fallback": "模型选择器", @@ -75,10 +225,9 @@ def build_model_picker_attachment( def build_model_select_attachment(provider: str, callback_id: str = "model_select") -> dict: - """构建指定提供商的模型选择 attachment。""" all_models = get_default_model_options() provider_models = [m for m in all_models if m.provider == provider] - actions = build_model_picker_actions(provider_models, callback_id) + actions = build_model_picker_actions(provider_models, callback_id, PickerState.LIST, 0) return { "fallback": f"选择 {provider} 模型", diff --git a/backend/package/yuxi/channels/adapters/mattermost/normalizer.py b/backend/package/yuxi/channels/adapters/mattermost/normalizer.py index 6ba74bf8..64d2bc7a 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/normalizer.py +++ b/backend/package/yuxi/channels/adapters/mattermost/normalizer.py @@ -36,7 +36,7 @@ def extract_mentions(text: str) -> list[str]: def check_bot_mentioned(text: str, bot_username: str) -> bool: if not text or not bot_username: return False - return f"@{bot_username}" in text.casefold() + return f"@{bot_username.casefold()}" in text.casefold() def extract_urls(text: str) -> list[str]: diff --git a/backend/package/yuxi/channels/adapters/mattermost/security.py b/backend/package/yuxi/channels/adapters/mattermost/security.py index 8014d491..499c91b5 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/security.py +++ b/backend/package/yuxi/channels/adapters/mattermost/security.py @@ -52,21 +52,21 @@ def is_allow_entry_match(entry: str, user_id: str, username: str = "") -> bool: @dataclass class MattermostSecurityConfig: - dm_policy: DmPolicy = DM_POLICY_OPEN - group_policy: GroupPolicy = GROUP_POLICY_OPEN + dm_policy: DmPolicy = DM_POLICY_PAIRING + group_policy: GroupPolicy = GROUP_POLICY_ALLOWLIST allow_from: set[str] = field(default_factory=set) group_allow_from: set[str] = field(default_factory=set) dangerously_allow_name_matching: bool = False @classmethod def from_config(cls, config: dict) -> MattermostSecurityConfig: - dm_policy = config.get("dm_policy", DM_POLICY_OPEN) + dm_policy = config.get("dm_policy", DM_POLICY_PAIRING) if dm_policy not in DM_POLICIES: - dm_policy = DM_POLICY_OPEN + dm_policy = DM_POLICY_PAIRING - group_policy = config.get("group_policy", GROUP_POLICY_OPEN) + group_policy = config.get("group_policy", GROUP_POLICY_ALLOWLIST) if group_policy not in GROUP_POLICIES: - group_policy = GROUP_POLICY_OPEN + group_policy = GROUP_POLICY_ALLOWLIST allow_from = {normalize_allow_entry(e) for e in config.get("allow_from", []) if normalize_allow_entry(e)} group_allow_from = { diff --git a/backend/package/yuxi/channels/adapters/mattermost/slash.py b/backend/package/yuxi/channels/adapters/mattermost/slash.py index 4709195e..1ced29fe 100644 --- a/backend/package/yuxi/channels/adapters/mattermost/slash.py +++ b/backend/package/yuxi/channels/adapters/mattermost/slash.py @@ -108,3 +108,104 @@ def build_skill_commands(skills: list[str]) -> list[SlashCommand]: ) for skill in skills ] + + +@dataclass +class ManagedSlashCommand: + trigger: str + description: str + url: str = "" + method: str = "POST" + auto_complete: bool = False + managed: bool = True + + +async def build_slash_command_payload( + trigger: str, + description: str, + url: str = "", + method: str = "POST", +) -> dict[str, Any]: + return { + "trigger": trigger, + "url": url, + "method": method, + "description": description, + } + + +async def list_slash_commands(client: Any) -> list[dict[str, Any]]: + if client and hasattr(client, "commands") and hasattr(client.commands, "get_commands"): + try: + result = await client.commands.get_commands() + if isinstance(result, list): + return result + except Exception: + pass + return [] + + +async def find_existing_command( + client: Any, + trigger: str, +) -> dict[str, Any] | None: + existing = await list_slash_commands(client) + for cmd in existing: + if cmd.get("trigger") == trigger or cmd.get("id") == trigger: + return cmd + return None + + +async def register_managed_commands( + client: Any, + managed_commands: list[ManagedSlashCommand], + base_url: str, +) -> dict[str, bool]: + results: dict[str, bool] = {} + existing = await list_slash_commands(client) + existing_triggers: dict[str, dict[str, Any]] = {} + for cmd in existing: + t = cmd.get("trigger", "") + if t: + existing_triggers[t] = cmd + + for mc in managed_commands: + try: + command_url = mc.url or f"{base_url.rstrip('/')}/api/mattermost/slash/{mc.trigger}" + payload = await build_slash_command_payload(mc.trigger, mc.description, command_url, mc.method) + + if mc.trigger in existing_triggers: + existing_cmd = existing_triggers[mc.trigger] + if client and hasattr(client, "commands"): + cmd_id = existing_cmd.get("id", mc.trigger) + await client.commands.update_command(cmd_id, payload) + results[mc.trigger] = True + else: + if client and hasattr(client, "commands"): + await client.commands.create_command(payload) + results[mc.trigger] = True + except Exception: + results[mc.trigger] = False + + return results + + +async def unregister_managed_commands( + client: Any, + managed_command_triggers: set[str], +) -> dict[str, bool]: + results: dict[str, bool] = {} + existing = await list_slash_commands(client) + + for cmd in existing: + trigger = cmd.get("trigger", "") + if trigger in managed_command_triggers: + try: + cmd_id = cmd.get("id", trigger) + if client and hasattr(client, "commands"): + await client.commands.delete_command(cmd_id) + results[trigger] = True + except Exception: + results[trigger] = False + + return results