新增 Mattermost 渠道扩展,支持在 Yuxi 平台中集成 Mattermost 团队协作平台。 包含以下功能模块: - client: Mattermost API 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - websocket: WebSocket 实时连接 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedup: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - interactions: 交互处理 - slash_commands: 斜杠指令 - actions: 动作处理 - approval: 审批流程 - delivery: 消息送达确认 - directory: 目录管理 - threading: 线程管理 - gating: 门控管理 - reconnect: 重连机制 - reactions: 表情反应 - media: 媒体资源处理 - model_picker: 模型选择 - types: 类型定义
208 lines
8.1 KiB
Python
208 lines
8.1 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MattermostConfigAdapter:
|
|
def __init__(self):
|
|
self._config: dict = {}
|
|
|
|
def list_account_ids(self, config: dict) -> list[str]:
|
|
self._config = config
|
|
accounts = config.get("accounts", {})
|
|
if not accounts:
|
|
return ["default"] if self._env_token_exists("default") else []
|
|
return list(accounts.keys())
|
|
|
|
def default_account_id(self, config: dict) -> str:
|
|
self._config = config
|
|
return config.get("default_account", "default")
|
|
|
|
async def resolve_account(self, account_id: str) -> dict:
|
|
raw = self._load_config(account_id)
|
|
return self._build_account(account_id, raw)
|
|
|
|
def _load_config(self, account_id: str) -> dict:
|
|
accounts = self._config.get("accounts", {})
|
|
return accounts.get(account_id, {})
|
|
|
|
def _build_account(self, account_id: str, raw: dict) -> dict:
|
|
env_token = self._env_bot_token(account_id)
|
|
env_url = os.environ.get("MATTERMOST_URL", "")
|
|
|
|
account = {
|
|
"account_id": account_id,
|
|
"name": raw.get("name", account_id),
|
|
"enabled": raw.get("enabled", True),
|
|
"bot_token": env_token or raw.get("bot_token", ""),
|
|
"base_url": env_url or raw.get("base_url", ""),
|
|
"chatmode": raw.get("chatmode", "oncall"),
|
|
"onchar_prefixes": raw.get("onchar_prefixes", [">", "!"]),
|
|
"require_mention": raw.get("require_mention", True),
|
|
"dm_policy": raw.get("dm_policy", "pairing"),
|
|
"allow_from": raw.get("allow_from", []),
|
|
"group_policy": raw.get("group_policy", "allowlist"),
|
|
"group_allow_from": raw.get("group_allow_from", []),
|
|
"text_chunk_limit": raw.get("text_chunk_limit", 4000),
|
|
"chunk_mode": raw.get("chunk_mode", "length"),
|
|
"reply_to_mode": raw.get("reply_to_mode", "off"),
|
|
"response_prefix": raw.get("response_prefix"),
|
|
"block_streaming": raw.get("block_streaming", True),
|
|
"block_streaming_coalesce": raw.get(
|
|
"block_streaming_coalesce",
|
|
{"min_chars": 1500, "idle_ms": 1000},
|
|
),
|
|
"reactions": raw.get("actions", {}).get("reactions", True),
|
|
"callback_base_url": raw.get("interactions", {}).get("callback_base_url"),
|
|
"allowed_source_ips": raw.get("interactions", {}).get("allowed_source_ips", []),
|
|
"native_commands": raw.get("commands", {}).get("native", False),
|
|
"native_skills": raw.get("commands", {}).get("native_skills", False),
|
|
"dangerously_allow_private_network": raw.get("network", {}).get("dangerously_allow_private_network", False),
|
|
"dangerously_allow_name_matching": raw.get("dangerously_allow_name_matching", False),
|
|
"config_writes": raw.get("config_writes", True),
|
|
"dm_channel_retry": raw.get(
|
|
"dm_channel_retry",
|
|
{
|
|
"max_retries": 3,
|
|
"initial_delay_ms": 1000,
|
|
"max_delay_ms": 10000,
|
|
"timeout_ms": 30000,
|
|
},
|
|
),
|
|
}
|
|
|
|
coalesce = account["block_streaming_coalesce"]
|
|
account["block_streaming_coalesce_min_chars"] = coalesce.get("min_chars", 1500)
|
|
account["block_streaming_coalesce_idle_ms"] = coalesce.get("idle_ms", 1000)
|
|
|
|
retry = account["dm_channel_retry"]
|
|
account["dm_channel_retry_max_retries"] = retry.get("max_retries", 3)
|
|
account["dm_channel_retry_initial_delay_ms"] = retry.get("initial_delay_ms", 1000)
|
|
account["dm_channel_retry_max_delay_ms"] = retry.get("max_delay_ms", 10000)
|
|
account["dm_channel_retry_timeout_ms"] = retry.get("timeout_ms", 30000)
|
|
|
|
return account
|
|
|
|
def is_configured(self, account: dict) -> bool:
|
|
return bool(account.get("bot_token") and account.get("base_url"))
|
|
|
|
def is_enabled(self, account: dict) -> bool:
|
|
return account.get("enabled", True)
|
|
|
|
def disabled_reason(self, account: dict) -> str:
|
|
if not account.get("enabled", True):
|
|
return "Account disabled"
|
|
return ""
|
|
|
|
def describe_account(self, account: dict) -> dict:
|
|
return {
|
|
"account_id": account.get("account_id", ""),
|
|
"name": account.get("name", ""),
|
|
"base_url": account.get("base_url", ""),
|
|
"dm_policy": account.get("dm_policy", "pairing"),
|
|
"configured": self.is_configured(account),
|
|
"enabled": self.is_enabled(account),
|
|
}
|
|
|
|
def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str] | None:
|
|
self._config = config
|
|
aid = account_id or "default"
|
|
accounts = config.get("accounts", {})
|
|
acct = accounts.get(aid, {}) if accounts else {}
|
|
allow = acct.get("allow_from", config.get("allow_from", []))
|
|
if not allow:
|
|
return None
|
|
return [str(e).strip() for e in allow]
|
|
|
|
def config_schema(self) -> dict:
|
|
return {
|
|
"$schema": "https://json-schema.org/draft-07/schema#",
|
|
"type": "object",
|
|
"title": "Mattermost 渠道配置",
|
|
"properties": {
|
|
"bot_token": {
|
|
"type": "string",
|
|
"title": "Bot Token",
|
|
"description": "Mattermost Personal Access Token",
|
|
"x-ui-password": True,
|
|
},
|
|
"base_url": {
|
|
"type": "string",
|
|
"title": "Base URL",
|
|
"description": "Mattermost 服务器 URL (如 https://mattermost.example.com)",
|
|
},
|
|
"dm_policy": {
|
|
"type": "string",
|
|
"title": "DM 策略",
|
|
"enum": ["pairing", "allowlist", "open", "disabled"],
|
|
"default": "pairing",
|
|
},
|
|
"group_policy": {
|
|
"type": "string",
|
|
"title": "群组策略",
|
|
"enum": ["open", "allowlist", "disabled"],
|
|
"default": "allowlist",
|
|
},
|
|
"chatmode": {
|
|
"type": "string",
|
|
"title": "聊天模式",
|
|
"enum": ["oncall", "onmessage", "onchar"],
|
|
"default": "oncall",
|
|
},
|
|
"require_mention": {
|
|
"type": "boolean",
|
|
"title": "要求 @提及",
|
|
"default": True,
|
|
},
|
|
"text_chunk_limit": {
|
|
"type": "integer",
|
|
"title": "文本分块上限",
|
|
"default": 4000,
|
|
"minimum": 100,
|
|
"maximum": 4000,
|
|
},
|
|
},
|
|
}
|
|
|
|
@staticmethod
|
|
def _env_bot_token(account_id: str) -> str:
|
|
key = f"MATTERMOST_BOT_TOKEN_{account_id.upper()}"
|
|
return os.environ.get(key, "") or os.environ.get("MATTERMOST_BOT_TOKEN", "")
|
|
|
|
@staticmethod
|
|
def _env_token_exists(account_id: str) -> bool:
|
|
return bool(MattermostConfigAdapter._env_bot_token(account_id))
|
|
|
|
|
|
def normalize_mattermost_base_url(url: str) -> str:
|
|
url = url.rstrip("/")
|
|
if url.endswith("/api/v4"):
|
|
url = url[:-7]
|
|
return url
|
|
|
|
|
|
def validate_mattermost_config(account: dict) -> list[str]:
|
|
errors: list[str] = []
|
|
|
|
dm_policy = account.get("dm_policy", "pairing")
|
|
if dm_policy == "open":
|
|
allow_from = account.get("allow_from", [])
|
|
if "*" not in [str(e).strip() for e in allow_from]:
|
|
errors.append(
|
|
"dm_policy='open' requires allow_from to include '*' (open to all senders)"
|
|
)
|
|
|
|
retry = account.get("dm_channel_retry", {})
|
|
initial = retry.get("initial_delay_ms", 1000)
|
|
max_delay = retry.get("max_delay_ms", 10000)
|
|
if initial > max_delay:
|
|
errors.append(
|
|
f"dm_channel_retry.initial_delay_ms ({initial}) must be <= max_delay_ms ({max_delay})"
|
|
)
|
|
|
|
return errors
|