ForcePilot/backend/package/yuxi/channel/extensions/feishu/monitor.py
Kris 5e91bb9985 feat(feishu): 新增飞书渠道完整插件实现
新增飞书渠道插件,包含基础配置、事件处理、消息收发、工具调用、权限管理等完整功能模块,支持WebSocket长连接、卡片消息、流式响应、群管理、审批通知等能力
2026-05-21 10:46:42 +08:00

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