1. 移除多个文件中的空行、冗余导入
2. 修复文件末尾缺少换行符的问题
3. 新增并补全飞书多类工具API实现:
- 多维表格:更新、删除记录,列出视图
- 文档:更新、追加、删除块
- 云文档:上传、下载文件
- 群组:创建、添加成员、更新信息、创建公告
- 目录:重构用户部门缓存逻辑
4. 优化消息发送、回复、转发等API的错误处理和逻辑
5. 新增消息列表查询、已读状态查询等功能
493 lines
16 KiB
Python
493 lines
16 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):
|
|
return _extract_from_content_dict(msg_type, content, event)
|
|
|
|
return str(content)
|
|
|
|
|
|
def _extract_from_content_dict(msg_type: str, content: dict, event: dict) -> str:
|
|
handlers = {
|
|
"text": lambda: content.get("text", ""),
|
|
"post": lambda: _extract_post_markdown(content),
|
|
"interactive": lambda: _extract_interactive_text(content, event),
|
|
}
|
|
if msg_type in handlers:
|
|
return handlers[msg_type]()
|
|
if msg_type in ("image", "file", "audio", "video", "sticker", "media"):
|
|
return _describe_media(msg_type, content)
|
|
return ""
|
|
|
|
|
|
def _describe_media(msg_type: str, parsed: dict) -> str:
|
|
descriptions = {
|
|
"image": "[图片]",
|
|
"sticker": "[贴纸]",
|
|
}
|
|
if msg_type in descriptions:
|
|
return descriptions[msg_type]
|
|
if msg_type == "file":
|
|
return f"[文件: {parsed.get('file_name', '未知')}]"
|
|
if msg_type == "audio":
|
|
return "[语音]"
|
|
if msg_type == "video":
|
|
return "[视频]"
|
|
if msg_type == "media":
|
|
items = parsed.get("items", [])
|
|
return f"[{len(items)}条媒体消息]"
|
|
return ""
|
|
|
|
|
|
def _extract_post_markdown(content: dict) -> str:
|
|
paragraphs = _get_post_paragraphs(content)
|
|
|
|
lines: list[str] = []
|
|
for paragraph in paragraphs:
|
|
if not isinstance(paragraph, list):
|
|
continue
|
|
seg_lines = _render_post_paragraph(paragraph)
|
|
if seg_lines:
|
|
lines.append("".join(seg_lines))
|
|
return "\n\n".join(lines)
|
|
|
|
|
|
def _get_post_paragraphs(content: dict) -> list:
|
|
if "content" in content and isinstance(content.get("content"), list):
|
|
return content.get("content", [])
|
|
lang_content = content.get("zh_cn") or content.get("en_us") or {}
|
|
return lang_content.get("content", []) if isinstance(lang_content, dict) else []
|
|
|
|
|
|
def _render_post_paragraph(paragraph: list) -> list[str]:
|
|
tag_handlers = {
|
|
"a": lambda seg: f"[{_escape_post_md(seg.get('text', ''))}]({seg.get('href', '')})",
|
|
"at": lambda seg: f"@<{_escape_post_md(seg.get('user_name', '') or seg.get('user_id', ''))}>",
|
|
"img": lambda seg: f"})",
|
|
"media": lambda seg: f"[file]({seg.get('file_key', '')})",
|
|
"emotion": lambda seg: seg.get("emoji_type", ""),
|
|
"code_block": lambda seg: f"```\n{seg.get('text', '')}\n```",
|
|
"hr": lambda seg: "---",
|
|
}
|
|
|
|
seg_lines: list[str] = []
|
|
for segment in paragraph:
|
|
tag = segment.get("tag", "")
|
|
text = segment.get("text", "")
|
|
|
|
if tag in tag_handlers:
|
|
seg_lines.append(tag_handlers[tag](segment))
|
|
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>")
|
|
else:
|
|
seg_lines.append(_escape_post_md(text))
|
|
|
|
return seg_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:
|
|
parts.append(_get_i18n_text(title, "content") if isinstance(title, dict) else 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 = _extract_action_label(action)
|
|
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_action_label(action: dict) -> str:
|
|
if not isinstance(action, dict):
|
|
return ""
|
|
value = action.get("value", {})
|
|
if isinstance(value, dict):
|
|
text_obj = value.get("text", {})
|
|
return _get_i18n_text(text_obj, "content") if isinstance(text_obj, dict) else str(text_obj)
|
|
return str(value) if value else ""
|
|
|
|
|
|
_MEDIA_DESCRIPTIONS = {
|
|
"image": "[图片]",
|
|
"audio": "[语音]",
|
|
"video": "[视频]",
|
|
"sticker": "[贴纸]",
|
|
}
|
|
|
|
|
|
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}"
|
|
else:
|
|
return f"[转发消息]{sender_info}"
|
|
|
|
inner_msg_type = inner_msg.get("msg_type", "text")
|
|
inner_content = inner_msg.get("content", "")
|
|
|
|
inner_text = _extract_inner_forwarded_text(inner_msg_type, inner_content)
|
|
|
|
lines = [f"[转发消息] {sender_info}".strip()]
|
|
if inner_text:
|
|
lines.append(inner_text)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _extract_inner_forwarded_text(msg_type: str, content: Any) -> str:
|
|
if msg_type == "text":
|
|
return content.get("text", "") if isinstance(content, dict) else str(content)
|
|
if msg_type == "post":
|
|
return _extract_post_markdown(content if isinstance(content, dict) else _parse_content(content))
|
|
if msg_type in _MEDIA_DESCRIPTIONS:
|
|
return _MEDIA_DESCRIPTIONS[msg_type]
|
|
if msg_type == "file":
|
|
file_name = content.get("file_name", "") if isinstance(content, dict) else ""
|
|
return f"[文件: {file_name}]" if file_name else "[文件]"
|
|
return f"[{msg_type}消息]"
|
|
|
|
|
|
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,
|
|
)
|