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