tanjingyu 4 недель назад
Родитель
Сommit
1390dde72a

+ 0 - 0
agent/tools/builtin/feishu/__init__.py


+ 359 - 0
agent/tools/builtin/feishu/chat.py

@@ -0,0 +1,359 @@
+import json
+import os
+import base64
+import httpx
+from typing import Optional, List, Dict, Any, Union
+from .feishu_client import FeishuClient, FeishuDomain
+from agent.tools import tool, ToolResult, ToolContext
+
+# 从环境变量获取飞书配置
+# 也可以在此设置硬编码的默认值,但推荐使用环境变量
+FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a90fe317987a9cc9")
+FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "nn2dWuXTiRA2N6xodbm4g0qz1AfM2ayi")
+
+CONTACTS_FILE = os.path.join(os.path.dirname(__file__), "contacts.json")
+
+# ==================== 一、文件内使用的功能函数 ====================
+
+def _load_contacts() -> List[Dict[str, Any]]:
+    """读取 contacts.json 中的所有联系人"""
+    if not os.path.exists(CONTACTS_FILE):
+        return []
+    try:
+        with open(CONTACTS_FILE, 'r', encoding='utf-8') as f:
+            return json.load(f)
+    except Exception:
+        return []
+
+def _save_contacts(contacts: List[Dict[str, Any]]):
+    """保存联系人信息到 contacts.json"""
+    try:
+        with open(CONTACTS_FILE, 'w', encoding='utf-8') as f:
+            json.dump(contacts, f, ensure_ascii=False, indent=2)
+    except Exception as e:
+        print(f"保存联系人失败: {e}")
+
+def list_contacts_info() -> List[Dict[str, str]]:
+    """
+    1. 列出所有联系人信息
+    读取 contacts.json 中的每一个联系人的 name、description,以字典列表返回
+    """
+    contacts = _load_contacts()
+    return [{"name": c.get("name", ""), "description": c.get("description", "")} for c in contacts]
+
+def get_contact_full_info(name: str) -> Optional[Dict[str, Any]]:
+    """
+    2. 根据联系人名称获取联系人完整字典信息
+    从 contacts.json 中读取每一个联系人做名称匹配,返回数据中的所有字段为一个字典对象
+    """
+    contacts = _load_contacts()
+    for c in contacts:
+        if c.get("name") == name:
+            return c
+    return None
+
+def update_contact_chat_id(name: str, chat_id: str):
+    """
+    3. 更新某一个联系人的 chat_id
+    根据第二个函数找出联系人信息,如果信息中的 chat_id 为空,那么就将传进来的 chat_id 更新进去
+    """
+    contacts = _load_contacts()
+    updated = False
+    for c in contacts:
+        if c.get("name") == name:
+            if not c.get("chat_id"):
+                c["chat_id"] = chat_id
+                updated = True
+            break
+    if updated:
+        _save_contacts(contacts)
+
+# ==================== 二、@tool 工具 ====================
+
+@tool(
+    display={
+        "zh": {
+            "name": "获取飞书联系人列表",
+            "params": {}
+        },
+        "en": {
+            "name": "Get Feishu Contact List",
+            "params": {}
+        }
+    }
+)
+async def feishu_get_contact_list(context: Optional[ToolContext] = None) -> ToolResult:
+    """
+    获取所有联系人的名称和描述。不需要参数。
+    """
+    contacts = list_contacts_info()
+    return ToolResult(
+        title="获取联系人列表成功",
+        output=json.dumps(contacts, ensure_ascii=False, indent=2),
+        metadata={"contacts": contacts}
+    )
+
+@tool(
+    display={
+        "zh": {
+            "name": "给飞书联系人发送消息",
+            "params": {
+                "contact_name": "联系人名称",
+                "content": "消息内容。可以是字符串,也可以是 OpenAI 多模态格式列表 (例如: [{'type': 'text', 'text': '你好'}, {'type': 'image_url', 'image_url': {'url': '...'}}])"
+            }
+        },
+        "en": {
+            "name": "Send Message to Feishu Contact",
+            "params": {
+                "contact_name": "Contact Name",
+                "content": "Message content. Can be a string or an OpenAI multimodal list format."
+            }
+        }
+    }
+)
+async def feishu_send_message_to_contact(
+    contact_name: str,
+    content: Union[str, List[Dict[str, Any]]],
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    给指定的联系人发送消息。支持发送文本和图片。
+    如果内容是 OpenAI 多模态格式,会自动转换为飞书相应的格式并发起多次发送。
+    发送成功后会更新 contacts.json 中的 chat_id。
+    """
+    contact = get_contact_full_info(contact_name)
+    if not contact:
+        return ToolResult(title="发送失败", output=f"未找到联系人: {contact_name}", error="Contact not found")
+
+    client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
+    
+    # 确定接收者 ID (优先使用 chat_id,否则使用 open_id)
+    receive_id = contact.get("chat_id") or contact.get("open_id") or contact.get("user_id")
+    if not receive_id:
+        return ToolResult(title="发送失败", output="联系人 ID 信息缺失", error="Receiver ID not found in contacts.json")
+
+    try:
+        last_res = None
+        if isinstance(content, str):
+            last_res = client.send_message(to=receive_id, text=content)
+        elif isinstance(content, list):
+            for item in content:
+                item_type = item.get("type")
+                if item_type == "text":
+                    last_res = client.send_message(to=receive_id, text=item.get("text", ""))
+                elif item_type == "image_url":
+                    img_info = item.get("image_url", {})
+                    url = img_info.get("url")
+                    if url.startswith("data:image"):
+                        # 处理 base64 图片
+                        try:
+                            if "," in url:
+                                _, encoded = url.split(",", 1)
+                            else:
+                                encoded = url
+                            image_bytes = base64.b64decode(encoded)
+                            last_res = client.send_image(to=receive_id, image=image_bytes)
+                        except Exception as e:
+                            print(f"解析 base64 图片失败: {e}")
+                    else:
+                        # 处理网络 URL
+                        try:
+                            async with httpx.AsyncClient() as httpx_client:
+                                img_resp = await httpx_client.get(url, timeout=15.0)
+                                img_resp.raise_for_status()
+                                last_res = client.send_image(to=receive_id, image=img_resp.content)
+                        except Exception as e:
+                            print(f"下载图片失败: {e}")
+        else:
+            return ToolResult(title="发送失败", output="不支持的内容格式", error="Invalid content format")
+
+        if last_res:
+            # 更新 chat_id
+            update_contact_chat_id(contact_name, last_res.chat_id)
+            return ToolResult(
+                title=f"消息已成功发送至 {contact_name}",
+                output=f"发送成功。消息 ID: {last_res.message_id}",
+                metadata={"message_id": last_res.message_id, "chat_id": last_res.chat_id}
+            )
+        return ToolResult(title="发送失败", output="没有执行成功的发送操作")
+    except Exception as e:
+        return ToolResult(title="发送异常", output=str(e), error=str(e))
+
+@tool(
+    display={
+        "zh": {
+            "name": "获取飞书联系人回复",
+            "params": {
+                "contact_name": "联系人名称"
+            }
+        },
+        "en": {
+            "name": "Get Feishu Contact Replies",
+            "params": {
+                "contact_name": "Contact Name"
+            }
+        }
+    }
+)
+async def feishu_get_contact_replies(
+    contact_name: str,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    获取指定联系人的最新回复消息。
+    返回的数据格式为 OpenAI 多模态消息内容列表。
+    只抓取自上一个机器人消息之后的用户回复。
+    """
+    contact = get_contact_full_info(contact_name)
+    if not contact:
+        return ToolResult(title="获取失败", output=f"未找到联系人: {contact_name}", error="Contact not found")
+
+    chat_id = contact.get("chat_id")
+    if not chat_id:
+        return ToolResult(title="获取失败", output=f"联系人 {contact_name} 尚未建立会话 (无 chat_id)", error="No chat_id")
+
+    client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
+    
+    try:
+        msg_list_res = client.get_message_list(chat_id=chat_id)
+        if not msg_list_res or "items" not in msg_list_res:
+            return ToolResult(title="获取失败", output="无法获取消息列表或结果为空")
+
+        openai_blocks = []
+        # 遍历消息列表 (最新的在前)
+        for msg in msg_list_res["items"]:
+            if msg.get("sender_type") == "app":
+                # 碰到机器人的消息即停止
+                break
+            
+            content_blocks = _convert_feishu_msg_to_openai_content(client, msg)
+            openai_blocks.extend(content_blocks)
+
+        # 反转列表以保持时间正序 (旧 -> 新)
+        openai_blocks.reverse()
+
+        return ToolResult(
+            title=f"获取 {contact_name} 回复成功",
+            output=json.dumps(openai_blocks, ensure_ascii=False, indent=2) if openai_blocks else "目前没有新的用户回复",
+            metadata={"replies": openai_blocks}
+        )
+    except Exception as e:
+        return ToolResult(title="获取回复异常", output=str(e), error=str(e))
+
+def _convert_feishu_msg_to_openai_content(client: FeishuClient, msg: Dict[str, Any]) -> List[Dict[str, Any]]:
+    """将单条飞书消息内容转换为 OpenAI 多模态格式块列表"""
+    blocks = []
+    msg_type = msg.get("content_type")
+    raw_content = msg.get("content", "")
+    message_id = msg.get("message_id")
+
+    if msg_type == "text":
+        blocks.append({"type": "text", "text": raw_content})
+    elif msg_type == "image":
+        try:
+            content_dict = json.loads(raw_content)
+            image_key = content_dict.get("image_key")
+            if image_key and message_id:
+                img_bytes = client.download_message_resource(
+                    message_id=message_id,
+                    file_key=image_key,
+                    resource_type="image"
+                )
+                b64_str = base64.b64encode(img_bytes).decode('utf-8')
+                blocks.append({
+                    "type": "image_url",
+                    "image_url": {"url": f"data:image/png;base64,{b64_str}"}
+                })
+        except Exception as e:
+            print(f"转换图片消息失败: {e}")
+            blocks.append({"type": "text", "text": "[图片内容获取失败]"})
+    elif msg_type == "post":
+        blocks.append({"type": "text", "text": raw_content})
+    else:
+        blocks.append({"type": "text", "text": f"[{msg_type} 消息]: {raw_content}"})
+    
+    return blocks
+
+@tool(
+    display={
+        "zh": {
+            "name": "获取飞书聊天历史记录",
+            "params": {
+                "contact_name": "联系人名称",
+                "start_time": "起始时间戳 (秒),可选",
+                "end_time": "结束时间戳 (秒),可选",
+                "page_size": "分页大小,默认 20",
+                "page_token": "分页令牌,用于加载下一页,可选"
+            }
+        },
+        "en": {
+            "name": "Get Feishu Chat History",
+            "params": {
+                "contact_name": "Contact Name",
+                "start_time": "Start timestamp (seconds), optional",
+                "end_time": "End timestamp (seconds), optional",
+                "page_size": "Page size, default 20",
+                "page_token": "Page token for next page, optional"
+            }
+        }
+    }
+)
+async def feishu_get_chat_history(
+    contact_name: str,
+    start_time: Optional[int] = None,
+    end_time: Optional[int] = None,
+    page_size: int = 20,
+    page_token: Optional[str] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    根据联系人名称获取完整的历史聊天记录。
+    支持通过时间戳进行范围筛选,并支持分页获取。
+    返回的消息按时间倒序排列(最新的在前面)。
+    """
+    contact = get_contact_full_info(contact_name)
+    if not contact:
+        return ToolResult(title="获取历史失败", output=f"未找到联系人: {contact_name}", error="Contact not found")
+
+    chat_id = contact.get("chat_id")
+    if not chat_id:
+        return ToolResult(title="获取历史失败", output=f"联系人 {contact_name} 尚未建立会话 (无 chat_id)", error="No chat_id")
+
+    client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
+
+    try:
+        res = client.get_message_list(
+            chat_id=chat_id,
+            start_time=start_time,
+            end_time=end_time,
+            page_size=page_size,
+            page_token=page_token
+        )
+
+        if not res or "items" not in res:
+            return ToolResult(title="获取历史失败", output="请求接口失败或返回为空")
+
+        # 将所有消息转换为 OpenAI 多模态格式
+        formatted_messages = []
+        for msg in res["items"]:
+            formatted_messages.append({
+                "message_id": msg.get("message_id"),
+                "sender_id": msg.get("sender_id"),
+                "sender_type": "assistant" if msg.get("sender_type") == "app" else "user",
+                "create_time": msg.get("create_time"),
+                "content": _convert_feishu_msg_to_openai_content(client, msg)
+            })
+
+        result_data = {
+            "messages": formatted_messages,
+            "page_token": res.get("page_token"),
+            "has_more": res.get("has_more")
+        }
+
+        return ToolResult(
+            title=f"获取 {contact_name} 历史记录成功",
+            output=json.dumps(result_data, ensure_ascii=False, indent=2),
+            metadata=result_data
+        )
+    except Exception as e:
+        return ToolResult(title="获取历史异常", output=str(e), error=str(e))

+ 79 - 0
agent/tools/builtin/feishu/chat_test.py

@@ -0,0 +1,79 @@
+import asyncio
+import json
+import os
+import sys
+
+# 将项目根目录添加到 python 路径,确保可以正确导入 agent 包
+PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
+if PROJECT_ROOT not in sys.path:
+    sys.path.append(PROJECT_ROOT)
+
+from agent.tools.builtin.feishu.chat import (
+    feishu_get_contact_list,
+    feishu_send_message_to_contact,
+    feishu_get_contact_replies,
+    feishu_get_chat_history
+)
+
+async def feishu_tools():
+    print("开始测试飞书工具...\n")
+
+    # # 1. 测试获取联系人列表
+    # print("--- 测试: feishu_get_contact_list ---")
+    # result_list = await feishu_get_contact_list()
+    # print(f"标题: {result_list.title}")
+    # print(f"输出: {result_list.output}")
+    # print("-" * 30 + "\n")
+    #
+    # # 2. 测试发送消息 (以 '谭景玉' 为例,请确保 contacts.json 中有此人且信息正确)
+    contact_name = "谭景玉"
+    # print(f"--- 测试: feishu_send_message_to_contact (对象: {contact_name}) ---")
+    #
+    # # 测试发送纯文本
+    # text_content = "你好,这是一条来自开发环境的自动化测试消息 (纯文本)。"
+    # print(f"正在发送文本: {text_content}")
+    # result_send_text = await feishu_send_message_to_contact(contact_name, text_content)
+    # print(f"标题: {result_send_text.title}")
+    # print(f"输出: {result_send_text.output}")
+    # if result_send_text.error:
+    #     print(f"错误: {result_send_text.error}")
+    #
+    # 测试发送多模态消息 (文本 + 图片)
+    # 注意:这里的图片 URL 需要是一个可访问的地址,或者你可以使用 base64 格式
+    # multimodal_content = [
+    #     {"type": "text", "text": "这是一条多模态测试消息:"},
+    #     {"type": "image_url", "image_url": {"url": "https://www.baidu.com/img/flexible/logo/pc/result.png"}}
+    # ]
+    # print(f"\n正在发送多模态消息...")
+    # result_send_multi = await feishu_send_message_to_contact(contact_name, multimodal_content)
+    # print(f"标题: {result_send_multi.title}")
+    # # print(f"输出: {result_send_multi.output}")
+    # if result_send_multi.error:
+    #     print(f"错误: {result_send_multi.error}")
+    # print("-" * 30 + "\n")
+
+    # # 3. 测试获取回复
+    # print(f"--- 测试: feishu_get_contact_replies (对象: {contact_name}) ---")
+    # result_replies = await feishu_get_contact_replies(contact_name)
+    # print(f"标题: {result_replies.title}")
+    # print(f"消息详情: {result_replies.output}")
+    # print("-" * 30 + "\n")
+
+    # 4. 测试获取历史记录
+    print(f"--- 测试: feishu_get_chat_history (对象: {contact_name}) ---")
+    result_history = await feishu_get_chat_history(contact_name, page_size=5, page_token="4cXSlmN7uFAnWWU5yfIGMNvUNrBPLlXZREzLcnvUtOcmK2QFKfwEqfbui_UDsR-y8ne0BkzXABiYTAQASh-n7my_3zQp6o3ERRz0bZ4LB5zMvahf8x7OQoso1rjrMaKM")
+    print(f"标题: {result_history.title}")
+    print(f"历史记录输出: {result_history.output}")
+    print("-" * 30 + "\n")
+
+if __name__ == "__main__":
+    # 模拟环境变量 (如果在系统环境变量中已设置,此处可省略)
+    os.environ["FEISHU_APP_ID"] = "cli_a90fe317987a9cc9"
+    os.environ["FEISHU_APP_SECRET"] = "nn2dWuXTiRA2N6xodbm4g0qz1AfM2ayi"
+    
+    try:
+        asyncio.run(feishu_tools())
+    except KeyboardInterrupt:
+        pass
+    except Exception as e:
+        print(f"测试过程中出现异常: {e}")

+ 8 - 0
agent/tools/builtin/feishu/contacts.json

@@ -0,0 +1,8 @@
+[
+  {
+    "name": "谭景玉",
+    "description": "开发工程师",
+    "open_id": "ou_11fdbd559cc6513ab53ff06d6c63413d",
+    "chat_id": "oc_56e85f0e2c97405d176729b62d8f56e5"
+  }
+]

+ 945 - 0
agent/tools/builtin/feishu/feishu_client.py

@@ -0,0 +1,945 @@
+"""
+飞书消息处理客户端
+基于 OpenClaw 项目的飞书集成代码整理
+
+依赖安装:
+    pip install lark-oapi websocket-client requests
+
+使用示例:
+    client = FeishuClient(app_id="cli_xxx", app_secret="xxx")
+
+    # 发送消息
+    client.send_message(to="ou_xxx", text="Hello!")
+
+    # 监听消息
+    client.start_websocket(on_message=my_handler)
+"""
+
+import json
+import io
+import logging
+import os
+import tempfile
+import threading
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any, Callable, Dict, List, Optional, Union
+
+import lark_oapi as lark
+from lark_oapi.api.contact.v3 import GetUserRequest, GetUserResponse
+from lark_oapi.api.im.v1 import (
+    CreateMessageRequest, CreateMessageRequestBody,
+    ReplyMessageRequest, ReplyMessageRequestBody,
+    GetMessageRequest, GetMessageResponse,
+    PatchMessageRequest, PatchMessageRequestBody,
+    CreateImageRequest, CreateImageRequestBody,
+    GetImageRequest,
+    CreateFileRequest, CreateFileRequestBody,
+    GetMessageResourceRequest, GetMessageResourceResponse,
+    ListMessageRequest, ListMessageResponse
+)
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+class FeishuDomain(Enum):
+    """飞书域名"""
+    FEISHU = "https://open.feishu.cn"      # 中国版
+    LARK = "https://open.larksuite.com"    # 国际版
+
+
+class ChatType(Enum):
+    """聊天类型"""
+    P2P = "p2p"      # 私聊
+    GROUP = "group"  # 群聊
+
+
+class ReceiveIdType(Enum):
+    """接收者ID类型"""
+    OPEN_ID = "open_id"
+    USER_ID = "user_id"
+    UNION_ID = "union_id"
+    EMAIL = "email"
+    CHAT_ID = "chat_id"
+
+
+@dataclass
+class FeishuMessageEvent:
+    """飞书消息事件"""
+    message_id: str
+    chat_id: str
+    chat_type: ChatType
+    content: str
+    content_type: str  # text, image, file, post, etc.
+    sender_open_id: str
+    sender_user_id: Optional[str] = None
+    sender_name: Optional[str] = None
+    root_id: Optional[str] = None      # 根消息ID(话题)
+    parent_id: Optional[str] = None    # 父消息ID(回复)
+    mentions: List[Dict] = field(default_factory=list)
+    mentioned_bot: bool = False
+
+
+@dataclass
+class SendResult:
+    """发送结果"""
+    message_id: str
+    chat_id: str
+
+
+class FeishuClient:
+    """
+    飞书客户端
+
+    功能:
+    - 发送/接收消息
+    - 上传/下载媒体文件
+    - WebSocket 实时监听
+    """
+
+    def __init__(
+        self,
+        app_id: str,
+        app_secret: str,
+        domain: FeishuDomain = FeishuDomain.FEISHU,
+        encrypt_key: Optional[str] = None,
+        verification_token: Optional[str] = None,
+    ):
+        """
+        初始化飞书客户端
+
+        Args:
+            app_id: 飞书应用 App ID
+            app_secret: 飞书应用 App Secret
+            domain: 飞书域名 (FEISHU 或 LARK)
+            encrypt_key: 事件加密密钥 (可选)
+            verification_token: 事件验证令牌 (可选)
+        """
+        self.app_id = app_id
+        self.app_secret = app_secret
+        self.domain = domain
+        self.encrypt_key = encrypt_key
+        self.verification_token = verification_token
+
+        # 创建 Lark 客户端
+        self.client = lark.Client.builder() \
+            .app_id(app_id) \
+            .app_secret(app_secret) \
+            .domain(domain.value) \
+            .build()
+
+        # 缓存
+        self._bot_open_id: Optional[str] = None
+        self._sender_name_cache: Dict[str, str] = {}
+
+    # ==================== 消息发送 ====================
+
+    def send_message(
+        self,
+        to: str,
+        text: str,
+        reply_to_message_id: Optional[str] = None,
+        receive_id_type: Optional[ReceiveIdType] = None,
+    ) -> SendResult:
+        """
+        发送文本消息
+
+        Args:
+            to: 接收者ID (open_id, user_id, chat_id 等)
+            text: 消息文本
+            reply_to_message_id: 回复的消息ID (可选)
+            receive_id_type: 接收者ID类型 (可选,自动推断)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        if receive_id_type is None:
+            receive_id_type = self._resolve_receive_id_type(to)
+
+        # 构建富文本消息 (支持 Markdown)
+        content = json.dumps({
+            "zh_cn": {
+                "content": [[{"tag": "md", "text": text}]]
+            }
+        })
+
+        if reply_to_message_id:
+            # 回复消息
+            request = ReplyMessageRequest.builder() \
+                .message_id(reply_to_message_id) \
+                .request_body(ReplyMessageRequestBody.builder()
+                    .content(content)
+                    .msg_type("post")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.reply(request)
+        else:
+            # 新消息
+            request = CreateMessageRequest.builder() \
+                .receive_id_type(receive_id_type.value) \
+                .request_body(CreateMessageRequestBody.builder()
+                    .receive_id(to)
+                    .content(content)
+                    .msg_type("post")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.create(request)
+
+        if not response.success():
+            raise Exception(f"发送消息失败: {response.msg} (code: {response.code})")
+
+        return SendResult(
+            message_id=response.data.message_id,
+            chat_id=response.data.chat_id
+        )
+
+    def send_card(
+        self,
+        to: str,
+        card: Dict[str, Any],
+        reply_to_message_id: Optional[str] = None,
+        receive_id_type: Optional[ReceiveIdType] = None,
+    ) -> SendResult:
+        """
+        发送卡片消息 (交互式消息)
+
+        Args:
+            to: 接收者ID
+            card: 卡片内容 (JSON 结构)
+            reply_to_message_id: 回复的消息ID (可选)
+            receive_id_type: 接收者ID类型 (可选)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        if receive_id_type is None:
+            receive_id_type = self._resolve_receive_id_type(to)
+
+        content = json.dumps(card)
+
+        if reply_to_message_id:
+            request = ReplyMessageRequest.builder() \
+                .message_id(reply_to_message_id) \
+                .request_body(ReplyMessageRequestBody.builder()
+                    .content(content)
+                    .msg_type("interactive")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.reply(request)
+        else:
+            request = CreateMessageRequest.builder() \
+                .receive_id_type(receive_id_type.value) \
+                .request_body(CreateMessageRequestBody.builder()
+                    .receive_id(to)
+                    .content(content)
+                    .msg_type("interactive")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.create(request)
+
+        if not response.success():
+            raise Exception(f"发送卡片失败: {response.msg}")
+
+        return SendResult(
+            message_id=response.data.message_id,
+            chat_id=response.data.chat_id
+        )
+
+    def send_markdown_card(
+        self,
+        to: str,
+        text: str,
+        reply_to_message_id: Optional[str] = None,
+    ) -> SendResult:
+        """
+        发送 Markdown 卡片 (更好的格式渲染)
+
+        Args:
+            to: 接收者ID
+            text: Markdown 文本
+            reply_to_message_id: 回复的消息ID (可选)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        card = {
+            "config": {"wide_screen_mode": True},
+            "elements": [{"tag": "markdown", "content": text}]
+        }
+        return self.send_card(to, card, reply_to_message_id)
+
+    # ==================== 媒体处理 ====================
+
+    def upload_image(
+            self,
+            image: Union[bytes, str],
+            image_type: str = "message"
+    ) -> str:
+        """
+        上传图片
+        """
+        file_obj = None
+
+        try:
+            # 1. 准备文件对象
+            if isinstance(image, str):
+                # 如果是路径,直接打开
+                file_obj = open(image, "rb")
+            else:
+                # 如果是二进制数据,使用内存文件 (避免写磁盘)
+                file_obj = io.BytesIO(image)
+                # 某些 SDK/API 依赖文件名来判断 Content-Type,我们手动给一个名字
+                # 如果知道真实格式更好,不知道则默认 .png 或 .bin
+                file_obj.name = "upload.png"
+
+                # 2. 构建请求
+            # 注意:这里直接传入 file_obj
+            request = CreateImageRequest.builder() \
+                .request_body(CreateImageRequestBody.builder()
+                              .image_type(image_type)
+                              .image(file_obj)
+                              .build()) \
+                .build()
+
+            # 3. 发起请求
+            response = self.client.im.v1.image.create(request)
+
+            if not response.success():
+                raise Exception(f"上传图片失败: {response.msg}")
+
+            return response.data.image_key
+
+        finally:
+            # 4. 显式关闭文件句柄
+            if file_obj and not isinstance(file_obj, io.BytesIO):
+                file_obj.close()
+
+    def download_image(self, image_key: str) -> bytes:
+        """
+        下载图片
+
+        Args:
+            image_key: 图片 key
+
+        Returns:
+            bytes: 图片数据
+        """
+        request = GetImageRequest.builder() \
+            .image_key(image_key) \
+            .build()
+
+        response = self.client.im.v1.image.get(request)
+
+        if not response.success():
+            raise Exception(f"下载图片失败: {response.msg}")
+
+        return response.file.read()
+
+    def send_image(
+        self,
+        to: str,
+        image: Union[bytes, str],
+        reply_to_message_id: Optional[str] = None,
+    ) -> SendResult:
+        """
+        发送图片消息
+
+        Args:
+            to: 接收者ID
+            image: 图片数据或文件路径
+            reply_to_message_id: 回复的消息ID (可选)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        image_key = self.upload_image(image)
+        content = json.dumps({"image_key": image_key})
+
+        receive_id_type = self._resolve_receive_id_type(to)
+
+        if reply_to_message_id:
+            request = ReplyMessageRequest.builder() \
+                .message_id(reply_to_message_id) \
+                .request_body(ReplyMessageRequestBody.builder()
+                    .content(content)
+                    .msg_type("image")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.reply(request)
+        else:
+            request = CreateMessageRequest.builder() \
+                .receive_id_type(receive_id_type.value) \
+                .request_body(CreateMessageRequestBody.builder()
+                    .receive_id(to)
+                    .content(content)
+                    .msg_type("image")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.create(request)
+
+        if not response.success():
+            raise Exception(f"发送图片失败: {response.msg}")
+
+        return SendResult(
+            message_id=response.data.message_id,
+            chat_id=response.data.chat_id
+        )
+
+    def upload_file(
+        self,
+        file: Union[bytes, str],
+        file_name: str,
+        file_type: str = "stream",
+    ) -> str:
+        """
+        上传文件
+
+        Args:
+            file: 文件数据或路径
+            file_name: 文件名
+            file_type: 文件类型 (opus/mp4/pdf/doc/xls/ppt/stream)
+
+        Returns:
+            str: file_key
+        """
+        if isinstance(file, str):
+            with open(file, "rb") as f:
+                file_data = f.read()
+            if not file_name:
+                file_name = os.path.basename(file)
+        else:
+            file_data = file
+
+        with tempfile.NamedTemporaryFile(delete=False) as tmp:
+            tmp.write(file_data)
+            tmp_path = tmp.name
+
+        try:
+            request = CreateFileRequest.builder() \
+                .request_body(CreateFileRequestBody.builder()
+                    .file_type(file_type)
+                    .file_name(file_name)
+                    .file(open(tmp_path, "rb"))
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.file.create(request)
+
+            if not response.success():
+                raise Exception(f"上传文件失败: {response.msg}")
+
+            return response.data.file_key
+        finally:
+            os.unlink(tmp_path)
+
+    def send_file(
+        self,
+        to: str,
+        file: Union[bytes, str],
+        file_name: str,
+        reply_to_message_id: Optional[str] = None,
+    ) -> SendResult:
+        """
+        发送文件消息
+
+        Args:
+            to: 接收者ID
+            file: 文件数据或路径
+            file_name: 文件名
+            reply_to_message_id: 回复的消息ID (可选)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        file_type = self._detect_file_type(file_name)
+        file_key = self.upload_file(file, file_name, file_type)
+        content = json.dumps({"file_key": file_key})
+
+        receive_id_type = self._resolve_receive_id_type(to)
+
+        if reply_to_message_id:
+            request = ReplyMessageRequest.builder() \
+                .message_id(reply_to_message_id) \
+                .request_body(ReplyMessageRequestBody.builder()
+                    .content(content)
+                    .msg_type("file")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.reply(request)
+        else:
+            request = CreateMessageRequest.builder() \
+                .receive_id_type(receive_id_type.value) \
+                .request_body(CreateMessageRequestBody.builder()
+                    .receive_id(to)
+                    .content(content)
+                    .msg_type("file")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.create(request)
+
+        if not response.success():
+            raise Exception(f"发送文件失败: {response.msg}")
+
+        return SendResult(
+            message_id=response.data.message_id,
+            chat_id=response.data.chat_id
+        )
+
+    def download_message_resource(
+        self,
+        message_id: str,
+        file_key: str,
+        resource_type: str = "file"
+    ) -> bytes:
+        """
+        下载消息中的资源文件
+
+        Args:
+            message_id: 消息ID
+            file_key: 文件 key
+            resource_type: 资源类型 ("image" 或 "file")
+
+        Returns:
+            bytes: 文件数据
+        """
+        request = GetMessageResourceRequest.builder() \
+            .message_id(message_id) \
+            .file_key(file_key) \
+            .type(resource_type) \
+            .build()
+
+        response = self.client.im.v1.message_resource.get(request)
+
+        if not response.success():
+            raise Exception(f"下载资源失败: {response.msg}")
+
+        return response.file.read()
+
+    # ==================== 消息获取 ====================
+
+    def get_message(self, message_id: str) -> Optional[Dict]:
+        """
+        获取消息详情
+
+        Args:
+            message_id: 消息ID
+
+        Returns:
+            Dict: 消息详情,失败返回 None
+        """
+        request = GetMessageRequest.builder() \
+            .message_id(message_id) \
+            .build()
+
+        response = self.client.im.v1.message.get(request)
+
+        if not response.success():
+            return None
+
+        items = response.data.items
+        if not items:
+            return None
+
+        item = items[0]
+        content = item.body.content if item.body else ""
+
+        # 解析文本内容
+        try:
+            parsed = json.loads(content)
+            if item.msg_type == "text" and "text" in parsed:
+                content = parsed["text"]
+        except:
+            pass
+
+        return {
+            "message_id": item.message_id,
+            "chat_id": item.chat_id,
+            "sender_id": item.sender.id if item.sender else None,
+            "sender_type": item.sender.sender_type if item.sender else None,
+            "content": content,
+            "content_type": item.msg_type,
+            "create_time": item.create_time,
+        }
+
+    def get_message_list(self, chat_id: str, start_time: Optional[Union[str, int]] = None, end_time: Optional[Union[str, int]] = None, page_size: int = 20, page_token: Optional[str] = None) -> Optional[Dict]:
+        """
+        获取消息列表
+
+        Args:
+            chat_id: 会话 ID
+            start_time: 起始时间 (可选)
+            end_time: 结束时间 (可选)
+            page_size: 分页大小 (默认 20)
+            page_token: 分页令牌 (可选)
+
+        Returns:
+            Dict: 包含消息列表和分页信息,失败返回 None
+        """
+        builder = ListMessageRequest.builder() \
+            .container_id_type("chat") \
+            .container_id(chat_id) \
+            .sort_type("ByCreateTimeDesc") \
+            .page_size(page_size)
+
+        if start_time is not None:
+            builder.start_time(str(start_time))
+        if end_time is not None:
+            builder.end_time(str(end_time))
+        if page_token:
+            builder.page_token(page_token)
+
+        request = builder.build()
+
+        # 发起请求
+        response: ListMessageResponse = self.client.im.v1.message.list(request)
+
+        # 处理失败返回
+        if not response.success():
+            logger.error(
+                f"client.im.v1.message.list failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}")
+            return None
+
+        # 构建返回结果
+        messages = []
+        if response.data.items:
+            for item in response.data.items:
+                content = item.body.content if item.body else ""
+                # 解析文本内容
+                try:
+                    parsed = json.loads(content)
+                    if item.msg_type == "text" and "text" in parsed:
+                        content = parsed["text"]
+                except:
+                    pass
+
+                messages.append({
+                    "message_id": item.message_id,
+                    "chat_id": item.chat_id,
+                    "sender_id": item.sender.id if item.sender else None,
+                    "sender_type": item.sender.sender_type if item.sender else None,
+                    "content": content,
+                    "content_type": item.msg_type,
+                    "create_time": item.create_time,
+                })
+
+        return {
+            "items": messages,
+            "page_token": response.data.page_token,
+            "has_more": response.data.has_more
+        }
+
+    # ==================== 用户信息 ====================
+
+    def get_user_info(self, open_id: str) -> Optional[Dict]:
+        """
+        获取用户信息
+
+        Args:
+            open_id: 用户 open_id
+
+        Returns:
+            Dict: 用户信息,失败返回 None
+        """
+        # 检查缓存
+        if open_id in self._sender_name_cache:
+            return {"name": self._sender_name_cache[open_id]}
+
+        request = GetUserRequest.builder() \
+            .user_id(open_id) \
+            .user_id_type("open_id") \
+            .build()
+
+        response = self.client.contact.v3.user.get(request)
+
+        if not response.success():
+            return None
+
+        user = response.data.user
+        name = user.name or user.en_name or user.nickname
+
+        if name:
+            self._sender_name_cache[open_id] = name
+
+        return {
+            "open_id": user.open_id,
+            "user_id": user.user_id,
+            "name": name,
+            "en_name": user.en_name,
+            "nickname": user.nickname,
+            "email": user.email,
+            "mobile": user.mobile,
+            "avatar": user.avatar.avatar_origin if user.avatar else None,
+        }
+
+    # ==================== WebSocket 监听 ====================
+
+    def start_websocket(
+        self,
+        on_message: Callable[[FeishuMessageEvent], None],
+        on_bot_added: Optional[Callable[[str], None]] = None,
+        on_bot_removed: Optional[Callable[[str], None]] = None,
+        blocking: bool = True,
+    ):
+        """
+        启动 WebSocket 监听消息
+
+        Args:
+            on_message: 消息回调函数
+            on_bot_added: 机器人被添加到群的回调 (可选)
+            on_bot_removed: 机器人被移出群的回调 (可选)
+            blocking: 是否阻塞当前线程
+        """
+        # 创建事件处理器
+        # 注意: lark-oapi SDK 的回调函数只接受一个参数 (data)
+        event_handler = lark.EventDispatcherHandler.builder(
+            self.encrypt_key or "",
+            self.verification_token or ""
+        ).register_p2_im_message_receive_v1(
+            lambda data: self._handle_message_event(data, on_message)
+        )
+
+        if on_bot_added:
+            event_handler = event_handler.register_p2_im_chat_member_bot_added_v1(
+                lambda data: on_bot_added(data.event.chat_id)
+            )
+
+        if on_bot_removed:
+            event_handler = event_handler.register_p2_im_chat_member_bot_deleted_v1(
+                lambda data: on_bot_removed(data.event.chat_id)
+            )
+
+        handler = event_handler.build()
+
+        # 创建 WebSocket 客户端
+        ws_client = lark.ws.Client(
+            self.app_id,
+            self.app_secret,
+            event_handler=handler,
+            domain=lark.FEISHU_DOMAIN if self.domain == FeishuDomain.FEISHU else lark.LARK_DOMAIN,
+            log_level=lark.LogLevel.INFO,
+        )
+
+        logger.info("启动飞书 WebSocket 监听...")
+
+        if blocking:
+            ws_client.start()
+        else:
+            thread = threading.Thread(target=ws_client.start, daemon=True)
+            thread.start()
+            return thread
+
+    def _handle_message_event(
+        self,
+        data,
+        callback: Callable[[FeishuMessageEvent], None]
+    ):
+        """处理消息事件"""
+        try:
+            # data 结构: P2ImMessageReceiveV1 对象
+            # data.event 包含实际的事件数据
+            event = data.event
+            msg = event.message
+            sender = event.sender
+
+            # 解析消息内容
+            content = self._parse_message_content(msg.content, msg.message_type)
+
+            # 检查是否 @了机器人
+            mentioned_bot = self._check_bot_mentioned(msg.mentions)
+
+            # 去除 @机器人 的文本
+            if msg.mentions:
+                content = self._strip_bot_mention(content, msg.mentions)
+
+            # 构建事件对象
+            message_event = FeishuMessageEvent(
+                message_id=msg.message_id,
+                chat_id=msg.chat_id,
+                chat_type=ChatType(msg.chat_type),
+                content=content,
+                content_type=msg.message_type,
+                sender_open_id=sender.sender_id.open_id if sender.sender_id else "",
+                sender_user_id=sender.sender_id.user_id if sender.sender_id else None,
+                root_id=msg.root_id,
+                parent_id=msg.parent_id,
+                mentions=[],  # 简化处理
+                mentioned_bot=mentioned_bot,
+            )
+
+            # 尝试获取发送者名称 (可能会失败,不影响主流程)
+            try:
+                if message_event.sender_open_id:
+                    user_info = self.get_user_info(message_event.sender_open_id)
+                    if user_info:
+                        message_event.sender_name = user_info.get("name")
+            except Exception as e:
+                logger.debug(f"获取用户信息失败: {e}")
+
+            callback(message_event)
+
+        except Exception as e:
+            logger.error(f"处理消息事件失败: {e}", exc_info=True)
+
+    # ==================== 辅助方法 ====================
+
+    def _resolve_receive_id_type(self, receive_id: str) -> ReceiveIdType:
+        """推断接收者ID类型"""
+        if receive_id.startswith("ou_"):
+            return ReceiveIdType.OPEN_ID
+        elif receive_id.startswith("on_"):
+            return ReceiveIdType.UNION_ID
+        elif receive_id.startswith("oc_"):
+            return ReceiveIdType.CHAT_ID
+        elif "@" in receive_id:
+            return ReceiveIdType.EMAIL
+        else:
+            return ReceiveIdType.USER_ID
+
+    def _parse_message_content(self, content: str, message_type: str) -> str:
+        """解析消息内容"""
+        try:
+            parsed = json.loads(content)
+            if message_type == "text":
+                return parsed.get("text", "")
+            elif message_type == "post":
+                return self._parse_post_content(parsed)
+            return content
+        except:
+            return content
+
+    def _parse_post_content(self, parsed: Dict) -> str:
+        """解析富文本消息"""
+        title = parsed.get("title", "")
+        content_blocks = parsed.get("content", [])
+
+        text_parts = [title] if title else []
+
+        for paragraph in content_blocks:
+            if isinstance(paragraph, list):
+                for element in paragraph:
+                    if element.get("tag") == "text":
+                        text_parts.append(element.get("text", ""))
+                    elif element.get("tag") == "a":
+                        text_parts.append(element.get("text", element.get("href", "")))
+                    elif element.get("tag") == "at":
+                        text_parts.append(f"@{element.get('user_name', '')}")
+
+        return "\n".join(text_parts).strip() or "[富文本消息]"
+
+    def _check_bot_mentioned(self, mentions: Optional[List]) -> bool:
+        """检查是否 @了机器人"""
+        if not mentions:
+            return False
+
+        if not self._bot_open_id:
+            # 如果没有缓存机器人 open_id,假设有 mention 就是 @了机器人
+            return len(mentions) > 0
+
+        return any(m.id.open_id == self._bot_open_id for m in mentions)
+
+    def _strip_bot_mention(self, text: str, mentions: List) -> str:
+        """去除 @机器人 的文本"""
+        result = text
+        for mention in mentions:
+            name = mention.name if hasattr(mention, 'name') else ""
+            key = mention.key if hasattr(mention, 'key') else ""
+            if name:
+                result = result.replace(f"@{name}", "").strip()
+            if key:
+                result = result.replace(key, "").strip()
+        return result
+
+    def _detect_file_type(self, file_name: str) -> str:
+        """检测文件类型"""
+        ext = os.path.splitext(file_name)[1].lower()
+        type_map = {
+            ".opus": "opus", ".ogg": "opus",
+            ".mp4": "mp4", ".mov": "mp4", ".avi": "mp4",
+            ".pdf": "pdf",
+            ".doc": "doc", ".docx": "doc",
+            ".xls": "xls", ".xlsx": "xls",
+            ".ppt": "ppt", ".pptx": "ppt",
+        }
+        return type_map.get(ext, "stream")
+
+
+# ==================== 使用示例 ====================
+
+if __name__ == "__main__":
+    # 从环境变量获取配置
+    APP_ID = os.getenv("FEISHU_APP_ID", "cli_a90fe317987a9cc9")
+    APP_SECRET = os.getenv("FEISHU_APP_SECRET", "nn2dWuXTiRA2N6xodbm4g0qz1AfM2ayi")
+
+    if not APP_ID or not APP_SECRET:
+        print("请设置环境变量 FEISHU_APP_ID 和 FEISHU_APP_SECRET")
+        exit(1)
+
+    # 创建客户端
+    client = FeishuClient(
+        app_id=APP_ID,
+        app_secret=APP_SECRET,
+        domain=FeishuDomain.FEISHU,
+    )
+
+    # 消息处理回调
+    def handle_message(event: FeishuMessageEvent):
+        print(f"\n收到消息:")
+        print(f"  发送者: {event.sender_name or event.sender_open_id}")
+        print(f"  类型: {event.chat_type.value}")
+        print(f"  内容: {event.content}")
+        print(f"  @机器人: {event.mentioned_bot}")
+
+        # 自动回复示例
+        if event.chat_type == ChatType.P2P or event.mentioned_bot:
+            # 先回复文字
+            reply_text = f"收到你的消息: {event.content}"
+            chat_id = event.chat_id
+            content = event.content
+            content_type = event.content_type # image、text等
+            open_id = event.sender_open_id
+            client.send_message(
+                to=event.chat_id,
+                text=reply_text,
+                reply_to_message_id=event.message_id
+            )
+            print(f"  已回复文字: {reply_text}")
+
+            # 再回复一张图片 (读取当前目录下的 hanli.png)
+            try:
+                image_path = os.path.join(os.path.dirname(__file__) or ".", "hanli.png")
+                if os.path.exists(image_path):
+                    client.send_image(
+                        to=event.chat_id,
+                        image=image_path,
+                    )
+                    print(f"  已回复图片: {image_path}")
+                else:
+                    print(f"  图片不存在: {image_path}")
+            except Exception as e:
+                print(f"  回复图片失败: {e}")
+
+    # 启动 WebSocket 监听
+    print("启动飞书消息监听...")
+    print("按 Ctrl+C 退出")
+
+    try:
+        client.start_websocket(
+            on_message=handle_message,
+            on_bot_added=lambda chat_id: print(f"机器人被添加到群: {chat_id}"),
+            on_bot_removed=lambda chat_id: print(f"机器人被移出群: {chat_id}"),
+            blocking=True
+        )
+
+        # res = client.get_message_list(chat_id='oc_56e85f0e2c97405d176729b62d8f56e5', start_time=0, end_time=1770623620)
+        # print(f"获取消息列表结果: {json.dumps(res, indent=4, ensure_ascii=False)}")
+    except KeyboardInterrupt:
+        print("\n退出")

+ 3 - 0
requirements.txt

@@ -11,3 +11,6 @@ browser-use>=0.11.0
 fastapi>=0.115.0
 uvicorn[standard]>=0.32.0
 websockets>=13.0
+
+# 飞书
+lark-oapi==1.5.3