from __future__ import annotations import logging import re from yuxi.channel.message.models import GroupContext, MessageType, PeerInfo, UnifiedMessage from yuxi.channel.routing.models import PeerKind from .format import clean_inbound_text from .sdk import was_bot_mentioned from .types import BotFrameworkActivity, MSTeamsActivityType logger = logging.getLogger(__name__) AI_TAG_RE = re.compile(r"\[AI\]\s*", re.IGNORECASE) def _resolve_chat_type(activity: BotFrameworkActivity) -> str: ct = activity.conversation_type if ct == "personal": return "direct" if ct == "channel": return "channel" if not activity.reply_to_id else "thread" if ct == "groupChat": return "group" return "direct" if not activity.is_group else "group" def _resolve_message_type(activity: BotFrameworkActivity) -> MessageType: if activity.attachments: first = activity.attachments[0] content_type = first.get("contentType", "") if "image" in content_type: return MessageType.IMAGE if "video" in content_type: return MessageType.VIDEO if "audio" in content_type: return MessageType.VOICE return MessageType.FILE if activity.type == MSTeamsActivityType.MESSAGE_REACTION: return MessageType.EVENT return MessageType.TEXT def _resolve_peer_kind(activity: BotFrameworkActivity) -> PeerKind: ct = _resolve_chat_type(activity) if ct in ("direct",): return PeerKind.DIRECT return PeerKind.GROUP def _build_group_context(activity: BotFrameworkActivity) -> GroupContext | None: chat_type = _resolve_chat_type(activity) if chat_type == "direct": return None conv = activity.conversation cd = activity.channel_data return GroupContext( id=activity.conversation_id, kind=chat_type, name=conv.name if conv else None, team_id=cd.team_id if cd else None, thread_id=activity.reply_to_id if activity.is_channel_thread else None, native_channel_id=cd.channel_id if cd else None, ) def _build_debounce_key(activity: BotFrameworkActivity) -> str: return f"msteams:{activity.from_id}:{activity.conversation_id}" def is_echo_activity(activity: BotFrameworkActivity, bot_app_id: str) -> bool: sender_id = activity.from_id bot_id = f"28:{bot_app_id}" return sender_id == bot_id or sender_id == bot_app_id def _build_event_message( activity: BotFrameworkActivity, account_id: str, event_type: str, payload: dict | list | None = None, ) -> UnifiedMessage: return UnifiedMessage( msg_id=f"msteams:{event_type}:{activity.id}", channel_type="msteams", account_id=account_id, content="", sender=PeerInfo(id=activity.from_id, kind=_resolve_peer_kind(activity)), message_type=MessageType.EVENT, group=_build_group_context(activity), raw_payload=activity.raw, metadata={"event_type": event_type, "payload": payload}, conversation_label=f"msteams:{activity.conversation_id}", native_channel_id=activity.conversation_id, ) def activity_to_unified_message( activity: BotFrameworkActivity, bot_app_id: str, account_id: str, ) -> UnifiedMessage | None: if activity.type == MSTeamsActivityType.TYPING: return None if activity.type == MSTeamsActivityType.MESSAGE_REACTION: from .reactions import parse_message_reaction reaction_data = parse_message_reaction(activity.raw) if reaction_data: return UnifiedMessage( msg_id=f"msteams:reaction:{activity.id}", channel_type="msteams", account_id=account_id, content="", sender=PeerInfo(id=activity.from_id, kind=_resolve_peer_kind(activity)), message_type=MessageType.EVENT, group=_build_group_context(activity), raw_payload=activity.raw, metadata={ "event_type": "message_reaction", "reply_to_id": reaction_data.get("reply_to_id"), "reactions_added": reaction_data.get("reactions_added", []), "reactions_removed": reaction_data.get("reactions_removed", []), }, conversation_label=f"msteams:{activity.conversation_id}", native_channel_id=activity.conversation_id, ) return None if activity.type == MSTeamsActivityType.CONVERSATION_UPDATE: members_added = activity.raw.get("membersAdded", []) members_removed = activity.raw.get("membersRemoved", []) bot_id = f"28:{bot_app_id}" bot_was_added = any(m.get("id") in (bot_id, bot_app_id) for m in members_added) bot_was_removed = any(m.get("id") in (bot_id, bot_app_id) for m in members_removed) if bot_was_added: return _build_event_message(activity, account_id, "bot_added", members_added) if bot_was_removed: return _build_event_message(activity, account_id, "bot_removed", members_removed) event_type = activity.raw.get("eventType") or activity.raw.get("channelData", {}).get("eventType", "") if event_type: return _build_event_message(activity, account_id, event_type, activity.raw.get("membersAdded", [])) return None if activity.type == MSTeamsActivityType.INSTALLATION_UPDATE: action = activity.raw.get("action", "") return _build_event_message(activity, account_id, "installation_update", {"action": action}) if activity.type == MSTeamsActivityType.MESSAGE_UPDATE: return UnifiedMessage( msg_id=f"msteams:msgupdate:{activity.id}", channel_type="msteams", account_id=account_id, content=clean_inbound_text(activity.text or ""), sender=PeerInfo(id=activity.from_id, kind=_resolve_peer_kind(activity)), message_type=MessageType.EVENT, group=_build_group_context(activity), raw_payload=activity.raw, metadata={"event_type": "message_update", "original_id": activity.id}, conversation_label=f"msteams:{activity.conversation_id}", native_channel_id=activity.conversation_id, ) if activity.type == MSTeamsActivityType.MESSAGE_DELETE: return _build_event_message(activity, account_id, "message_delete") if activity.type == MSTeamsActivityType.INVOKE: return None if is_echo_activity(activity, bot_app_id): return None text = clean_inbound_text(activity.text or "") text = AI_TAG_RE.sub("", text) if not text and not activity.attachments: return None chat_type = _resolve_chat_type(activity) peer_kind = _resolve_peer_kind(activity) sender = PeerInfo( id=activity.from_id, kind=peer_kind, display_name=activity.from_name or activity.from_id, ) media_urls_list = [att.get("contentUrl", "") for att in activity.attachments if att.get("contentUrl")] mentioned = was_bot_mentioned(activity, bot_app_id) thread_id = activity.reply_to_id if activity.is_channel_thread else None metadata: dict = { "service_url": activity.service_url, "tenant_id": activity.tenant_id, "debounce_key": _build_debounce_key(activity), "chat_type": chat_type, "locale": activity.locale, } if text.startswith("/"): command, *args = text[1:].split(maxsplit=1) metadata["command"] = command metadata["command_args"] = args[0] if args else "" return UnifiedMessage( msg_id=f"msteams:{activity.id}", channel_type="msteams", account_id=account_id, content=text, sender=sender, message_type=_resolve_message_type(activity), group=_build_group_context(activity), reply_to_id=activity.reply_to_id, was_mentioned=mentioned, media_urls=media_urls_list if media_urls_list else [], raw_payload=activity.raw, metadata=metadata, conversation_label=f"msteams:{activity.conversation_id}", native_channel_id=activity.conversation_id, native_direct_user_id=activity.from_id, thread_parent_id=thread_id, )