from __future__ import annotations import json import logging import os from typing import Any from yuxi.channels.models import DeliveryResult from .cards import build_feishu_card, build_feishu_post_content, build_feishu_text_content logger = logging.getLogger(__name__) try: import lark_oapi HAS_LARK_SDK = True _LarkAccessDenied = lark_oapi.exception.AccessDeniedException _LarkInvalidArgs = lark_oapi.exception.InvalidArgsException except ImportError: HAS_LARK_SDK = False lark_oapi = None # type: ignore class _DummyLarkError(BaseException): pass _LarkAccessDenied = _DummyLarkError _LarkInvalidArgs = _DummyLarkError IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"} CHUNK_STRATEGIES = ["auto", "zero", "newline", "sentence"] REPLY_DEGRADE_CODES = {230011, 231003} # Feishu 401 错误分类: 消息中包含这些关键词的才是 token 过期,需要刷新 _TOKEN_EXPIRED_KEYWORDS = ("tenant_access_token", "app_access_token", "token expire", "token invalid") def _is_token_expired_error(code: int, msg: str) -> bool: if code != 401: return False msg_lower = msg.lower() return any(kw in msg_lower for kw in _TOKEN_EXPIRED_KEYWORDS) def _resolve_receive_id_type(chat_type: str) -> str: if chat_type in ("direct", "private", "p2p"): return "open_id" if chat_type in ("group", "thread"): return "chat_id" return "open_id" async def reply_message( client: Any, message_id: str, content: str, *, buttons: list[dict[str, str]] | None = None, thread_id: str | None = None, use_post_format: bool = False, silent: bool = False, chat_type: str | None = None, chat_id: str | None = None, ) -> DeliveryResult: if not HAS_LARK_SDK: return DeliveryResult(success=False, error="lark-oapi SDK not available") try: if buttons: card = build_feishu_card(content, buttons=buttons) msg_content = json.dumps(card, ensure_ascii=False) msg_type = "interactive" elif use_post_format: msg_content = json.dumps(build_feishu_post_content(content), ensure_ascii=False) msg_type = "post" else: msg_content = json.dumps(build_feishu_text_content(content), ensure_ascii=False) msg_type = "text" request_body = ( lark_oapi.api.im.v1.ReplyMessageRequestBody.builder().content(msg_content).msg_type(msg_type).build() ) request = ( lark_oapi.api.im.v1.ReplyMessageRequest.builder().message_id(message_id).request_body(request_body).build() ) if thread_id: request.root_id = thread_id resp = await client.im.v1.message.reply(request) if resp.success(): return DeliveryResult(success=True, message_id=resp.data.get("message_id", "")) error_code = getattr(resp, "code", 0) if error_code in REPLY_DEGRADE_CODES: logger.info(f"[Feishu] Reply target revoked (code={error_code}), falling back to direct send") return await send_text( client, chat_id if chat_id else message_id, content, chat_type=chat_type or "private", thread_id=thread_id, ) return DeliveryResult(success=False, error=f"Feishu API error: {resp.msg}") except AttributeError as e: return DeliveryResult(success=False, error=f"SDK error: {e}") except _LarkAccessDenied as e: return DeliveryResult(success=False, error=f"Access denied: {e}") except _LarkInvalidArgs as e: return DeliveryResult(success=False, error=f"Invalid args: {e}") except Exception as e: logger.error(f"Feishu reply_message failed: {e}") return DeliveryResult(success=False, error=str(e)) async def send_text( client: Any, receive_id: str, content: str, *, chat_type: str = "private", thread_id: str | None = None, use_post_format: bool = False, silent: bool = False, ) -> DeliveryResult: if not HAS_LARK_SDK: return DeliveryResult(success=False, error="lark-oapi SDK not available") try: if use_post_format: content_json = build_feishu_post_content(content) msg_type = "post" else: content_json = build_feishu_text_content(content) msg_type = "text" msg_content = json.dumps(content_json, ensure_ascii=False) request = _make_create_msg_request(receive_id, chat_type, msg_type, msg_content) if thread_id: request.root_id = thread_id resp = await client.im.message.create(request) if resp.success(): return DeliveryResult(success=True, message_id=resp.data.get("message_id", "")) code = getattr(resp, "code", -1) msg = getattr(resp, "msg", str(resp)) if _is_token_expired_error(code, msg): return DeliveryResult(success=False, error=f"Auth failed: {msg}", error_code="auth_expired") return DeliveryResult(success=False, error=f"Send failed: {msg}") except AttributeError as e: return DeliveryResult(success=False, error=f"SDK error: {e}") except _LarkAccessDenied as e: return DeliveryResult(success=False, error=f"Access denied: {e}") except _LarkInvalidArgs as e: return DeliveryResult(success=False, error=f"Invalid args: {e}") except Exception as e: logger.error(f"Feishu send_text failed: {e}") return DeliveryResult(success=False, error=str(e)) async def read_message(client: Any, message_id: str) -> dict: if not HAS_LARK_SDK: return {} try: request = lark_oapi.api.im.v1.GetMessageRequest.builder().message_id(message_id).build() resp = await client.im.v1.message.get(request) if resp.success(): items = resp.data.get("items", []) if items: item = items[0] return { "message_id": item.get("message_id", ""), "msg_type": item.get("msg_type", ""), "chat_id": item.get("chat_id", ""), "chat_type": item.get("chat_type", ""), "content": item.get("body", {}).get("content", ""), "root_id": item.get("root_id", ""), "parent_id": item.get("parent_id", ""), "sender_id": item.get("sender", {}).get("id", ""), "create_time": item.get("create_time", ""), "mentions": item.get("mentions", []), } return {} except Exception as e: logger.error(f"[Feishu] read_message failed: {e}") return {} async def list_messages( client: Any, chat_id: str, *, page_token: str = "", page_size: int = 20, start_time: str = "", end_time: str = "", ) -> dict[str, Any]: if not HAS_LARK_SDK: return {"messages": [], "has_more": False, "page_token": ""} try: request_builder = ( lark_oapi.api.im.v1.ListMessageRequest.builder() .container_id_type("chat") .container_id(chat_id) .page_size(min(page_size, 50)) ) if page_token: request_builder.page_token(page_token) if start_time: request_builder.start_time(start_time) if end_time: request_builder.end_time(end_time) request = request_builder.build() resp = await client.im.v1.message.list(request) if not resp.success(): raise RuntimeError(f"获取消息列表失败: {resp.msg}") data = resp.data if hasattr(resp, "data") else {} items = data.get("items", []) messages = [ { "message_id": item.get("message_id", ""), "msg_type": item.get("msg_type", ""), "chat_id": item.get("chat_id", ""), "chat_type": item.get("chat_type", ""), "content": item.get("body", {}).get("content", ""), "root_id": item.get("root_id", ""), "parent_id": item.get("parent_id", ""), "sender_id": item.get("sender", {}).get("id", ""), "create_time": item.get("create_time", ""), "mentions": item.get("mentions", []), } for item in items ] return { "messages": messages, "has_more": data.get("has_more", False), "page_token": data.get("page_token", ""), } except _LarkAccessDenied as e: raise RuntimeError(f"权限不足: {e}") from e except _LarkInvalidArgs as e: raise RuntimeError(f"参数错误: {e}") from e except Exception as e: raise RuntimeError(f"获取消息列表失败: {e}") from e async def send_card( client: Any, receive_id: str, content: str, *, chat_type: str = "private", title: str = "AI 助手", buttons: list[dict[str, str]] | None = None, streaming: bool = False, thread_id: str | None = None, raw_card: bool = False, template: str | None = None, tone: str | None = None, url_unfurl: list[str] | None = None, silent: bool = False, ) -> DeliveryResult: if not HAS_LARK_SDK: return DeliveryResult(success=False, error="lark-oapi SDK not available") try: if raw_card: msg_content = content else: card = build_feishu_card( content, title=title, buttons=buttons, streaming=streaming, template=template, tone=tone, url_unfurl=url_unfurl, ) msg_content = json.dumps(card, ensure_ascii=False) request = _make_create_msg_request(receive_id, chat_type, "interactive", msg_content) if thread_id: request.root_id = thread_id resp = await client.im.message.create(request) if resp.success(): return DeliveryResult(success=True, message_id=resp.data.get("message_id", "")) code = getattr(resp, "code", -1) msg = getattr(resp, "msg", str(resp)) if _is_token_expired_error(code, msg): return DeliveryResult(success=False, error=f"Auth failed: {msg}", error_code="auth_expired") return DeliveryResult(success=False, error=f"Feishu API error: {msg}") except AttributeError as e: return DeliveryResult(success=False, error=f"SDK error: {e}") except _LarkAccessDenied as e: return DeliveryResult(success=False, error=f"Access denied: {e}") except _LarkInvalidArgs as e: return DeliveryResult(success=False, error=f"Invalid args: {e}") except Exception as e: logger.error(f"Feishu send_card failed: {e}") return DeliveryResult(success=False, error=str(e)) async def send_reaction( client: Any, message_id: str, reaction: str, *, chat_type: str = "private", ) -> DeliveryResult: if not HAS_LARK_SDK: return DeliveryResult(success=False, error="lark-oapi SDK not available") try: req = ( lark_oapi.api.im.v1.CreateMessageReactionRequest.builder() .message_id(message_id) .request_body( lark_oapi.api.im.v1.CreateMessageReactionRequestBody.builder() .reaction_type(lark_oapi.api.im.v1.CreateMessageReactionRequestBodyReactionType(emoji_type=reaction)) .build() ) .build() ) resp = await client.im.message.reaction.create(req) if resp.success(): return DeliveryResult(success=True) return DeliveryResult(success=False, error=resp.msg) except AttributeError: return DeliveryResult( success=False, error="SDK version incompatible: CreateMessageReactionRequestReactionType not found", ) except _LarkAccessDenied as e: return DeliveryResult(success=False, error=f"Access denied: {e}") except _LarkInvalidArgs as e: return DeliveryResult(success=False, error=f"Invalid args: {e}") except Exception as e: logger.error(f"Feishu send_reaction failed: {e}") return DeliveryResult(success=False, error=str(e)) def _make_create_msg_request( receive_id: str, chat_type: str, msg_type: str, content_json: str, *, silent: bool = False ) -> Any: receive_id_type = _resolve_receive_id_type(chat_type) body_builder = ( lark_oapi.api.im.v1.CreateMessageRequestBody.builder() .receive_id(receive_id) .msg_type(msg_type) .content(content_json) ) req_builder = ( lark_oapi.api.im.v1.CreateMessageRequest.builder() .receive_id_type(receive_id_type) .request_body(body_builder.build()) .silent(silent) ) return req_builder.build() def is_local_image_path(text: str) -> str | None: cleaned = text.strip() if os.path.isabs(cleaned) and os.path.splitext(cleaned)[1].lower() in IMAGE_EXTENSIONS: if os.path.isfile(cleaned): return cleaned return None async def update_card_message(client: Any, message_id: str, content: str) -> DeliveryResult: if not HAS_LARK_SDK: return DeliveryResult(success=False, error="lark-oapi SDK not available") try: content_json = json.dumps( { "config": {"wide_screen_mode": True}, "header": {"title": {"tag": "plain_text", "content": content[:100]}}, "elements": [{"tag": "markdown", "content": content}], }, ensure_ascii=False, ) request = ( lark_oapi.api.im.v1.PatchMessageRequest.builder() .message_id(message_id) .request_body(lark_oapi.api.im.v1.PatchMessageRequestBody.builder().content(content_json).build()) .build() ) resp = await client.im.v1.message.patch(request) if resp.success(): return DeliveryResult(success=True, message_id=message_id) getattr(resp, "code", -1) msg = getattr(resp, "msg", str(resp)) return DeliveryResult(success=False, error=f"Feishu API error: {msg}") except _LarkAccessDenied as e: return DeliveryResult(success=False, error=f"Access denied: {e}") except _LarkInvalidArgs as e: return DeliveryResult(success=False, error=f"Invalid args: {e}") except Exception as e: logger.error(f"Feishu update_card_message failed: {e}") return DeliveryResult(success=False, error=str(e)) async def forward_message( client: Any, message_id: str, target_chat_id: str, *, target_type: str = "chat", ) -> DeliveryResult: if not HAS_LARK_SDK: return DeliveryResult(success=False, error="lark-oapi SDK not available") try: request_body = lark_oapi.api.im.v1.ForwardMessageRequestBody.builder().receive_id(target_chat_id).build() request = ( lark_oapi.api.im.v1.ForwardMessageRequest.builder() .message_id(message_id) .receive_id_type(target_type) .request_body(request_body) .build() ) resp = await client.im.v1.message.forward(request) if resp.success(): new_msg_id = resp.data.get("message_id", "") if hasattr(resp, "data") else "" return DeliveryResult(success=True, message_id=new_msg_id) code = getattr(resp, "code", -1) msg = getattr(resp, "msg", str(resp)) if _is_token_expired_error(code, msg): return DeliveryResult(success=False, error=f"Auth failed: {msg}", error_code="auth_expired") return DeliveryResult(success=False, error=f"Feishu API error: {msg}") except _LarkAccessDenied as e: return DeliveryResult(success=False, error=f"Access denied: {e}") except _LarkInvalidArgs as e: return DeliveryResult(success=False, error=f"Invalid args: {e}") except Exception as e: logger.error(f"Feishu forward_message failed: {e}") return DeliveryResult(success=False, error=str(e)) async def get_message_read_status( client: Any, message_id: str, *, page_token: str = "", page_size: int = 20, ) -> dict[str, Any]: if not HAS_LARK_SDK: return {"users": [], "has_more": False, "page_token": ""} try: request_builder = ( lark_oapi.api.im.v1.ListReadUserRequest.builder().message_id(message_id).page_size(min(page_size, 100)) ) if page_token: request_builder.page_token(page_token) request = request_builder.build() resp = await client.im.v1.message.read_user.list(request) if not resp.success(): raise RuntimeError(f"获取已读状态失败: {resp.msg}") data = resp.data if hasattr(resp, "data") else {} items = data.get("items", []) users = [ { "user_id": item.get("user_id", ""), "timestamp": item.get("timestamp", ""), } for item in items ] return { "users": users, "has_more": data.get("has_more", False), "page_token": data.get("page_token", ""), } except _LarkAccessDenied as e: raise RuntimeError(f"权限不足: {e}") from e except _LarkInvalidArgs as e: raise RuntimeError(f"参数错误: {e}") from e except Exception as e: raise RuntimeError(f"获取已读状态失败: {e}") from e