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"]*user_id\s*=\s*\"([^\"]+)\"[^>]*>[^<]*", re.IGNORECASE) _AT_MENTION_STRIP_RE = re.compile(r"]*>[^<]*", 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"{text}") 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, )