ForcePilot/backend/package/yuxi/channels/adapters/feishu/media.py
Kris a6fa7245e5 feat(feishu): 完整实现飞书适配器核心模块
新增飞书机器人适配器全套功能,包括:
- 基础适配器入口与工具导出
- 消息格式化、卡片渲染、回复调度逻辑
- 会话ID生成、模型覆盖策略
- 消息发送缓存、顺序队列管理
- 飞书签名验证、加解密webhook请求
- 审批权限校验、机器人菜单事件处理
- 文档评论、钉消息、语音转码处理
- 静态/动态目录管理、子代理生命周期管理
- 各类工具集:聊天、云盘、文档、知识库API封装
2026-05-12 00:43:59 +08:00

216 lines
6.8 KiB
Python

from __future__ import annotations
import asyncio
import io
import logging
import os
import re
from typing import Any
from yuxi.channels.exceptions import DeliveryFailedError
logger = logging.getLogger(__name__)
DEFAULT_MAX_MEDIA_MB = 50
IMAGE_MAX_MB = 10
FILE_MAX_MB = 50
DEFAULT_MEDIA_TIMEOUT_S = float(os.environ.get("FEISHU_MEDIA_HTTP_TIMEOUT_MS", "120000")) / 1000.0
MIME_TO_MEDIA_KIND = {
"image/png": "img",
"image/jpeg": "img",
"image/gif": "img",
"image/webp": "img",
"image/bmp": "img",
"audio/ogg": "opus",
"audio/opus": "opus",
"audio/mpeg": "stream",
"audio/mp3": "stream",
"audio/wav": "stream",
"audio/mp4": "stream",
"video/mp4": "mp4",
"video/quicktime": "mp4",
}
EXT_TO_MEDIA_KIND = {
".png": "img",
".jpg": "img",
".jpeg": "img",
".gif": "img",
".webp": "img",
".bmp": "img",
".ogg": "opus",
".opus": "opus",
".mp3": "stream",
".wav": "stream",
".m4a": "stream",
".mp4": "mp4",
".mov": "mp4",
".webm": "mp4",
}
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
_UNSAFE_FILENAME_CHARS_RE = re.compile(r'["\\]')
_LATIN1_MOJIBAKE_RE = re.compile(rb"[\xc3\xc5][\x80-\xbf]{2,}")
class MediaSizeError(DeliveryFailedError):
def __init__(self, size_mb: float, max_mb: float):
super().__init__(f"Media size {size_mb:.1f}MB exceeds limit {max_mb:.0f}MB")
self.size_mb = size_mb
self.max_mb = max_mb
def validate_media_size(data: bytes, max_mb: float = DEFAULT_MAX_MEDIA_MB, label: str = "file") -> None:
size_mb = len(data) / (1024 * 1024)
if size_mb > max_mb:
raise MediaSizeError(size_mb, max_mb)
def validate_image_size(data: bytes) -> None:
validate_media_size(data, IMAGE_MAX_MB, "image")
def validate_file_size(data: bytes) -> None:
validate_media_size(data, FILE_MAX_MB, "file")
def resolve_feishu_outbound_media_kind(
filename: str = "",
mime_type: str = "",
) -> str:
ext = os.path.splitext(filename)[1].lower()
if ext and ext in EXT_TO_MEDIA_KIND:
return EXT_TO_MEDIA_KIND[ext]
if mime_type and mime_type in MIME_TO_MEDIA_KIND:
return MIME_TO_MEDIA_KIND[mime_type]
if ext in (".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".csv"):
return "stream"
return "stream"
def sanitize_filename_for_upload(filename: str) -> str:
name, ext = os.path.splitext(filename)
name = _CONTROL_CHARS_RE.sub("", name)
name = _UNSAFE_FILENAME_CHARS_RE.sub("_", name)
if not name.strip():
name = "file"
ext = _CONTROL_CHARS_RE.sub("", ext)
return f"{name}{ext}"
def recover_utf8_filename_from_latin1_header(filename: str) -> str:
try:
raw = filename.encode("latin-1")
if _LATIN1_MOJIBAKE_RE.search(raw):
decoded = raw.decode("utf-8", errors="replace")
if decoded != filename:
logger.debug("[FeishuMedia] Recovered UTF-8 filename from Latin-1 header: %s -> %s", filename, decoded)
return decoded
except (UnicodeEncodeError, UnicodeDecodeError):
pass
return filename
async def upload_image(client: Any, image_data: bytes) -> str:
validate_image_size(image_data)
token = await _get_tenant_token(client)
url = f"https://{client.domain}/open-apis/im/v1/images"
resp = await _do_upload(url, token, "image", image_data, "image.png")
image_key = resp.get("data", {}).get("image_key", "")
if not image_key:
raise DeliveryFailedError("Image upload: no image_key in response")
logger.debug(f"[Feishu] Image uploaded, image_key={image_key}")
return image_key
async def upload_file(client: Any, file_data: bytes, filename: str, file_type: str = "stream") -> str:
validate_file_size(file_data)
valid_types = {"opus", "mp4", "pdf", "doc", "xls", "ppt", "stream"}
if file_type not in valid_types:
file_type = "stream"
filename = sanitize_filename_for_upload(filename)
token = await _get_tenant_token(client)
url = f"https://{client.domain}/open-apis/im/v1/files"
resp = await _do_upload(url, token, "file", file_data, filename, file_type=file_type)
file_key = resp.get("data", {}).get("file_key", "")
if not file_key:
raise DeliveryFailedError("File upload: no file_key in response")
logger.debug(f"[Feishu] File uploaded, file_key={file_key}")
return file_key
async def download_media(client: Any, message_id: str, file_key: str, file_type: str) -> bytes:
resp = await _download_with_fallback(client, message_id, file_key, file_type)
if resp is not None and resp.success():
return resp.file.read()
if file_type == "file":
logger.info("[FeishuMedia] File download failed, trying media type fallback for %s", file_key)
resp = await _download_with_fallback(client, message_id, file_key, "media")
if resp is not None and resp.success():
return resp.file.read()
raise DeliveryFailedError(f"Media download failed: file_key={file_key}")
async def _download_with_fallback(client: Any, message_id: str, file_key: str, file_type: str) -> Any:
import lark_oapi
try:
request = (
lark_oapi.api.im.v1.GetMessageResourceRequest.builder()
.message_id(message_id)
.file_key(file_key)
.type(file_type)
.build()
)
resp = client.im.v1.message_resource.get(request)
http_status = getattr(resp, "http_status", 0) or getattr(resp, "status_code", 0)
if resp.success():
return resp
if http_status == 502:
logger.warning("[FeishuMedia] 502 error downloading type=%s for %s", file_type, file_key)
return None
except Exception as e:
logger.warning("[FeishuMedia] Download error (type=%s): %s", file_type, e)
return None
async def _get_tenant_token(client: Any) -> str:
resp = await asyncio.to_thread(client.auth.tenant_access_token_internal)
if not resp.success():
raise RuntimeError(f"Failed to get tenant token: {resp.msg}")
return resp.token
async def _do_upload(
url: str,
token: str,
field_name: str,
file_data: bytes,
filename: str,
*,
timeout: float = DEFAULT_MEDIA_TIMEOUT_S,
**extra_fields: str,
) -> dict[str, Any]:
import httpx
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as http_client:
files = {field_name: (filename, io.BytesIO(file_data), "application/octet-stream")}
data: dict[str, str] = {"image_type": "message"} if field_name == "image" else {}
data.update(extra_fields)
resp = await http_client.post(url, headers=headers, files=files, data=data)
if resp.status_code != 200:
raise RuntimeError(f"Upload failed: HTTP {resp.status_code}, {resp.text[:300]}")
return resp.json()