From 98ef57e3704860c54b86f6e8ae0d742c646883aa Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Sat, 21 Feb 2026 12:56:57 +0800 Subject: [PATCH 1/3] feat(feishu): add multimedia download support for images, audio and files Add download functionality for multimedia messages in Feishu channel, enabling agents to process images, audio recordings, and file attachments sent through Feishu. Co-Authored-By: Claude Opus 4.6 --- nanobot/channels/feishu.py | 143 ++++++++++++++++++++++++++++++------- 1 file changed, 117 insertions(+), 26 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a8ca1fa..a948d84 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -27,6 +27,8 @@ try: CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji, + GetFileRequest, + GetImageRequest, P2ImMessageReceiveV1, ) FEISHU_AVAILABLE = True @@ -345,6 +347,80 @@ class FeishuChannel(BaseChannel): logger.error("Error uploading file {}: {}", file_path, e) return None + def _download_image_sync(self, image_key: str) -> tuple[bytes | None, str | None]: + """Download an image from Feishu by image_key.""" + try: + request = GetImageRequest.builder().image_key(image_key).build() + response = self._client.im.v1.image.get(request) + if response.success(): + return response.file, response.file_name + else: + logger.error("Failed to download image: code={}, msg={}", response.code, response.msg) + return None, None + except Exception as e: + logger.error("Error downloading image {}: {}", image_key, e) + return None, None + + def _download_file_sync(self, file_key: str) -> tuple[bytes | None, str | None]: + """Download a file from Feishu by file_key.""" + try: + request = GetFileRequest.builder().file_key(file_key).build() + response = self._client.im.v1.file.get(request) + if response.success(): + return response.file, response.file_name + else: + logger.error("Failed to download file: code={}, msg={}", response.code, response.msg) + return None, None + except Exception as e: + logger.error("Error downloading file {}: {}", file_key, e) + return None, None + + async def _download_and_save_media( + self, + msg_type: str, + content_json: dict + ) -> tuple[str | None, str]: + """ + Download media from Feishu and save to local disk. + + Returns: + (file_path, content_text) - file_path is None if download failed + """ + from pathlib import Path + + loop = asyncio.get_running_loop() + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + + data, filename = None, None + + if msg_type == "image": + image_key = content_json.get("image_key") + if image_key: + data, filename = await loop.run_in_executor( + None, self._download_image_sync, image_key + ) + if not filename: + filename = f"{image_key[:16]}.jpg" + + elif msg_type in ("audio", "file"): + file_key = content_json.get("file_key") + if file_key: + data, filename = await loop.run_in_executor( + None, self._download_file_sync, file_key + ) + if not filename: + ext = ".opus" if msg_type == "audio" else "" + filename = f"{file_key[:16]}{ext}" + + if data and filename: + file_path = media_dir / filename + file_path.write_bytes(data) + logger.debug("Downloaded {} to {}", msg_type, file_path) + return str(file_path), f"[{msg_type}: {filename}]" + + return None, f"[{msg_type}: download failed]" + def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: """Send a single message (text/image/file/interactive) synchronously.""" try: @@ -425,60 +501,75 @@ class FeishuChannel(BaseChannel): event = data.event message = event.message sender = event.sender - + # Deduplication check message_id = message.message_id if message_id in self._processed_message_ids: return self._processed_message_ids[message_id] = None - - # Trim cache: keep most recent 500 when exceeds 1000 + + # Trim cache while len(self._processed_message_ids) > 1000: self._processed_message_ids.popitem(last=False) - + # Skip bot messages - sender_type = sender.sender_type - if sender_type == "bot": + if sender.sender_type == "bot": return - + sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" chat_id = message.chat_id - chat_type = message.chat_type # "p2p" or "group" + chat_type = message.chat_type msg_type = message.message_type - - # Add reaction to indicate "seen" + + # Add reaction await self._add_reaction(message_id, "THUMBSUP") - - # Parse message content + + # Parse content + content_parts = [] + media_paths = [] + + try: + content_json = json.loads(message.content) if message.content else {} + except json.JSONDecodeError: + content_json = {} + if msg_type == "text": - try: - content = json.loads(message.content).get("text", "") - except json.JSONDecodeError: - content = message.content or "" + text = content_json.get("text", "") + if text: + content_parts.append(text) + elif msg_type == "post": - try: - content_json = json.loads(message.content) - content = _extract_post_text(content_json) - except (json.JSONDecodeError, TypeError): - content = message.content or "" + text = _extract_post_text(content_json) + if text: + content_parts.append(text) + + elif msg_type in ("image", "audio", "file"): + file_path, content_text = await self._download_and_save_media(msg_type, content_json) + if file_path: + media_paths.append(file_path) + content_parts.append(content_text) + else: - content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]") - - if not content: + content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) + + content = "\n".join(content_parts) if content_parts else "" + + if not content and not media_paths: return - + # Forward to message bus reply_to = chat_id if chat_type == "group" else sender_id await self._handle_message( sender_id=sender_id, chat_id=reply_to, content=content, + media=media_paths, metadata={ "message_id": message_id, "chat_type": chat_type, "msg_type": msg_type, } ) - + except Exception as e: logger.error("Error processing Feishu message: {}", e) From b9c3f8a5a3520fe4815f8baf1aea2f095295b3f8 Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Sat, 21 Feb 2026 14:08:25 +0800 Subject: [PATCH 2/3] feat(feishu): add share card and interactive message parsing - Add content extraction for share cards (chat, user, calendar event) - Add recursive parsing for interactive card elements - Fix image download API to use GetMessageResourceRequest with message_id - Handle BytesIO response from message resource API Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- nanobot/channels/feishu.py | 211 +++++++++++++++++++++++++++++++++++-- 1 file changed, 201 insertions(+), 10 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a948d84..7e1d50a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -28,7 +28,7 @@ try: CreateMessageReactionRequestBody, Emoji, GetFileRequest, - GetImageRequest, + GetMessageResourceRequest, P2ImMessageReceiveV1, ) FEISHU_AVAILABLE = True @@ -46,6 +46,182 @@ MSG_TYPE_MAP = { } +def _extract_share_card_content(content_json: dict, msg_type: str) -> str: + """Extract content from share cards and interactive messages. + + Handles: + - share_chat: Group share card + - share_user: User share card + - interactive: Interactive card (may contain links from external shares) + - share_calendar_event: Calendar event share + - system: System messages + """ + parts = [] + + if msg_type == "share_chat": + # Group share: {"chat_id": "oc_xxx"} + chat_id = content_json.get("chat_id", "") + parts.append(f"[分享群聊: {chat_id}]") + + elif msg_type == "share_user": + # User share: {"user_id": "ou_xxx"} + user_id = content_json.get("user_id", "") + parts.append(f"[分享用户: {user_id}]") + + elif msg_type == "interactive": + # Interactive card - extract text and links recursively + parts.extend(_extract_interactive_content(content_json)) + + elif msg_type == "share_calendar_event": + # Calendar event share + event_key = content_json.get("event_key", "") + parts.append(f"[分享日程: {event_key}]") + + elif msg_type == "system": + # System message + parts.append("[系统消息]") + + elif msg_type == "merge_forward": + # Merged forward messages + parts.append("[合并转发消息]") + + return "\n".join(parts) if parts else f"[{msg_type}]" + + +def _extract_interactive_content(content: dict) -> list[str]: + """Recursively extract text and links from interactive card content.""" + parts = [] + + if isinstance(content, str): + # Try to parse as JSON + try: + content = json.loads(content) + except (json.JSONDecodeError, TypeError): + return [content] if content.strip() else [] + + if not isinstance(content, dict): + return parts + + # Extract title + if "title" in content: + title = content["title"] + if isinstance(title, dict): + title_content = title.get("content", "") or title.get("text", "") + if title_content: + parts.append(f"标题: {title_content}") + elif isinstance(title, str): + parts.append(f"标题: {title}") + + # Extract from elements array + elements = content.get("elements", []) + if isinstance(elements, list): + for element in elements: + parts.extend(_extract_element_content(element)) + + # Extract from card config + card = content.get("card", {}) + if card: + parts.extend(_extract_interactive_content(card)) + + # Extract header + header = content.get("header", {}) + if header: + header_title = header.get("title", {}) + if isinstance(header_title, dict): + header_text = header_title.get("content", "") or header_title.get("text", "") + if header_text: + parts.append(f"标题: {header_text}") + + return parts + + +def _extract_element_content(element: dict) -> list[str]: + """Extract content from a single card element.""" + parts = [] + + if not isinstance(element, dict): + return parts + + tag = element.get("tag", "") + + # Markdown element + if tag == "markdown" or tag == "lark_md": + content = element.get("content", "") + if content: + parts.append(content) + + # Div element + elif tag == "div": + text = element.get("text", {}) + if isinstance(text, dict): + text_content = text.get("content", "") or text.get("text", "") + if text_content: + parts.append(text_content) + elif isinstance(text, str): + parts.append(text) + # Check for extra fields + fields = element.get("fields", []) + for field in fields: + if isinstance(field, dict): + field_text = field.get("text", {}) + if isinstance(field_text, dict): + parts.append(field_text.get("content", "")) + + # Link/URL element + elif tag == "a": + href = element.get("href", "") + text = element.get("text", "") + if href: + parts.append(f"链接: {href}") + if text: + parts.append(text) + + # Button element (may contain URL) + elif tag == "button": + text = element.get("text", {}) + if isinstance(text, dict): + parts.append(text.get("content", "")) + url = element.get("url", "") or element.get("multi_url", {}).get("url", "") + if url: + parts.append(f"链接: {url}") + + # Image element + elif tag == "img": + alt = element.get("alt", {}) + if isinstance(alt, dict): + parts.append(alt.get("content", "[图片]")) + else: + parts.append("[图片]") + + # Note element + elif tag == "note": + note_elements = element.get("elements", []) + for ne in note_elements: + parts.extend(_extract_element_content(ne)) + + # Column set + elif tag == "column_set": + columns = element.get("columns", []) + for col in columns: + col_elements = col.get("elements", []) + for ce in col_elements: + parts.extend(_extract_element_content(ce)) + + # Plain text + elif tag == "plain_text": + content = element.get("content", "") + if content: + parts.append(content) + + # Recursively check nested elements + nested = element.get("elements", []) + if isinstance(nested, list): + for ne in nested: + parts.extend(_extract_element_content(ne)) + + return parts + + def _extract_post_text(content_json: dict) -> str: """Extract plain text from Feishu post (rich text) message content. @@ -347,13 +523,21 @@ class FeishuChannel(BaseChannel): logger.error("Error uploading file {}: {}", file_path, e) return None - def _download_image_sync(self, image_key: str) -> tuple[bytes | None, str | None]: - """Download an image from Feishu by image_key.""" + def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]: + """Download an image from Feishu message by message_id and image_key.""" try: - request = GetImageRequest.builder().image_key(image_key).build() - response = self._client.im.v1.image.get(request) + request = GetMessageResourceRequest.builder() \ + .message_id(message_id) \ + .file_key(image_key) \ + .type("image") \ + .build() + response = self._client.im.v1.message_resource.get(request) if response.success(): - return response.file, response.file_name + file_data = response.file + # GetMessageResourceRequest returns BytesIO, need to read bytes + if hasattr(file_data, 'read'): + file_data = file_data.read() + return file_data, response.file_name else: logger.error("Failed to download image: code={}, msg={}", response.code, response.msg) return None, None @@ -378,7 +562,8 @@ class FeishuChannel(BaseChannel): async def _download_and_save_media( self, msg_type: str, - content_json: dict + content_json: dict, + message_id: str | None = None ) -> tuple[str | None, str]: """ Download media from Feishu and save to local disk. @@ -396,9 +581,9 @@ class FeishuChannel(BaseChannel): if msg_type == "image": image_key = content_json.get("image_key") - if image_key: + if image_key and message_id: data, filename = await loop.run_in_executor( - None, self._download_image_sync, image_key + None, self._download_image_sync, message_id, image_key ) if not filename: filename = f"{image_key[:16]}.jpg" @@ -544,11 +729,17 @@ class FeishuChannel(BaseChannel): content_parts.append(text) elif msg_type in ("image", "audio", "file"): - file_path, content_text = await self._download_and_save_media(msg_type, content_json) + file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id) if file_path: media_paths.append(file_path) content_parts.append(content_text) + elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"): + # Handle share cards and interactive messages + text = _extract_share_card_content(content_json, msg_type) + if text: + content_parts.append(text) + else: content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) From 8125d9b6bcf39a2d92f13833aa53be45ed3a9330 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 06:30:26 +0000 Subject: [PATCH 3/3] fix(feishu): fix double recursion, English placeholders, top-level Path import --- nanobot/channels/feishu.py | 130 ++++++++++++------------------------- 1 file changed, 43 insertions(+), 87 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7e1d50a..815d853 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -6,6 +6,7 @@ import os import re import threading from collections import OrderedDict +from pathlib import Path from typing import Any from loguru import logger @@ -47,44 +48,22 @@ MSG_TYPE_MAP = { def _extract_share_card_content(content_json: dict, msg_type: str) -> str: - """Extract content from share cards and interactive messages. - - Handles: - - share_chat: Group share card - - share_user: User share card - - interactive: Interactive card (may contain links from external shares) - - share_calendar_event: Calendar event share - - system: System messages - """ + """Extract text representation from share cards and interactive messages.""" parts = [] - + if msg_type == "share_chat": - # Group share: {"chat_id": "oc_xxx"} - chat_id = content_json.get("chat_id", "") - parts.append(f"[分享群聊: {chat_id}]") - + parts.append(f"[shared chat: {content_json.get('chat_id', '')}]") elif msg_type == "share_user": - # User share: {"user_id": "ou_xxx"} - user_id = content_json.get("user_id", "") - parts.append(f"[分享用户: {user_id}]") - + parts.append(f"[shared user: {content_json.get('user_id', '')}]") elif msg_type == "interactive": - # Interactive card - extract text and links recursively parts.extend(_extract_interactive_content(content_json)) - elif msg_type == "share_calendar_event": - # Calendar event share - event_key = content_json.get("event_key", "") - parts.append(f"[分享日程: {event_key}]") - + parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]") elif msg_type == "system": - # System message - parts.append("[系统消息]") - + parts.append("[system message]") elif msg_type == "merge_forward": - # Merged forward messages - parts.append("[合并转发消息]") - + parts.append("[merged forward messages]") + return "\n".join(parts) if parts else f"[{msg_type}]" @@ -93,44 +72,37 @@ def _extract_interactive_content(content: dict) -> list[str]: parts = [] if isinstance(content, str): - # Try to parse as JSON try: content = json.loads(content) except (json.JSONDecodeError, TypeError): return [content] if content.strip() else [] - + if not isinstance(content, dict): return parts - - # Extract title + if "title" in content: title = content["title"] if isinstance(title, dict): title_content = title.get("content", "") or title.get("text", "") if title_content: - parts.append(f"标题: {title_content}") + parts.append(f"title: {title_content}") elif isinstance(title, str): - parts.append(f"标题: {title}") - - # Extract from elements array - elements = content.get("elements", []) - if isinstance(elements, list): - for element in elements: - parts.extend(_extract_element_content(element)) - - # Extract from card config + parts.append(f"title: {title}") + + for element in content.get("elements", []) if isinstance(content.get("elements"), list) else []: + parts.extend(_extract_element_content(element)) + card = content.get("card", {}) if card: parts.extend(_extract_interactive_content(card)) - - # Extract header + header = content.get("header", {}) if header: header_title = header.get("title", {}) if isinstance(header_title, dict): header_text = header_title.get("content", "") or header_title.get("text", "") if header_text: - parts.append(f"标题: {header_text}") + parts.append(f"title: {header_text}") return parts @@ -144,13 +116,11 @@ def _extract_element_content(element: dict) -> list[str]: tag = element.get("tag", "") - # Markdown element - if tag == "markdown" or tag == "lark_md": + if tag in ("markdown", "lark_md"): content = element.get("content", "") if content: parts.append(content) - - # Div element + elif tag == "div": text = element.get("text", {}) if isinstance(text, dict): @@ -159,64 +129,52 @@ def _extract_element_content(element: dict) -> list[str]: parts.append(text_content) elif isinstance(text, str): parts.append(text) - # Check for extra fields - fields = element.get("fields", []) - for field in fields: + for field in element.get("fields", []): if isinstance(field, dict): field_text = field.get("text", {}) if isinstance(field_text, dict): - parts.append(field_text.get("content", "")) - - # Link/URL element + c = field_text.get("content", "") + if c: + parts.append(c) + elif tag == "a": href = element.get("href", "") text = element.get("text", "") if href: - parts.append(f"链接: {href}") + parts.append(f"link: {href}") if text: parts.append(text) - - # Button element (may contain URL) + elif tag == "button": text = element.get("text", {}) if isinstance(text, dict): - parts.append(text.get("content", "")) + c = text.get("content", "") + if c: + parts.append(c) url = element.get("url", "") or element.get("multi_url", {}).get("url", "") if url: - parts.append(f"链接: {url}") - - # Image element + parts.append(f"link: {url}") + elif tag == "img": alt = element.get("alt", {}) - if isinstance(alt, dict): - parts.append(alt.get("content", "[图片]")) - else: - parts.append("[图片]") - - # Note element + parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]") + elif tag == "note": - note_elements = element.get("elements", []) - for ne in note_elements: + for ne in element.get("elements", []): parts.extend(_extract_element_content(ne)) - - # Column set + elif tag == "column_set": - columns = element.get("columns", []) - for col in columns: - col_elements = col.get("elements", []) - for ce in col_elements: + for col in element.get("columns", []): + for ce in col.get("elements", []): parts.extend(_extract_element_content(ce)) - - # Plain text + elif tag == "plain_text": content = element.get("content", "") if content: parts.append(content) - - # Recursively check nested elements - nested = element.get("elements", []) - if isinstance(nested, list): - for ne in nested: + + else: + for ne in element.get("elements", []): parts.extend(_extract_element_content(ne)) return parts @@ -571,8 +529,6 @@ class FeishuChannel(BaseChannel): Returns: (file_path, content_text) - file_path is None if download failed """ - from pathlib import Path - loop = asyncio.get_running_loop() media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True)