ForcePilot/backend/package/yuxi/channel/extensions/tlon/media.py
Kris 0babb1ca8e feat(channel): 添加 Tlon 渠道扩展
新增 Tlon 渠道扩展,支持在 Yuxi 平台中集成 Tlon/Urbit 去中心化通讯平台。

包含以下功能模块:
- tlon_api: Tlon API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- sse_client: SSE 客户端
- outbound: 外发消息管理
- send: 消息发送
- security: 安全校验
- auth: 认证管理
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- approval: 审批流程
- channel_mgmt: 频道管理
- channel_ops: 频道操作
- contacts: 联系人管理
- discovery: 服务发现
- doctor: 健康诊断
- expose: 服务暴露
- gallery: 图库管理
- history: 历史记录
- hooks: 钩子管理
- media: 媒体资源处理
- notebook: 笔记本功能
- settings_store: 设置存储
- setup: 初始化设置
- story: 故事功能
- targets: 目标管理
- cite_parser: 引用解析
- utils: 工具函数
- types: 类型定义
2026-05-21 11:55:25 +08:00

99 lines
2.8 KiB
Python

import asyncio
import logging
from pathlib import Path
import httpx
from yuxi.channel.extensions.tlon.story import extract_image_blocks
logger = logging.getLogger(__name__)
MAX_IMAGES_PER_MESSAGE = 8
MAX_IMAGE_BYTES = 5 * 1024 * 1024
MEDIA_DOWNLOAD_TIMEOUT = 30.0
async def download_message_images(content: list[dict],
media_dir: str | None = None) -> list[dict]:
images = extract_image_blocks(content)[:MAX_IMAGES_PER_MESSAGE]
attachments = []
for img in images:
url = img.get("src", "")
if not url:
continue
try:
downloaded = await download_media(url, media_dir)
attachments.append(downloaded)
except Exception:
pass
return attachments
async def download_media(url: str, media_dir: str | None = None) -> dict:
_assert_safe_url(url)
async with httpx.AsyncClient(
timeout=MEDIA_DOWNLOAD_TIMEOUT,
follow_redirects=True,
) as client:
response = await client.get(url)
response.raise_for_status()
content = response.content
if len(content) > MAX_IMAGE_BYTES:
raise ValueError(f"Media too large: {len(content)} bytes")
content_type = response.headers.get("content-type", "application/octet-stream")
ext = _guess_extension(content_type)
if media_dir:
dest_dir = Path(media_dir)
else:
dest_dir = Path(__file__).parent / "cache"
dest_dir.mkdir(parents=True, exist_ok=True)
import uuid
filename = f"{uuid.uuid4().hex}{ext}"
filepath = dest_dir / filename
filepath.write_bytes(content)
return {
"path": str(filepath),
"content_type": content_type,
"filename": filename,
"size": len(content),
}
def _assert_safe_url(url: str) -> None:
from ipaddress import ip_address
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsafe URL scheme: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("Missing hostname in URL")
try:
ip = ip_address(hostname)
except ValueError:
if hostname in ("localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"):
raise ValueError(f"Unsafe hostname: {hostname}")
return
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified:
raise ValueError(f"Blocked private/internal IP: {hostname}")
def _guess_extension(content_type: str) -> str:
mapping = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
return mapping.get(content_type.split(";")[0].strip(), ".bin")