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