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