from __future__ import annotations import json import logging from typing import Any from yuxi.channel.extensions.feishu.client import get_client from yuxi.channel.extensions.feishu.config import FeishuConfigAdapter from yuxi.channel.extensions.feishu.format import text_to_post_message, chunk_post_content, markdown_to_feishu_post from yuxi.channel.extensions.feishu.media import upload_image as _upload_media_image, upload_file as _upload_media_file from yuxi.channel.extensions.feishu.types import FeishuAccount logger = logging.getLogger(__name__) REPLY_DEGRADE_ERRORS = frozenset({230011, 231003}) class FeishuOutboundAdapter: delivery_mode = "direct" def __init__(self): self._config_adapter = FeishuConfigAdapter() async def _resolve_account(self, account_id: str | None, config: dict | None = None) -> FeishuAccount: aid = account_id or self._config_adapter.default_account_id(config or {}) return await self._config_adapter.resolve_account(aid, config) async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) body = self._build_message_body(target_id, content, reply_to_id=reply_to_id) import lark_oapi req = lark_oapi.im.v1.CreateMessageRequest( receive_id_type="chat_id", request_body=lark_oapi.im.v1.CreateMessageRequestBody(**body), ) resp = await client.im.v1.message.create_async(req) return self._parse_response(resp) except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu send_text error") return {"success": False, "error": "Internal error"} async def send_card( self, target_id: str, card_json: dict, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) body: dict[str, Any] = { "receive_id": target_id, "msg_type": "interactive", "content": json.dumps(card_json, ensure_ascii=False), } if reply_to_id: body["root_id"] = reply_to_id import lark_oapi req = lark_oapi.im.v1.CreateMessageRequest( receive_id_type="chat_id", request_body=lark_oapi.im.v1.CreateMessageRequestBody(**body), ) resp = await client.im.v1.message.create_async(req) return self._parse_response(resp) except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu send_card error") return {"success": False, "error": "Internal error"} async def send_markdown_card( self, target_id: str, text: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: from yuxi.channel.extensions.feishu.card import build_markdown_card return await self.send_card(target_id, build_markdown_card(text), account_id=account_id, config=config) async def send_structured_card( self, target_id: str, text: str, *, title: str | None = None, template: str = "blue", account_id: str | None = None, config: dict | None = None, ) -> dict: from yuxi.channel.extensions.feishu.card import build_structured_card return await self.send_card( target_id, build_structured_card(text, title=title, template=template), account_id=account_id, config=config, ) async def reply_message( self, target_id: str, content: str, message_id: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) post_content = text_to_post_message(content) body: dict[str, Any] = { "content": post_content, "msg_type": "post", } import lark_oapi req = lark_oapi.im.v1.ReplyMessageRequest( message_id=message_id, request_body=lark_oapi.im.v1.ReplyMessageRequestBody(**body), ) resp = await client.im.v1.message.reply_async(req) if resp.success(): return {"success": True, "msg_id": getattr(resp.data, "message_id", "")} err_code = getattr(resp, "code", 0) err_msg = getattr(resp, "msg", "") if err_code in REPLY_DEGRADE_ERRORS: logger.debug("Reply target not found, degrading to direct send: code=%d", err_code) return await self.send_text( target_id, content, account_id=account_id, config=config, ) return {"success": False, "error": err_msg, "code": err_code} except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu reply_message error") return {"success": False, "error": "Internal error"} async def thread_reply( self, target_id: str, content: str, root_id: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: return await self.send_text( target_id, content, reply_to_id=root_id, account_id=account_id, config=config, ) async def delete_message( self, message_id: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) import lark_oapi req = lark_oapi.im.v1.DeleteMessageRequest(message_id=message_id) resp = await client.im.v1.message.delete_async(req) return self._parse_response(resp) except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu delete_message error") return {"success": False, "error": "Internal error"} async def forward_message( self, message_id: str, target_chat_id: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) import lark_oapi req = lark_oapi.im.v1.ForwardMessageRequest( message_id=message_id, request_body=lark_oapi.im.v1.ForwardMessageRequestBody( receive_id=target_chat_id, ), ) resp = await client.im.v1.message.forward_async(req) return self._parse_response(resp) except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu forward_message error") return {"success": False, "error": "Internal error"} async def merge_forward( self, target_id: str, message_ids: list[str], *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) import lark_oapi req = lark_oapi.im.v1.MergeForwardMessageRequest( receive_id_type="chat_id", request_body=lark_oapi.im.v1.MergeForwardMessageRequestBody( receive_id=target_id, message_id_list=message_ids, ), ) resp = await client.im.v1.message.merge_forward_async(req) return self._parse_response(resp) except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu merge_forward error") return {"success": False, "error": "Internal error"} async def edit_message( self, message_id: str, content: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) post_content = text_to_post_message(content) import lark_oapi req = lark_oapi.im.v1.PatchMessageRequest( message_id=message_id, request_body=lark_oapi.im.v1.PatchMessageRequestBody( content=post_content, ), ) resp = await client.im.v1.message.patch_async(req) return self._parse_response(resp) except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu edit_message error") return {"success": False, "error": "Internal error"} async def send_image( self, target_id: str, image_key: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) import lark_oapi req = lark_oapi.im.v1.CreateMessageRequest( receive_id_type="chat_id", request_body=lark_oapi.im.v1.CreateMessageRequestBody( receive_id=target_id, msg_type="image", content=json.dumps({"image_key": image_key}), ), ) resp = await client.im.v1.message.create_async(req) return self._parse_response(resp) except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu send_image error") return {"success": False, "error": "Internal error"} async def send_image_by_path( self, target_id: str, file_path: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} upload_result = await _upload_media_image( account.app_id, account.app_secret, account.domain, file_path, account.http_timeout_ms ) if not upload_result.get("success"): return upload_result image_key = upload_result.get("image_key", "") return await self.send_image(target_id, image_key, account_id=account_id, config=config) async def send_file( self, target_id: str, file_key: str, file_type: str = "stream", *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) import lark_oapi req = lark_oapi.im.v1.CreateMessageRequest( receive_id_type="chat_id", request_body=lark_oapi.im.v1.CreateMessageRequestBody( receive_id=target_id, msg_type="file", content=json.dumps({"file_key": file_key}), ), ) resp = await client.im.v1.message.create_async(req) return self._parse_response(resp) except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu send_file error") return {"success": False, "error": "Internal error"} async def send_file_by_path( self, target_id: str, file_path: str, *, file_type: str = "stream", filename: str = "", account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} upload_result = await _upload_media_file( account.app_id, account.app_secret, account.domain, file_path, file_type, filename, account.http_timeout_ms, ) if not upload_result.get("success"): return upload_result file_key = upload_result.get("file_key", "") return await self.send_file(target_id, file_key, file_type, account_id=account_id, config=config) async def send_audio_by_path( self, target_id: str, file_path: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} from yuxi.channel.extensions.feishu.media import detect_file_type file_type = detect_file_type(file_path) upload_result = await _upload_media_file( account.app_id, account.app_secret, account.domain, file_path, file_type, "", account.http_timeout_ms, ) if not upload_result.get("success"): return upload_result file_key = upload_result.get("file_key", "") return await self._send_media_by_key(target_id, file_key, "audio", account_id=account_id) async def send_video_by_path( self, target_id: str, file_path: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} from yuxi.channel.extensions.feishu.media import detect_file_type file_type = detect_file_type(file_path) upload_result = await _upload_media_file( account.app_id, account.app_secret, account.domain, file_path, file_type, "", account.http_timeout_ms, ) if not upload_result.get("success"): return upload_result file_key = upload_result.get("file_key", "") return await self._send_media_by_key(target_id, file_key, "media", account_id=account_id) async def _send_media_by_key( self, target_id: str, file_key: str, msg_type: str, *, account_id: str | None = None, ) -> dict: account = await self._resolve_account(account_id, None) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) if msg_type == "audio": content_json = json.dumps({"file_key": file_key}) elif msg_type == "media": content_json = json.dumps({"file_key": file_key, "image_key": ""}) else: return {"success": False, "error": f"Unsupported media type: {msg_type}"} import lark_oapi req = lark_oapi.im.v1.CreateMessageRequest( receive_id_type="chat_id", request_body=lark_oapi.im.v1.CreateMessageRequestBody( receive_id=target_id, msg_type=msg_type, content=content_json, ), ) resp = await client.im.v1.message.create_async(req) return self._parse_response(resp) except ImportError: return {"success": False, "error": "lark-oapi SDK not installed"} except Exception: logger.exception("Feishu _send_media_by_key error") return {"success": False, "error": "Internal error"} async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, ) -> None: account_id = None try: await self._resolve_account(account_id, None) except Exception: logger.warning("Feishu send_media - could not resolve account") return if media_type.startswith("image"): await self.send_image_by_path(target_id, media_url, account_id=account_id) elif media_type.startswith("audio") or media_type.startswith("voice"): await self.send_audio_by_path(target_id, media_url, account_id=account_id) elif media_type.startswith("video"): await self.send_video_by_path(target_id, media_url, account_id=account_id) def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: if limit >= 28000: post = markdown_to_feishu_post(text) elements = post.get("zh_cn", {}).get("content", [[]]) chunks = chunk_post_content(elements, limit=min(limit, 28000)) result = [] for chunk_elements in chunks: chunk_post = { "zh_cn": { "title": "", "content": chunk_elements, } } result.append(json.dumps(chunk_post, ensure_ascii=False)) return result if len(text) <= limit: return [text] chunks = [] remaining = text while len(remaining) > limit: split_at = remaining.rfind("\n", 0, limit) if split_at == -1 or split_at < limit // 2: split_at = limit chunks.append(remaining[:split_at]) remaining = remaining[split_at:].lstrip("\n") if remaining: chunks.append(remaining) return chunks async def edit_card( self, target_id: str, message_id: str, card_content: dict, *, account_id: str | None = None, config: dict | None = None, ) -> str | None: account = await self._resolve_account(account_id, config) if not account.is_configured(): return None try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) import lark_oapi req = lark_oapi.im.v1.PatchMessageRequest( message_id=message_id, request_body=lark_oapi.im.v1.PatchMessageRequestBody( content=json.dumps(card_content, ensure_ascii=False), ), ) resp = await client.im.v1.message.patch_async(req) if resp.success(): return message_id logger.warning("Feishu edit_card failed: code=%s, msg=%s", resp.code, resp.msg) return None except ImportError: return None except Exception: logger.exception("Feishu edit_card error") return None async def get_message( self, message_id: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict | None: account = await self._resolve_account(account_id, config) if not account.is_configured(): return None try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import get_message as _get_msg return await _get_msg(client, message_id) except Exception: logger.exception("Feishu get_message error") return None async def list_chat_messages( self, chat_id: str, *, page_token: str = "", page_size: int = 20, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"messages": [], "has_more": False, "page_token": ""} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import list_messages as _list_msg return await _list_msg(client, container_id=chat_id, page_token=page_token, page_size=page_size) except Exception: logger.exception("Feishu list_chat_messages error") return {"messages": [], "has_more": False, "page_token": ""} async def create_group_chat( self, name: str, user_ids: list[str] | None = None, description: str = "", *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import create_chat return await create_chat(client, name, description, user_ids or [], "group") except Exception: logger.exception("Feishu create_group_chat error") return {"success": False, "error": "Internal error"} async def update_group_chat( self, chat_id: str, name: str = "", description: str = "", *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import update_chat return await update_chat(client, chat_id, name, description) except Exception: logger.exception("Feishu update_group_chat error") return {"success": False, "error": "Internal error"} async def delete_group_chat( self, chat_id: str, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import delete_chat return await delete_chat(client, chat_id) except Exception: logger.exception("Feishu delete_group_chat error") return {"success": False, "error": "Internal error"} async def add_group_members( self, chat_id: str, member_ids: list[str], *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import add_chat_members return await add_chat_members(client, chat_id, member_ids) except Exception: logger.exception("Feishu add_group_members error") return {"success": False, "error": "Internal error"} async def remove_group_members( self, chat_id: str, member_ids: list[str], *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"success": False, "error": "Account not configured"} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import remove_chat_members return await remove_chat_members(client, chat_id, member_ids) except Exception: logger.exception("Feishu remove_group_members error") return {"success": False, "error": "Internal error"} async def list_user_chats( self, user_id: str = "", page_token: str = "", page_size: int = 50, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"chats": [], "has_more": False, "page_token": ""} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import list_user_chats as _list_user_chats return await _list_user_chats(client, user_id, page_token, page_size) except Exception: logger.exception("Feishu list_user_chats error") return {"chats": [], "has_more": False, "page_token": ""} async def search_chats( self, query: str = "", page_token: str = "", page_size: int = 50, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"chats": [], "has_more": False, "page_token": ""} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import search_chats as _search_chats return await _search_chats(client, query, page_token, page_size) except Exception: logger.exception("Feishu search_chats error") return {"chats": [], "has_more": False, "page_token": ""} async def list_departments( self, parent_department_id: str = "0", page_token: str = "", page_size: int = 50, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"departments": [], "has_more": False, "page_token": ""} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import list_departments as _list_dept return await _list_dept(client, parent_department_id, page_token, page_size) except Exception: logger.exception("Feishu list_departments error") return {"departments": [], "has_more": False, "page_token": ""} async def find_users_by_department( self, department_id: str, page_token: str = "", page_size: int = 50, *, account_id: str | None = None, config: dict | None = None, ) -> dict: account = await self._resolve_account(account_id, config) if not account.is_configured(): return {"users": [], "has_more": False, "page_token": ""} try: client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms) from yuxi.channel.extensions.feishu.client import find_users_by_department as _find_dept_users return await _find_dept_users(client, department_id, page_token, page_size) except Exception: logger.exception("Feishu find_users_by_department error") return {"users": [], "has_more": False, "page_token": ""} def _build_message_body( self, target_id: str, content: str, *, reply_to_id: str | None = None, ) -> dict[str, Any]: post_content = text_to_post_message(content) body: dict[str, Any] = { "receive_id": target_id, "msg_type": "post", "content": post_content, } if reply_to_id: body["root_id"] = reply_to_id return body def _parse_response(self, resp) -> dict: if resp.success(): msg_id = getattr(resp.data, "message_id", "") return {"success": True, "msg_id": msg_id} err_code = getattr(resp, "code", 0) err_msg = getattr(resp, "msg", "") if err_code in REPLY_DEGRADE_ERRORS: logger.debug("Reply target not found, code=%d", err_code) return {"success": False, "error": err_msg, "code": err_code}