ForcePilot/backend/package/yuxi/channels/adapters/mattermost/doctor.py
Kris d7fe152dae feat(mattermost): 完成 Mattermost 适配器多账号支持与功能增强
本次提交对 Mattermost 适配器进行了全面升级:
1.  重构为多账号架构,支持同时管理多个 Mattermost 机器人账号
2.  更新安全策略默认配置为配对模式和白名单模式
3.  新增 WebSocket 心跳、重连配置项与连接监控
4.  扩展 Agent 工具支持 pin/unpin、获取反应、搜索消息等操作
5.  重构交互按钮构建逻辑,新增分页与提供商筛选功能
6.  优化 SSRF 防护代码,复用公共工具库实现
7.  新增配置兼容性迁移与可变白名单项检测
8.  完善错误处理与日志输出,添加重复消息去重统计
9.  新增发送临时消息(ephemeral)支持
10. 修复提及检测逻辑,正确处理用户名大小写
2026-05-13 16:12:02 +08:00

213 lines
7.6 KiB
Python

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] = []
warnings: list[str] = []
checks: dict[str, bool] = {}
config = (adapter.config or {}) if adapter else {}
server_url = (
config.get("server_url", "") or os.getenv("MATTERMOST_SERVER_URL", "") or os.getenv("MATTERMOST_URL", "")
)
bot_token = config.get("bot_token", "") or os.getenv("MATTERMOST_BOT_TOKEN", "")
checks["server_url_configured"] = bool(server_url)
checks["bot_token_configured"] = bool(bot_token)
if not server_url:
issues.append("server_url not configured (config.server_url or MATTERMOST_SERVER_URL)")
if not bot_token:
issues.append("bot_token not configured (config.bot_token or MATTERMOST_BOT_TOKEN)")
if checks["server_url_configured"] and checks["bot_token_configured"]:
from .adapter import probe_mattermost
try:
probe_result = await probe_mattermost(server_url, bot_token, timeout_s=30.0)
if probe_result.get("status") == "ok":
checks["connection_ok"] = True
checks["bot_verified"] = True
else:
checks["connection_ok"] = False
issues.append(f"Connection probe failed: {probe_result.get('message', 'unknown')}")
except Exception as e:
checks["connection_ok"] = False
issues.append(f"Connection probe exception: {e}")
signing_secret = os.getenv("MATTERMOST_SIGNING_SECRET", "")
checks["signing_secret_configured"] = bool(signing_secret)
if not signing_secret:
warnings.append("MATTERMOST_SIGNING_SECRET not set — interactive messages may fail")
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"],
}
if adapter:
snapshot = adapter.snapshot() if hasattr(adapter, "snapshot") else {}
result["connected"] = adapter._is_connected() if hasattr(adapter, "_is_connected") else False
result["snapshot"] = snapshot
return result
async def migrate_config(adapter: Any) -> dict[str, Any]:
config = (adapter.config or {}) if adapter else {}
changes: list[str] = []
if not config.get("silence_send"):
changes.append("Added silence_send default")
if "commands" not in config or not isinstance(config.get("commands"), dict):
from .slash import SlashCommandConfig
slash_cfg = SlashCommandConfig.from_config(config.get("slash_commands", {}))
config["commands"] = {
"native": slash_cfg.auto_register,
"callback_url": slash_cfg.callback_url,
}
changes.append("Migrated slash_commands to commands config")
if "interactions" not in config:
config["interactions"] = {"allowedSourceIps": []}
changes.append("Added default interactions config")
if "blockStreamingCoalesce" not in config:
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}