from __future__ import annotations import json import logging import time from datetime import datetime, UTC from yuxi.channel.extensions.feishu.dedup import get_deduplicator from yuxi.channel.extensions.feishu.file_cache import get_file_cache from yuxi.channel.extensions.feishu.identity import resolve_sender_name from yuxi.channel.extensions.feishu.mentions import check_bot_mentioned from yuxi.channel.extensions.feishu.security import FeishuSecurityAdapter from yuxi.channel.extensions.feishu.types import FeishuAccount, FeishuGroupSessionScope from yuxi.channel.extensions.feishu.utils import is_broadcast_mention logger = logging.getLogger(__name__) BUILD_MESSAGE_BODY_PREFIX = "[message_id: {msg_id}]\n" REPLY_PREFIX = "[Replying to: {content}]\n\n" MENTION_SYSTEM_HINT = "[System: The content may include mention tags formatted as name]\n" MENTIONED_USERS_HINT = "[System: Feishu users mentioned: {names}]\n" PERMISSION_ERROR_HINT = "[System: Permission error: {error}]\n" class FeishuMonitor: def __init__(self): self._bot_open_ids: dict[str, str] = {} self._security = FeishuSecurityAdapter() async def handle_event(self, event: dict, account: FeishuAccount, queue) -> None: event_type = event.get("type", "") if event_type == "im.message.receive_v1": await self._handle_message(event, account, queue) elif event_type == "im.message.reaction.created_v1": await self._handle_reaction(event, account, queue) elif event_type == "im.message.reaction.deleted_v1": await self._handle_reaction(event, account, queue) elif event_type == "card.action.trigger": await self._handle_card_action(event, account, queue) elif event_type in ("im.chat.member.bot.added_v1", "im.chat.member.bot.deleted_v1"): await self._handle_bot_member_event(event, account, queue, event_type) elif event_type in ("im.chat.member.user.added_v1", "im.chat.disbanded_v1", "im.chat.updated_v1"): await self._handle_chat_event(event, account, queue, event_type) async def _handle_message(self, event: dict, account: FeishuAccount, queue) -> None: try: event_data = event.get("event", {}) message = event_data.get("message", {}) sender = event_data.get("sender", {}) message_id = message.get("message_id", "") if not message_id: return dedup = get_deduplicator(account.account_id) if dedup.is_duplicate(message_id): logger.debug("Duplicate message %s for account %s", message_id, account.account_id) return create_time_ms = message.get("create_time", "") if create_time_ms: try: msg_age = time.monotonic() - int(create_time_ms) / 1000.0 if msg_age > 60: logger.debug("Stale message filtered: age=%.1fs, msg_id=%s", msg_age, message_id) return except (ValueError, TypeError): pass msg_type = message.get("msg_type", "text") chat_type = message.get("chat_type", "") chat_id = message.get("chat_id", "") sender_id = sender.get("sender_id", {}).get("open_id", "") content_str = message.get("content", "{}") content = json.loads(content_str) if isinstance(content_str, str) else content_str extracted_text = self._extract_content(message) mentioned_list = self._extract_mentions(message) is_group = chat_type == "group_chat" or chat_type == "topic_group" thread_id = message.get("thread_id", "") or event_data.get("thread_id", "") sender_name = "" if account.resolve_sender_names and sender_id: try: sender_name = await resolve_sender_name( account.app_id, account.app_secret, account.domain, sender_id, account.http_timeout_ms ) except Exception: pass access_result = self._check_access(account, is_group, chat_id, sender_id, content) if not access_result["allowed"]: logger.debug( "Access denied for sender %s in chat %s: %s", sender_id, chat_id, access_result["reason"], ) return is_topic = chat_type == "topic_group" session_key = self._build_session_key( account, chat_id, is_group, sender_id, thread_id, is_topic ) if msg_type == "image": image_key = content.get("image_key", "") if image_key: get_file_cache().add(session_key, image_key) return elif msg_type == "file": file_key = content.get("file_key", "") if file_key: get_file_cache().add(session_key, file_key, file_name=content.get("file_name", "")) return elif msg_type == "text": cached = get_file_cache().get(session_key) if cached: ref_str = f"\n[附件引用: {cached.file_name or cached.file_key}]" extracted_text = extracted_text + ref_str get_file_cache().clear(session_key) root_id = message.get("root_id", "") parent_id = message.get("parent_id", "") unified = { "msg_id": message_id, "channel_type": "feishu", "account_id": account.account_id, "content": extracted_text, "message_type": self._map_message_type(msg_type), "sender": { "id": sender_id, "display_name": sender_name or sender_id, "kind": "GROUP" if is_group else "DIRECT", }, "group": { "id": chat_id, "type": chat_type, } if is_group else None, "timestamp": self._parse_timestamp(message.get("create_time", "")), "mentioned_ids": mentioned_list, "reply_to_id": root_id or parent_id or None, "thread_id": thread_id or None, "session_key": session_key, "raw_payload": { "msg_type": msg_type, "chat_type": chat_type, "chat_id": chat_id, "message": message, "content": content, }, "agent_message_body": self._build_agent_message_body( message_id=message_id, text=extracted_text, sender_name=sender_name, mentioned_ids=mentioned_list, reply_to_content=self._extract_reply_content(message), ), } await queue.put(unified) except Exception: logger.exception("Failed to handle feishu message event") async def _handle_reaction(self, event: dict, account: FeishuAccount, queue) -> None: try: if account.reaction_notifications == "off": return event_data = event.get("event", {}) reaction = event_data.get("reaction", {}) sender = event_data.get("sender", {}) message_id = event_data.get("message_id", "") if not message_id: return sender_id = sender.get("sender_id", {}).get("open_id", "") if account.reaction_notifications == "own": bot_open_id = self._bot_open_ids.get(account.account_id, "") if bot_open_id and sender_id == bot_open_id: return emoji_type = reaction.get("emoji_type", "") action = event_data.get("action_type", "") unified = { "msg_id": f"reaction_{message_id}_{sender_id}_{emoji_type}", "channel_type": "feishu", "account_id": account.account_id, "content": f"[reacted with {emoji_type} to message {message_id}]", "message_type": "EVENT", "sender": { "id": sender_id, "display_name": "", "kind": "DIRECT", }, "group": None, "mentioned_ids": [], "reply_to_id": None, "thread_id": None, "raw_payload": { "msg_type": "reaction", "action": action, "emoji_type": emoji_type, "message_id": message_id, }, } await queue.put(unified) except Exception: logger.exception("Failed to handle feishu reaction event") async def _handle_card_action(self, event: dict, account: FeishuAccount, queue) -> None: try: event_data = event.get("event", {}) action = event_data.get("action", {}) sender = event_data.get("sender", {}) sender_id = sender.get("sender_id", {}).get("open_id", "") action_value = action.get("value", "") action_tag = action.get("tag", "") unified = { "msg_id": f"card_action_{event_data.get('request_id', '')}", "channel_type": "feishu", "account_id": account.account_id, "content": action_value or f"[card action: {action_tag}]", "message_type": "EVENT", "sender": { "id": sender_id, "display_name": "", "kind": "DIRECT", }, "group": None, "mentioned_ids": [], "reply_to_id": None, "thread_id": None, "raw_payload": { "msg_type": "card_action", "action": action, "open_id": sender_id, }, } await queue.put(unified) except Exception: logger.exception("Failed to handle feishu card action event") async def _handle_bot_member_event(self, event: dict, account: FeishuAccount, queue, event_type: str) -> None: try: event_data = event.get("event", {}) chat_id = event_data.get("chat_id", "") sender = event_data.get("sender", {}) sender_id = sender.get("sender_id", {}).get("open_id", "") if event_type == "im.chat.member.bot.added_v1": content = "Bot was added to the chat." else: content = "Bot was removed from the chat." unified = { "msg_id": event.get("event_id", ""), "channel_type": "feishu", "account_id": account.account_id, "content": content, "message_type": "EVENT", "sender": {"id": sender_id, "display_name": "", "kind": "GROUP"}, "group": {"id": chat_id, "type": ""}, "mentioned_ids": [], "reply_to_id": None, "thread_id": None, "raw_payload": {"msg_type": "bot_member_event", "event_type": event_type, "chat_id": chat_id}, } await queue.put(unified) except Exception: logger.exception("Failed to handle bot member event") async def _handle_chat_event(self, event: dict, account: FeishuAccount, queue, event_type: str) -> None: try: event_data = event.get("event", {}) chat_id = event_data.get("chat_id", "") sender = event_data.get("sender", {}) sender_id = sender.get("sender_id", {}).get("open_id", "") if event_type == "im.chat.member.user.added_v1": content = "A user was added to the chat." elif event_type == "im.chat.disbanded_v1": content = "The chat was disbanded." elif event_type == "im.chat.updated_v1": content = "The chat was updated." else: content = f"Chat event: {event_type}" unified = { "msg_id": event.get("event_id", ""), "channel_type": "feishu", "account_id": account.account_id, "content": content, "message_type": "EVENT", "sender": {"id": sender_id, "display_name": "", "kind": "GROUP"}, "group": {"id": chat_id, "type": ""}, "mentioned_ids": [], "reply_to_id": None, "thread_id": None, "raw_payload": {"msg_type": "chat_event", "event_type": event_type, "chat_id": chat_id}, } await queue.put(unified) except Exception: logger.exception("Failed to handle chat event") def _check_access( self, account: FeishuAccount, is_group: bool, chat_id: str, sender_id: str, content: dict, ) -> dict: if is_group: bot_mentioned = check_bot_mentioned( content, self._bot_open_ids.get(account.account_id, ""), account.require_mention ) allowed, reason = self._security.check_group_access( { "group_policy": account.group_policy, "group_allow_from": account.group_allow_from, "groups": account.groups, }, chat_id, sender_id, mentioned_bot=bot_mentioned, require_mention=account.require_mention, ) return {"allowed": allowed, "reason": reason} else: allowed, reason = self._security.check_dm_access( { "dm_policy": account.dm_policy, "allow_from": account.allow_from, }, sender_id, ) return {"allowed": allowed, "reason": reason} def _build_session_key( self, account: FeishuAccount, chat_id: str, is_group: bool, sender_id: str, thread_id: str, is_topic: bool, ) -> str: if not is_group: return f"dm:{sender_id}" scope = account.group_session_scope if chat_id in account.groups: group_cfg = account.groups[chat_id] if "group_session_scope" in group_cfg: scope = group_cfg["group_session_scope"] if scope == FeishuGroupSessionScope.GROUP_SENDER and sender_id: return f"{chat_id}:sender:{sender_id}" elif scope == FeishuGroupSessionScope.GROUP_TOPIC and thread_id: return f"{chat_id}:topic:{thread_id}" elif scope == FeishuGroupSessionScope.GROUP_TOPIC_SENDER and thread_id and sender_id: return f"{chat_id}:topic:{thread_id}:sender:{sender_id}" else: return chat_id def _build_agent_message_body( self, message_id: str, text: str, sender_name: str, mentioned_ids: list[str], reply_to_content: str = "", ) -> str: parts = [] parts.append(BUILD_MESSAGE_BODY_PREFIX.format(msg_id=message_id)) if reply_to_content: parts.append(REPLY_PREFIX.format(content=reply_to_content)) if mentioned_ids: names_str = ", ".join(f"@{mid}" for mid in mentioned_ids) parts.append(MENTIONED_USERS_HINT.format(names=names_str)) parts.append(MENTION_SYSTEM_HINT) if sender_name: parts.append(f"{sender_name}: {text}") else: parts.append(text) return "".join(parts) def _extract_reply_content(self, message: dict) -> str: content_str = message.get("content", "{}") try: content = json.loads(content_str) if isinstance(content_str, str) else content_str except json.JSONDecodeError: return "" if isinstance(content, dict): title = content.get("title", "") if title: text = content.get("text", "") return f"{title}\n{text}" if text else title return "" def _extract_content(self, message: dict) -> str: msg_type = message.get("msg_type", "text") content_str = message.get("content", "{}") try: content = json.loads(content_str) if isinstance(content_str, str) else content_str except json.JSONDecodeError: return content_str or "" if msg_type == "text": return content.get("text", "") elif msg_type == "post": return self._extract_post_text(content) elif msg_type == "image": return "[图片]" elif msg_type == "file": return f"[文件] {content.get('file_name', '')}" elif msg_type == "audio": return "[语音消息]" elif msg_type == "media": return f"[媒体] {content.get('file_name', '')}" elif msg_type == "sticker": return "[贴纸]" elif msg_type == "interactive": return "[交互式卡片]" elif msg_type == "merge_forward": return "[合并转发消息]" return "" def _extract_post_text(self, content: dict) -> str: zh_cn = content.get("zh_cn", content) if isinstance(zh_cn, dict): title = zh_cn.get("title", "") text_parts = [] if title: text_parts.append(f"【{title}】") post_content = zh_cn.get("content", []) for paragraph_group in post_content: if isinstance(paragraph_group, list): for paragraph in paragraph_group: if isinstance(paragraph, dict): children = paragraph.get("children", []) for child in children: if isinstance(child, dict): tag = child.get("tag", "") if tag == "img": image_key = child.get("image_key", "") if image_key: text_parts.append(f"[图片: {image_key}]") else: text_parts.append(child.get("text", "")) elif isinstance(paragraph_group, dict): children = paragraph_group.get("children", []) for child in children: if isinstance(child, dict): tag = child.get("tag", "") if tag == "img": image_key = child.get("image_key", "") if image_key: text_parts.append(f"[图片: {image_key}]") else: text_parts.append(child.get("text", "")) return "\n".join(text_parts) return "" def _extract_mentions(self, message: dict) -> list[str]: content_str = message.get("content", "{}") try: content = json.loads(content_str) if isinstance(content_str, str) else content_str except json.JSONDecodeError: return [] mentions = message.get("mentions", []) or [] result = [] for m in mentions: if isinstance(m, dict): key = m.get("key", "") if not is_broadcast_mention(key): open_id = m.get("id", {}).get("open_id", "") if open_id: result.append(open_id) return result def _map_message_type(self, msg_type: str) -> str: mapping = { "text": "TEXT", "post": "TEXT", "image": "IMAGE", "file": "FILE", "audio": "VOICE", "media": "FILE", "sticker": "IMAGE", "interactive": "EVENT", "merge_forward": "TEXT", } return mapping.get(msg_type, "TEXT") def _parse_timestamp(self, ts: str | int) -> datetime | None: if not ts: return None try: if isinstance(ts, str): ts = int(ts) return datetime.fromtimestamp(ts / 1000, tz=UTC) if ts > 10000000000 else datetime.fromtimestamp(ts, tz=UTC) except (ValueError, TypeError, OSError): return None def set_bot_open_id(self, account_id: str, bot_open_id: str) -> None: self._bot_open_ids[account_id] = bot_open_id