1. 移除多个文件中的空行、冗余导入
2. 修复文件末尾缺少换行符的问题
3. 新增并补全飞书多类工具API实现:
- 多维表格:更新、删除记录,列出视图
- 文档:更新、追加、删除块
- 云文档:上传、下载文件
- 群组:创建、添加成员、更新信息、创建公告
- 目录:重构用户部门缓存逻辑
4. 优化消息发送、回复、转发等API的错误处理和逻辑
5. 新增消息列表查询、已读状态查询等功能
252 lines
8.0 KiB
Python
252 lines
8.0 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, max_retries=3)
|
|
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", max_retries=2)
|
|
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, max_retries: int = 1
|
|
) -> Any:
|
|
import lark_oapi
|
|
|
|
for attempt in range(max_retries):
|
|
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 (attempt %d/%d)",
|
|
file_type,
|
|
file_key,
|
|
attempt + 1,
|
|
max_retries,
|
|
)
|
|
if attempt < max_retries - 1:
|
|
await asyncio.sleep(0.5 * (attempt + 1))
|
|
continue
|
|
elif http_status >= 500:
|
|
logger.warning(
|
|
"[FeishuMedia] %d error downloading type=%s for %s (attempt %d/%d)",
|
|
http_status,
|
|
file_type,
|
|
file_key,
|
|
attempt + 1,
|
|
max_retries,
|
|
)
|
|
if attempt < max_retries - 1:
|
|
await asyncio.sleep(1.0 * (attempt + 1))
|
|
continue
|
|
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(
|
|
"[FeishuMedia] Download error (type=%s, attempt %d/%d): %s",
|
|
file_type,
|
|
attempt + 1,
|
|
max_retries,
|
|
e,
|
|
)
|
|
if attempt < max_retries - 1:
|
|
await asyncio.sleep(0.5 * (attempt + 1))
|
|
|
|
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()
|