新增 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: 类型定义
99 lines
2.8 KiB
Python
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") |