ForcePilot/backend/package/yuxi/channels/adapters/feishu/normalizer.py
Kris a6fa7245e5 feat(feishu): 完整实现飞书适配器核心模块
新增飞书机器人适配器全套功能,包括:
- 基础适配器入口与工具导出
- 消息格式化、卡片渲染、回复调度逻辑
- 会话ID生成、模型覆盖策略
- 消息发送缓存、顺序队列管理
- 飞书签名验证、加解密webhook请求
- 审批权限校验、机器人菜单事件处理
- 文档评论、钉消息、语音转码处理
- 静态/动态目录管理、子代理生命周期管理
- 各类工具集:聊天、云盘、文档、知识库API封装
2026-05-12 00:43:59 +08:00

487 lines
17 KiB
Python

from __future__ import annotations
import json
import re
from typing import Any
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelType,
ChatType,
EventType,
MentionsInfo,
MessageType,
)
from .session import generate_feishu_chat_id, resolve_feishu_chat_type
_POST_ESCAPE_RE = re.compile(r"([\\*_{}\[\]()~`>#+\-=|!])")
_TEMPLATE_VAR_RE = re.compile(r"\$\{(\w+)\}|\{\{\s*(\w+)\s*\}\}")
_AT_MENTION_RE = re.compile(r"<at\s+[^>]*user_id\s*=\s*\"([^\"]+)\"[^>]*>[^<]*</at>", re.IGNORECASE)
_AT_MENTION_STRIP_RE = re.compile(r"<at\s+[^>]*>[^<]*</at>", re.IGNORECASE)
def _escape_post_md(text: str) -> str:
return _POST_ESCAPE_RE.sub(r"\\\1", text)
def _resolve_template_vars(text: str, event: dict) -> str:
def _replace(match):
var_name = match.group(1) or match.group(2)
if var_name in event:
val = event[var_name]
return str(val) if val is not None else ""
msg = event.get("message", {})
if var_name in msg:
val = msg[var_name]
return str(val) if val is not None else ""
return match.group(0)
return _TEMPLATE_VAR_RE.sub(_replace, text)
def _get_i18n_text(content: dict, key: str = "content", prefer_lang: str = "zh_cn") -> str:
i18n = content.get("i18n", {})
if i18n:
for lang in (prefer_lang, "en_us", "zh_cn", "ja_jp"):
lang_val = i18n.get(lang, "")
if lang_val:
return lang_val
first = next(iter(i18n.values()), "")
return first if first else ""
return content.get(key, "")
def map_event_type(event_type: str) -> EventType:
_map = {
"im.message.receive_v1": EventType.MESSAGE_RECEIVED,
"im.message.updated_v1": EventType.MESSAGE_UPDATED,
"im.message.deleted_v1": EventType.MESSAGE_DELETED,
"im.message.message_read_v1": EventType.READ_RECEIPT,
"im.message.reaction.created_v1": EventType.REACTION_ADDED,
"im.message.reaction.deleted_v1": EventType.REACTION_REMOVED,
"im.chat.member.bot.added_v1": EventType.BOT_ADDED,
"im.chat.member.bot.deleted_v1": EventType.BOT_REMOVED,
"card.action.trigger": EventType.CARD_ACTION,
"card.action.trigger_v1": EventType.CARD_ACTION,
"application.bot.menu_v6": EventType.BOT_MENU,
"drive.notice.comment_add_v1": EventType.SYSTEM_EVENT,
}
return _map.get(event_type, EventType.MESSAGE_RECEIVED)
def map_msg_type(feishu_type: str) -> MessageType:
_map = {
"text": MessageType.TEXT,
"image": MessageType.IMAGE,
"file": MessageType.FILE,
"audio": MessageType.AUDIO,
"video": MessageType.VIDEO,
"post": MessageType.TEXT,
"interactive": MessageType.CARD,
}
return _map.get(feishu_type, MessageType.TEXT)
def extract_mentions(event: dict, bot_open_id: str = "") -> MentionsInfo:
msg = event.get("message", {})
mentions_list = msg.get("mentions", [])
mentioned_ids: list[str] = []
is_bot_mentioned = False
raw_text = ""
for m in mentions_list:
key = m.get("key", "")
mentioned_ids.append(key)
if key in (bot_open_id, "@all_users"):
is_bot_mentioned = True
name = m.get("name", "")
if name:
raw_text += f"@{name} "
if not mentioned_ids:
content = msg.get("content", "")
content_str = content if isinstance(content, str) else ""
if isinstance(content, dict):
content_str = content.get("text", "")
raw_text = content_str
return MentionsInfo(
mentioned_user_ids=mentioned_ids,
is_bot_mentioned=is_bot_mentioned,
raw_text=raw_text.strip(),
)
def _parse_content(content: Any) -> dict:
if isinstance(content, dict):
return content
if isinstance(content, str):
try:
return json.loads(content)
except (json.JSONDecodeError, TypeError):
return {}
return {}
def extract_content(event: dict, event_type: EventType) -> str:
msg = event.get("message", {})
content = msg.get("content", "")
msg_type = msg.get("msg_type", "text")
if msg_type == "forwarded":
return _extract_forwarded_content(content, msg)
if isinstance(content, str):
if msg_type in ("image", "file", "audio", "video", "sticker", "media"):
parsed = _parse_content(content)
return _describe_media(msg_type, parsed)
return content
if isinstance(content, dict):
if msg_type == "text":
return content.get("text", "")
if msg_type == "post":
return _extract_post_markdown(content)
if msg_type == "interactive":
return _extract_interactive_text(content, event)
if msg_type in ("image", "file", "audio", "video", "sticker", "media"):
return _describe_media(msg_type, content)
return ""
return str(content)
def _describe_media(msg_type: str, parsed: dict) -> str:
if msg_type == "image":
return "[图片]"
if msg_type == "file":
return f"[文件: {parsed.get('file_name', '未知')}]"
if msg_type == "audio":
return "[语音]"
if msg_type == "video":
return "[视频]"
if msg_type == "sticker":
return "[贴纸]"
if msg_type == "media":
items = parsed.get("items", [])
return f"[{len(items)}条媒体消息]"
return ""
def _extract_post_markdown(content: dict) -> str:
paragraphs: list = []
if "content" in content and isinstance(content.get("content"), list):
paragraphs = content.get("content", [])
else:
lang_content = content.get("zh_cn") or content.get("en_us") or {}
paragraphs = lang_content.get("content", []) if isinstance(lang_content, dict) else []
lines: list[str] = []
for paragraph in paragraphs:
if not isinstance(paragraph, list):
continue
seg_lines: list[str] = []
for segment in paragraph:
tag = segment.get("tag", "")
text = segment.get("text", "")
if tag == "a":
href = segment.get("href", "")
seg_lines.append(f"[{_escape_post_md(text)}]({href})")
elif tag == "at":
user_id = segment.get("user_id", "")
user_name = segment.get("user_name", "")
seg_lines.append(f"@<{_escape_post_md(user_name or user_id)}>")
elif tag == "img":
image_key = segment.get("image_key", "")
seg_lines.append(f"![image]({image_key})")
elif tag == "media":
file_key = segment.get("file_key", "")
seg_lines.append(f"[file]({file_key})")
elif tag == "emotion":
emoji = segment.get("emoji_type", "")
seg_lines.append(emoji)
elif tag == "code_block":
seg_lines.append(f"```\n{text}\n```")
elif tag.endswith("_bold"):
seg_lines.append(f"**{text}**")
elif tag.endswith("_italic"):
seg_lines.append(f"*{text}*")
elif tag.endswith("_strikethrough"):
seg_lines.append(f"~~{text}~~")
elif tag.endswith("_underline"):
seg_lines.append(f"<u>{text}</u>")
elif tag == "hr":
seg_lines.append("---")
else:
seg_lines.append(_escape_post_md(text))
if seg_lines:
lines.append("".join(seg_lines))
return "\n\n".join(lines)
def _extract_interactive_text(content: dict, event: dict | None = None) -> str:
event_ctx = event or {}
parts: list[str] = []
title = content.get("title", "")
if title:
if isinstance(title, dict):
parts.append(_get_i18n_text(title, "content"))
else:
parts.append(str(title))
header = content.get("header", {})
if header:
header_title = header.get("title", {})
if isinstance(header_title, dict):
parts.append(_get_i18n_text(header_title, "content"))
for element in content.get("elements", []):
tag = element.get("tag", "")
if tag == "markdown":
text = _resolve_template_vars(element.get("content", ""), event_ctx)
parts.append(text)
elif tag == "plain_text":
text = _get_i18n_text(element, "content")
text = _resolve_template_vars(text, event_ctx)
parts.append(text)
elif tag == "div":
for field in element.get("fields", []):
if field.get("is_short"):
field_text = field.get("text", {})
parts.append(
_get_i18n_text(field_text, "content") if isinstance(field_text, dict) else str(field_text)
)
elif tag == "action":
for action in element.get("actions", []):
label = ""
if isinstance(action, dict):
value = action.get("value", {})
if isinstance(value, dict):
text_obj = value.get("text", {})
label = _get_i18n_text(text_obj, "content") if isinstance(text_obj, dict) else str(text_obj)
else:
label = str(value) if value else ""
if label:
parts.append(f"[{label}]")
text = "\n".join(p for p in parts if p)
if not text:
text = json.dumps(content, ensure_ascii=False)
return text
def _extract_forwarded_content(content: Any, msg: dict) -> str:
sender_info = ""
sender = msg.get("sender", {}) or {}
sender_name = sender.get("sender_name", "") or sender.get("name", "")
if sender_name:
sender_info = f"转发自 {sender_name}"
if isinstance(content, dict):
inner_msg = content
elif isinstance(content, str):
try:
inner_msg = json.loads(content)
except (json.JSONDecodeError, TypeError):
return f"[转发消息]{sender_info}"
inner_msg_type = inner_msg.get("msg_type", "text")
inner_content = inner_msg.get("content", "")
inner_text = ""
if inner_msg_type == "text":
inner_text = inner_content.get("text", "") if isinstance(inner_content, dict) else str(inner_content)
elif inner_msg_type == "post":
inner_text = _extract_post_markdown(
inner_content
if isinstance(inner_content, dict)
else json.loads(inner_content)
if isinstance(inner_content, str)
else {}
)
elif inner_msg_type == "image":
inner_text = "[图片]"
elif inner_msg_type == "file":
file_name = inner_content.get("file_name", "") if isinstance(inner_content, dict) else ""
inner_text = f"[文件: {file_name}]" if file_name else "[文件]"
elif inner_msg_type == "audio":
inner_text = "[语音]"
elif inner_msg_type == "video":
inner_text = "[视频]"
elif inner_msg_type == "sticker":
inner_text = "[贴纸]"
else:
inner_text = f"[{inner_msg_type}消息]"
lines = [f"[转发消息] {sender_info}".strip()]
if inner_text:
lines.append(inner_text)
return "\n".join(lines)
def _encode_file_id(message_id: str, file_key: str, file_type: str) -> str:
return json.dumps({"message_id": message_id, "file_key": file_key, "file_type": file_type})
def extract_attachments(event: dict) -> list[Attachment]:
msg = event.get("message", {})
msg_type = msg.get("msg_type", "text")
message_id = msg.get("message_id", "") or ""
content = msg.get("content", "")
parsed = _parse_content(content)
if msg_type == "image":
image_key = parsed.get("image_key", "")
if image_key:
return [Attachment(type="image", file_id=_encode_file_id(message_id, image_key, "image"))]
if msg_type == "file":
file_key = parsed.get("file_key", "")
if file_key:
return [
Attachment(
type="file",
file_id=_encode_file_id(message_id, file_key, "file"),
filename=parsed.get("file_name", ""),
size_bytes=parsed.get("file_size", 0),
)
]
if msg_type == "audio":
file_key = parsed.get("file_key", "")
if file_key:
return [Attachment(type="audio", file_id=_encode_file_id(message_id, file_key, "file"))]
if msg_type == "video":
result = []
image_key = parsed.get("image_key", "")
file_key = parsed.get("file_key", "")
if image_key:
result.append(
Attachment(
type="image",
file_id=_encode_file_id(message_id, image_key, "image"),
metadata={"role": "cover"},
)
)
if file_key:
result.append(Attachment(type="video", file_id=_encode_file_id(message_id, file_key, "file")))
return result
if msg_type == "media":
items = parsed.get("items", [])
result = []
for item in items:
item_type = item.get("msg_type", "")
item_message_id = item.get("message_id", message_id)
item_content = _parse_content(item.get("content", ""))
if item_type == "image" and item_content.get("image_key"):
result.append(
Attachment(
type="image",
file_id=_encode_file_id(item_message_id, item_content["image_key"], "image"),
)
)
elif item_type == "file" and item_content.get("file_key"):
result.append(
Attachment(
type="file",
file_id=_encode_file_id(item_message_id, item_content["file_key"], "file"),
filename=item_content.get("file_name", ""),
)
)
return result
if msg_type == "sticker":
file_key = parsed.get("file_key", "")
if file_key:
return [Attachment(type="sticker", file_id=_encode_file_id(message_id, file_key, "image"))]
return []
def strip_at_mentions(text: str, *, replace_with: str = "") -> str:
if not text:
return text
return _AT_MENTION_STRIP_RE.sub(replace_with, text).strip()
def extract_mentioned_user_ids(text: str) -> list[str]:
if not text:
return []
return [m.group(1) for m in _AT_MENTION_RE.finditer(text)]
def normalize_inbound(
channel_id: str,
channel_type: ChannelType,
raw_payload: dict[str, Any],
bot_open_id: str = "",
) -> ChannelMessage:
if not isinstance(raw_payload, dict) or "event" not in raw_payload:
raise ValueError(f"Invalid raw_payload: expected dict with 'event' key, got {type(raw_payload).__name__}")
event = raw_payload.get("event", {})
event_type_str = raw_payload.get("event_type", "")
msg = event.get("message", {})
event_type = map_event_type(event_type_str)
msg_type = map_msg_type(msg.get("msg_type", "text"))
open_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
root_id = msg.get("root_id", "")
parent_id = msg.get("parent_id", "")
msg_chat_type = msg.get("chat_type", "")
raw_chat_id = event.get("chat_id", "") or msg.get("chat_id", "") or open_id
if msg_chat_type == "p2p":
raw_chat_id = open_id
chat_type_str = "direct"
elif root_id or parent_id:
chat_type_str = "thread"
else:
chat_type_str = resolve_feishu_chat_type(raw_chat_id)
chat_type = ChatType(chat_type_str)
message_id = msg.get("message_id", "") or ""
chat_id = generate_feishu_chat_id(
raw_chat_id=raw_chat_id,
chat_type=chat_type_str,
root_id=root_id,
sender_id=open_id,
)
content = extract_content(event, event_type)
content = strip_at_mentions(content)
attachments = extract_attachments(event)
mentions = extract_mentions(event, bot_open_id) if msg else None
metadata: dict[str, Any] = {
"msg_type": msg.get("msg_type", ""),
"chat_type_raw": msg.get("chat_type", "private"),
}
if root_id:
metadata["root_id"] = root_id
if parent_id:
metadata["parent_id"] = parent_id
return ChannelMessage(
identity=ChannelIdentity(
channel_id=channel_id,
channel_type=channel_type,
channel_user_id=open_id,
channel_chat_id=chat_id,
channel_message_id=message_id,
),
event_type=event_type,
message_type=msg_type,
chat_type=chat_type,
content=content,
attachments=attachments,
mentions=mentions,
metadata=metadata,
)