219 lines
7.8 KiB
Python
219 lines
7.8 KiB
Python
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
|
||
|