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:
Kris 2026-05-13 16:12:02 +08:00
parent f2387884a3
commit d7fe152dae
10 changed files with 907 additions and 162 deletions

View File

@ -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

View File

@ -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": "消息 IDedit、delete 和 react 操作需要)",
"description": "消息 IDedit、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}

View File

@ -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))

View File

@ -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,

View File

@ -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}

View File

@ -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

View File

@ -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} 模型",

View File

@ -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]:

View File

@ -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 = {

View File

@ -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