ForcePilot/backend/package/yuxi/channel/extensions/mattermost/monitor.py
Kris ebab14660a feat(channel): 添加 Mattermost 渠道扩展
新增 Mattermost 渠道扩展,支持在 Yuxi 平台中集成 Mattermost 团队协作平台。

包含以下功能模块:
- client: Mattermost API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- websocket: WebSocket 实时连接
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedup: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- interactions: 交互处理
- slash_commands: 斜杠指令
- actions: 动作处理
- approval: 审批流程
- delivery: 消息送达确认
- directory: 目录管理
- threading: 线程管理
- gating: 门控管理
- reconnect: 重连机制
- reactions: 表情反应
- media: 媒体资源处理
- model_picker: 模型选择
- types: 类型定义
2026-05-21 11:22:43 +08:00

340 lines
12 KiB
Python

from __future__ import annotations
import asyncio
import logging
import time
from yuxi.channel.extensions.mattermost.client import MattermostClient
from yuxi.channel.extensions.mattermost.dedup import ClaimableDedupe
from yuxi.channel.extensions.mattermost.errors import MattermostError
from yuxi.channel.extensions.mattermost.format import normalize_message
from yuxi.channel.extensions.mattermost.gating import MattermostGating
from yuxi.channel.extensions.mattermost.outbound import (
map_mattermost_channel_type_to_chat_type,
)
from yuxi.channel.extensions.mattermost.security import MattermostSecurityAdapter
from yuxi.channel.extensions.mattermost.threading import MattermostThreadingAdapter
from yuxi.channel.extensions.mattermost.types import MattermostPost
logger = logging.getLogger(__name__)
DEBOUNCE_DEFAULT_MS = 500
DEBOUNCE_MAX_MS = 3000
class MattermostMonitor:
def __init__(
self,
client: MattermostClient,
account_id: str,
account: dict,
on_message=None,
on_dispatched=None,
):
self.client = client
self.account_id = account_id
self.account = account
self._on_message = on_message
self._on_dispatched = on_dispatched
self.dedupe = ClaimableDedupe(ttl_ms=300_000, max_size=2000)
self.security = MattermostSecurityAdapter()
self.gating = MattermostGating(account)
self.threading = MattermostThreadingAdapter(account)
self.bot_user_id: str = ""
self.bot_username: str = ""
self._debounce_pending: dict[str, dict] = {}
self._debounce_tasks: dict[str, asyncio.Task] = {}
async def handle_post(
self,
post: MattermostPost,
event_data: dict,
) -> dict | None:
try:
channel_id = post.channel_id or event_data.get("channel_id", "")
if not channel_id:
logger.debug("Post missing channel_id, dropping")
return None
if not post.user_id:
logger.debug("Post missing user_id, dropping")
return None
if post.user_id == self.bot_user_id:
return None
if post.type and post.type != "":
logger.debug("Skipping system post type: %s", post.type)
return None
dedupe_key = f"{self.account_id}:{post.id}"
result = self.dedupe.claim(dedupe_key)
if result == "duplicate":
logger.debug("Duplicate message %s, dropping", post.id)
return None
try:
channel_info = await self.client.fetch_channel(channel_id)
mm_type = channel_info.get("type", "")
chat_type = map_mattermost_channel_type_to_chat_type(mm_type)
except MattermostError:
chat_type = "channel"
sender_name = event_data.get("sender_name", "")
allowed, reason = self.security.check_sender_access(
chat_type, post.user_id, self.account, sender_name
)
if not allowed:
logger.info(
"Access denied for %s in %s: %s", post.user_id, channel_id, reason
)
return {
"status": "blocked",
"reason": reason,
"channel_id": channel_id,
"chat_type": chat_type,
}
raw_text = post.message
normalized = normalize_message(raw_text, self.bot_username)
mentions = self.gating.extract_mentions(raw_text)
was_mentioned = self.bot_user_id in mentions or (
self.bot_username and self.bot_username in raw_text
)
should_respond, gate_reason = self.gating.should_respond(
chat_type, normalized, was_mentioned
)
if not should_respond:
logger.debug("Gate blocked: %s", gate_reason)
return {
"status": "gated",
"reason": gate_reason,
"channel_id": channel_id,
"chat_type": chat_type,
}
root_id = post.root_id or ""
thread_context = self.threading.build_thread_context(
root_id, post.id, channel_id, chat_type
)
unified = {
"channel_type": "mattermost",
"account_id": self.account_id,
"msg_id": post.id,
"channel_id": channel_id,
"chat_type": chat_type,
"sender_id": post.user_id,
"sender_name": sender_name,
"content": normalized,
"raw_content": raw_text,
"root_id": root_id,
"file_ids": post.file_ids,
"props": event_data,
"thread_context": thread_context,
"timestamp": post.create_at / 1000.0 if post.create_at else time.time(),
}
if self._on_message:
try:
await self._on_message(unified)
except Exception as e:
logger.error("Error in on_message handler: %s", e)
return {
"status": "processed",
"channel_id": channel_id,
"chat_type": chat_type,
"msg_id": post.id,
"unified": unified,
}
except Exception as e:
logger.error("Error handling post %s: %s", getattr(post, "id", "?"), e)
return None
async def handle_reaction_added(self, event_data: dict) -> dict | None:
reaction_data = event_data.get("reaction", event_data)
post_id = reaction_data.get("post_id", "")
emoji_name = reaction_data.get("emoji_name", "")
user_id = reaction_data.get("user_id", "")
if not post_id or not emoji_name:
return None
return {
"type": "reaction_added",
"post_id": post_id,
"emoji_name": emoji_name,
"user_id": user_id,
"account_id": self.account_id,
}
async def handle_reaction_removed(self, event_data: dict) -> dict | None:
reaction_data = event_data.get("reaction", event_data)
post_id = reaction_data.get("post_id", "")
emoji_name = reaction_data.get("emoji_name", "")
user_id = reaction_data.get("user_id", "")
if not post_id or not emoji_name:
return None
return {
"type": "reaction_removed",
"post_id": post_id,
"emoji_name": emoji_name,
"user_id": user_id,
"account_id": self.account_id,
}
def debounce_key(self, channel_id: str, thread_key: str = "") -> str:
base = f"{self.account_id}:{channel_id}"
if thread_key:
return f"{base}:{thread_key}"
return base
async def debounce_message(
self,
channel_id: str,
post: MattermostPost,
event_data: dict,
debounce_ms: int = DEBOUNCE_DEFAULT_MS,
) -> None:
key = self.debounce_key(channel_id, post.root_id)
if key in self._debounce_tasks and not self._debounce_tasks[key].done():
self._debounce_tasks[key].cancel()
self._debounce_pending[key] = {
"post": post,
"event_data": event_data,
"timestamp": time.monotonic(),
}
async def delayed_handle():
await asyncio.sleep(debounce_ms / 1000.0)
pending = self._debounce_pending.pop(key, None)
if pending:
await self.handle_post(pending["post"], pending["event_data"])
self._debounce_tasks[key] = asyncio.create_task(delayed_handle())
def reset(self) -> None:
self.dedupe.reset()
for task in self._debounce_tasks.values():
task.cancel()
self._debounce_tasks.clear()
self._debounce_pending.clear()
async def handle_post_edited(self, post: MattermostPost, event_data: dict) -> dict | None:
if post.user_id == self.bot_user_id:
return None
if post.type and post.type != "":
return None
msg_id = post.id
dedupe_key = f"{self.account_id}:edited:{msg_id}"
result = self.dedupe.claim(dedupe_key)
if result == "duplicate":
logger.debug("Duplicate edit event %s, dropping", msg_id)
return None
channel_id = post.channel_id or event_data.get("channel_id", "")
try:
channel_info = await self.client.fetch_channel(channel_id)
mm_type = channel_info.get("type", "")
chat_type = map_mattermost_channel_type_to_chat_type(mm_type)
except MattermostError:
chat_type = "channel"
unified = {
"event_type": "message_edited",
"account_id": self.account_id,
"msg_id": msg_id,
"sender_id": post.user_id,
"channel_id": channel_id,
"channel_type": chat_type,
"content": normalize_message(post.message, self.bot_username),
"raw_content": post.message,
"root_id": post.root_id,
"file_ids": post.file_ids or [],
"edit_at": post.edit_at,
}
if self._on_message:
await self._on_message(unified)
return {"status": "processed", "event_type": "message_edited", "msg_id": msg_id}
async def handle_user_added(self, event_data: dict) -> dict | None:
user_id = event_data.get("user_id", "")
channel_id = event_data.get("channel_id", "")
team_id = event_data.get("team_id", "")
if user_id == self.bot_user_id:
logger.info(
"Bot added to channel: channel=%s team=%s",
channel_id, team_id,
)
unified = {
"event_type": "bot_added_to_channel",
"account_id": self.account_id,
"channel_id": channel_id,
"team_id": team_id,
}
if self._on_message:
await self._on_message(unified)
return {"status": "processed", "event_type": "bot_added_to_channel"}
else:
logger.debug("User %s added to channel %s", user_id, channel_id)
return None
async def handle_user_removed(self, event_data: dict) -> dict | None:
user_id = event_data.get("user_id", "")
if user_id == self.bot_user_id:
logger.info(
"Bot removed from channel/team: data=%s",
{k: v for k, v in event_data.items() if k in ("channel_id", "team_id")},
)
return None
async def handle_generic_event(self, event_type: str, event_data: dict) -> dict | None:
if event_type == "post_deleted":
post_id = event_data.get("post_id", event_data.get("id", ""))
channel_id = event_data.get("channel_id", "")
unified = {
"event_type": "post_deleted",
"account_id": self.account_id,
"post_id": post_id,
"channel_id": channel_id,
}
if self._on_message:
await self._on_message(unified)
return {"status": "processed", "event_type": "post_deleted"}
elif event_type in ("channel_created", "channel_deleted", "channel_updated"):
logger.info("Channel event %s: channel=%s", event_type, event_data.get("channel_id", ""))
elif event_type == "typing":
logger.debug(
"User %s typing in channel %s",
event_data.get("user_id", ""),
event_data.get("channel_id", ""),
)
elif event_type == "status_change":
logger.debug("User %s status: %s", event_data.get("user_id", ""), event_data.get("status", ""))
elif event_type == "thread_updated":
logger.debug("Thread updated: %s", event_data.get("thread_id", ""))
elif event_type == "user_updated":
user_id = event_data.get("user_id", "")
if user_id == self.bot_user_id:
logger.info("Bot user info updated, may need reconnection")
return None