PMFlow/backend/app/services/feishu_integration_service/mixin_transport.py

219 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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