新增 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: 类型定义
340 lines
12 KiB
Python
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
|