from __future__ import annotations import logging import re import time from datetime import datetime, UTC from yuxi.channel.extensions.slack.constants import ( SLACK_DEDUPE_MAX_SIZE, SLACK_DEDUPE_TTL_SECONDS, SLACK_MENTION_PATTERN, SLACK_MESSAGE_SUBTYPES_SYSTEM, ) from yuxi.channel.extensions.slack.types import SlackBotIdentity, SlackInboundEvent from yuxi.channel.message.models import ( GroupContext, MentionSource, MessageType, PeerInfo, UnifiedMessage, ) from yuxi.channel.routing.models import PeerKind logger = logging.getLogger(__name__) class SlackMonitor: def __init__(self): self._dedupe: dict[str, float] = {} def _dedupe_key(self, event: SlackInboundEvent) -> str: return f"{event.channel}:{event.ts}" def _is_duplicate(self, event: SlackInboundEvent) -> bool: key = self._dedupe_key(event) now = time.monotonic() if key in self._dedupe: if now - self._dedupe[key] < SLACK_DEDUPE_TTL_SECONDS: return True self._dedupe[key] = now if len(self._dedupe) > SLACK_DEDUPE_MAX_SIZE: expired = [k for k, v in self._dedupe.items() if now - v >= SLACK_DEDUPE_TTL_SECONDS] for k in expired: del self._dedupe[k] return False async def convert_event( self, event: SlackInboundEvent, bot_identity: SlackBotIdentity, account_id: str, ) -> UnifiedMessage | None: if self.should_drop_mismatched_event(event, bot_identity): return None if self.is_system_event(event): return None if self.is_echo(event, bot_identity): return None if self._is_duplicate(event): logger.debug("Dropping duplicate event: channel=%s ts=%s", event.channel, event.ts) return None return self._build_unified_message(event, bot_identity, account_id) def should_drop_mismatched_event(self, event: SlackInboundEvent, bot_identity: SlackBotIdentity) -> bool: if event.api_app_id and bot_identity.api_app_id and event.api_app_id != bot_identity.api_app_id: logger.debug( "Dropping event: api_app_id mismatch %s != %s", event.api_app_id, bot_identity.api_app_id, ) return True if event.team_id and bot_identity.team_id and event.team_id != bot_identity.team_id: logger.debug( "Dropping event: team_id mismatch %s != %s", event.team_id, bot_identity.team_id, ) return True return False def is_system_event(self, event: SlackInboundEvent) -> bool: return event.subtype in SLACK_MESSAGE_SUBTYPES_SYSTEM def is_echo(self, event: SlackInboundEvent, bot_identity: SlackBotIdentity) -> bool: if not event.bot_id or not bot_identity.bot_id: return False return event.bot_id == bot_identity.bot_id def _build_unified_message( self, event: SlackInboundEvent, bot_identity: SlackBotIdentity, account_id: str, ) -> UnifiedMessage: peer_kind = self._determine_peer_kind(event.channel_type) media_urls, media_types = self._extract_media(event) message_type = MessageType.TEXT if media_urls: message_type = self._detect_message_type(media_types) display_name = event.user_real_name or event.user_name or event.user sender = PeerInfo( kind=peer_kind, id=event.user, display_name=display_name, username=event.user_name or event.user, ) text = event.text or "" mentioned_user_ids = self._extract_mentioned_user_ids(text) cleaned_text = self._strip_slack_mentions(text) was_mentioned = len(mentioned_user_ids) > 0 bot_user_id = bot_identity.bot_user_id explicitly_mentioned_bot = bot_user_id and bot_user_id in mentioned_user_ids mention_source = MentionSource.NONE if explicitly_mentioned_bot: mention_source = MentionSource.EXPLICIT_BOT elif was_mentioned: mention_source = MentionSource.MENTION_PATTERN thread_id = None if self.is_thread_message(event): thread_id = event.thread_ts group = GroupContext( id=event.channel, thread_id=thread_id, ) timestamp = None if event.event_ts: try: timestamp = datetime.fromtimestamp(float(event.event_ts), tz=UTC) except (ValueError, TypeError): pass return UnifiedMessage( msg_id=event.ts, channel_type="slack", account_id=account_id, content=cleaned_text, sender=sender, message_type=message_type, media_urls=media_urls, media_types=media_types, group=group, timestamp=timestamp, was_mentioned=was_mentioned, explicitly_mentioned_bot=explicitly_mentioned_bot, mentioned_user_ids=mentioned_user_ids, mention_source=mention_source, raw_payload=event.raw, ) @staticmethod def _determine_peer_kind(channel_type: str) -> PeerKind: if channel_type in ("im", "mpim"): return PeerKind.DIRECT return PeerKind.GROUP @staticmethod def _extract_media(event: SlackInboundEvent) -> tuple[list[str], list[str]]: urls: list[str] = [] types: list[str] = [] for f in event.files: url = f.get("url_private_download") or f.get("url_private") or f.get("permalink") or "" if url: urls.append(url) mimetype = f.get("mimetype", "") or f.get("filetype", "") types.append(mimetype) return urls, types @staticmethod def _detect_message_type(media_types: list[str]) -> MessageType: for mt in media_types: lower = mt.lower() if lower.startswith("image/"): return MessageType.IMAGE if lower.startswith("audio/") or lower.startswith("voice/"): return MessageType.VOICE return MessageType.FILE @staticmethod def _strip_slack_mentions(text: str) -> str: return re.sub(SLACK_MENTION_PATTERN, "", text).strip() @staticmethod def _extract_mentioned_user_ids(text: str) -> list[str]: return re.findall(SLACK_MENTION_PATTERN, text) @staticmethod def is_thread_message(event: SlackInboundEvent) -> bool: return bool(event.thread_ts and event.thread_ts != event.ts)