232 lines
8.2 KiB
Python
232 lines
8.2 KiB
Python
|
|
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,
|
||
|
|
)
|