ForcePilot/backend/package/yuxi/channel/extensions/imessage/media.py
Kris 4d52f634fe feat(imessage): 新增iMessage渠道插件完整实现
该提交新增了基于BlueBubbles的iMessage渠道插件,支持单聊和群组消息,包含文本、图片、语音、文件和视频消息收发,支持消息编辑、撤回、回复、 reactions和输入状态提示,同时实现了账号配置、安全校验、配对授权、消息格式化与分片等完整功能。
2026-05-21 10:50:15 +08:00

105 lines
2.8 KiB
Python

from __future__ import annotations
import logging
import os
import tempfile
from pathlib import Path
import httpx
from yuxi.channel.extensions.imessage.errors import IMessageMediaError
logger = logging.getLogger(__name__)
DEFAULT_MEDIA_MAX_MB = 16
async def download_media(
media_url: str,
max_mb: int = DEFAULT_MEDIA_MAX_MB,
temp_dir: str | None = None,
) -> tuple[bytes, str, str]:
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
resp = await client.get(media_url)
resp.raise_for_status()
data = resp.content
size_mb = len(data) / (1024 * 1024)
if size_mb > max_mb:
raise IMessageMediaError(
f"Media size {size_mb:.1f}MB exceeds limit of {max_mb}MB"
)
content_type = resp.headers.get("content-type", "application/octet-stream")
ext = _guess_extension(content_type)
filename = f"imessage_media_{os.urandom(8).hex()}{ext}"
write_dir = temp_dir or tempfile.gettempdir()
filepath = os.path.join(write_dir, filename)
Path(filepath).write_bytes(data)
return data, content_type, filepath
def cleanup_temp_file(filepath: str) -> None:
try:
os.unlink(filepath)
except OSError:
pass
async def download_attachment_via_api(
attachment_guid: str,
server_url: str,
password: str,
max_mb: int = DEFAULT_MEDIA_MAX_MB,
) -> tuple[bytes, str]:
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
resp = await client.get(
f"{server_url}/api/v1/attachment/{attachment_guid}/download",
headers={"Password": password},
)
resp.raise_for_status()
data = resp.content
size_mb = len(data) / (1024 * 1024)
if size_mb > max_mb:
raise IMessageMediaError(
f"Attachment size {size_mb:.1f}MB exceeds limit of {max_mb}MB"
)
content_type = resp.headers.get("content-type", "application/octet-stream")
return data, content_type
async def download_attachment_chunked(
attachment_guid: str,
server_url: str,
password: str,
chunk_size: int = 1024 * 1024,
) -> bytes:
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
resp = await client.get(
f"{server_url}/api/v1/attachment/{attachment_guid}/chunk",
headers={"Password": password},
)
resp.raise_for_status()
return resp.content
def _guess_extension(content_type: str) -> str:
ext_map = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/heic": ".heic",
"image/heif": ".heif",
"audio/mpeg": ".mp3",
"audio/aac": ".aac",
"audio/wav": ".wav",
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"application/pdf": ".pdf",
}
return ext_map.get(content_type, ".bin")