from __future__ import annotations from .shared import * class FeishuTransportMixin: async def send_message_to_group( self, chat_id: str, message: str ) -> Dict[str, Any]: """ 发送消息到飞书群聊 用于推送通知、简报等 """ if not self.is_configured(): raise ValueError("飞书未配置") # 获取 tenant_access_token access_token = await self._get_tenant_access_token() # 发送消息 API url = "https://open.feishu.cn/open-apis/im/v1/messages" headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } message_data = { 'receive_id': chat_id, 'msg_type': 'text', 'content': json.dumps({'text': message}) } async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: resp = await client.post( url, headers=headers, params={'receive_id_type': 'chat_id'}, json=message_data, ) payload: Dict[str, Any] try: payload = resp.json() except Exception: payload = {"http_status": resp.status_code, "raw": resp.text[:500]} if resp.status_code >= 400: raise ValueError( f"飞书发送失败(http={resp.status_code}): {payload}" ) if int(payload.get("code", -1)) != 0: raise ValueError( f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" ) return payload async def _get_tenant_access_token(self) -> str: """获取 tenant_access_token""" if not self.app_id or not self.app_secret: raise ValueError("飞书未配置") url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" headers = {'Content-Type': 'application/json'} data = { 'app_id': self.app_id, 'app_secret': self.app_secret } async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: try: resp = await client.post(url, headers=headers, json=data) except httpx.RequestError as exc: raise ValueError(f"访问飞书 auth 接口失败: {exc}") from exc try: result = resp.json() except Exception: result = {"http_status": resp.status_code, "raw": resp.text[:500]} if resp.status_code >= 400: raise ValueError( f"获取 tenant_access_token 失败(http={resp.status_code}): {result}" ) if result.get('code') == 0 and result.get('tenant_access_token'): return result.get('tenant_access_token') raise ValueError( "获取 tenant_access_token 失败: " f"code={result.get('code')} msg={result.get('msg') or result.get('message')} payload={result}" ) async def download_attachment_bytes(self, attachment: Dict[str, Any]) -> Dict[str, Any]: """通过飞书开放平台代理下载附件。""" if not self.is_configured(): raise ValueError("飞书未配置") access_token = await self._get_tenant_access_token() attachment_type = str(attachment.get("attachment_type") or "file").strip().lower() file_name = str(attachment.get("file_name") or "").strip() or None media_type = str(attachment.get("mime_type") or "").strip() or None image_key = str(attachment.get("image_key") or "").strip() file_key = str(attachment.get("file_key") or "").strip() media_id = str(attachment.get("media_id") or "").strip() download_url = str(attachment.get("download_url") or "").strip() url = "" if download_url: url = download_url elif attachment_type == "image" and image_key: url = f"https://open.feishu.cn/open-apis/im/v1/images/{image_key}" elif file_key: url = f"https://open.feishu.cn/open-apis/im/v1/files/{file_key}" elif media_id: url = f"https://open.feishu.cn/open-apis/im/v1/files/{media_id}" if not url: raise ValueError("附件缺少可下载 key,无法代理下载") headers = {"Authorization": f"Bearer {access_token}"} async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: try: resp = await client.get(url, headers=headers, follow_redirects=True) except httpx.RequestError as exc: raise ValueError(f"飞书附件下载请求失败: {exc}") from exc if resp.status_code >= 400: body_preview = resp.text[:300] if resp.text else "" raise ValueError( f"飞书附件下载失败(http={resp.status_code}): {body_preview}" ) content = resp.content or b"" if not content: raise ValueError("飞书附件下载为空内容") response_content_type = str(resp.headers.get("content-type") or "").strip() content_type = media_type or response_content_type or "application/octet-stream" return { "content": content, "content_type": content_type, "file_name": file_name, } async def send_rich_card( self, chat_id: str, title: str, content: List[Dict[str, Any]] ) -> Dict[str, Any]: """ 发送富文本卡片消息 用于发送格式化的简报、通知等 """ if not self.is_configured(): raise ValueError("飞书未配置") access_token = await self._get_tenant_access_token() url = "https://open.feishu.cn/open-apis/im/v1/messages" headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # 构造卡片内容 card_content = { 'config': {'wide_screen_mode': True}, 'header': { 'title': { 'content': title, 'tag': 'plain_text' } }, 'elements': [] } for item in content: if item.get('type') == 'text': card_content['elements'].append({ 'tag': 'div', 'text': { 'content': item.get('text', ''), 'tag': 'lark_md' } }) elif item.get('type') == 'divider': card_content['elements'].append({'tag': 'hr'}) message_data = { 'receive_id': chat_id, 'msg_type': 'interactive', 'content': json.dumps(card_content) } async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: resp = await client.post( url, headers=headers, params={'receive_id_type': 'chat_id'}, json=message_data, ) payload: Dict[str, Any] try: payload = resp.json() except Exception: payload = {"http_status": resp.status_code, "raw": resp.text[:500]} if resp.status_code >= 400: raise ValueError( f"飞书发送失败(http={resp.status_code}): {payload}" ) if int(payload.get("code", -1)) != 0: raise ValueError( f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" ) return payload