528 lines
21 KiB
Python
528 lines
21 KiB
Python
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 <at user_id=\"...\">name</at>]\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
|