新增 Twitter 和 Viber 两个渠道扩展。 Twitter 渠道扩展功能模块: - auth: OAuth 认证管理 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - tweets: 推文管理 - social: 社交互动 - reactions: 表情反应 - media: 媒体资源处理 Viber 渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - rate_limiter: 速率限制 - media: 媒体资源处理
242 lines
7.8 KiB
Python
242 lines
7.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
from requests_oauthlib import OAuth1Session
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MEDIA_CATEGORY_SIZES = {
|
|
"dm_image": 5 * 1024 * 1024,
|
|
"dm_video": 512 * 1024 * 1024,
|
|
"dm_gif": 15 * 1024 * 1024,
|
|
}
|
|
|
|
MEDIA_CATEGORY_TYPES = {
|
|
"dm_image": "image",
|
|
"dm_video": "video",
|
|
"dm_gif": "image",
|
|
}
|
|
|
|
CHUNK_SIZE = 5 * 1024 * 1024
|
|
|
|
X_UPLOAD_BASE = "https://upload.twitter.com"
|
|
|
|
|
|
class TwitterMedia:
|
|
@staticmethod
|
|
def _build_oauth(account: dict) -> OAuth1Session:
|
|
return OAuth1Session(
|
|
account["api_key"],
|
|
client_secret=account["api_secret"],
|
|
resource_owner_key=account["access_token"],
|
|
resource_owner_secret=account["access_secret"],
|
|
)
|
|
|
|
@classmethod
|
|
async def upload(
|
|
cls, account: dict, media_url_or_path: str, media_category: str
|
|
) -> str | None:
|
|
max_size = MEDIA_CATEGORY_SIZES.get(media_category, 5 * 1024 * 1024)
|
|
media_type = MEDIA_CATEGORY_TYPES.get(media_category, "image")
|
|
|
|
file_path = await cls._resolve_file(media_url_or_path)
|
|
if not file_path:
|
|
logger.error(
|
|
"Twitter media: cannot resolve file from %s", media_url_or_path
|
|
)
|
|
return None
|
|
|
|
file_size = os.path.getsize(file_path)
|
|
if file_size > max_size:
|
|
logger.error(
|
|
"Twitter media: file too large (%d bytes, max %d for %s)",
|
|
file_size,
|
|
max_size,
|
|
media_category,
|
|
)
|
|
return None
|
|
|
|
oauth = cls._build_oauth(account)
|
|
|
|
init_result = await asyncio.to_thread(
|
|
cls._init_upload,
|
|
oauth,
|
|
file_size,
|
|
media_category,
|
|
media_type,
|
|
)
|
|
if not init_result:
|
|
return None
|
|
|
|
media_id = init_result["media_id_string"]
|
|
|
|
with open(file_path, "rb") as f:
|
|
segment_index = 0
|
|
while True:
|
|
chunk = f.read(CHUNK_SIZE)
|
|
if not chunk:
|
|
break
|
|
success = await asyncio.to_thread(
|
|
cls._append_chunk,
|
|
oauth,
|
|
media_id,
|
|
segment_index,
|
|
chunk,
|
|
)
|
|
if not success:
|
|
logger.error(
|
|
"Twitter media: APPEND failed at segment %d", segment_index
|
|
)
|
|
return None
|
|
segment_index += 1
|
|
|
|
finalize_result = await asyncio.to_thread(
|
|
cls._finalize_upload,
|
|
oauth,
|
|
media_id,
|
|
)
|
|
if not finalize_result:
|
|
return None
|
|
|
|
if media_category == "dm_video":
|
|
media_id = await asyncio.to_thread(
|
|
cls._wait_for_video_processing,
|
|
oauth,
|
|
media_id,
|
|
)
|
|
|
|
logger.info(
|
|
"Twitter media: uploaded media_id=%s, size=%d bytes", media_id, file_size
|
|
)
|
|
return media_id
|
|
|
|
@staticmethod
|
|
def _init_upload(
|
|
oauth: OAuth1Session, total_bytes: int, media_category: str, media_type: str
|
|
) -> dict | None:
|
|
url = f"{X_UPLOAD_BASE}/1.1/media/upload.json"
|
|
params = {
|
|
"command": "INIT",
|
|
"total_bytes": total_bytes,
|
|
"media_type": media_type,
|
|
"media_category": media_category,
|
|
}
|
|
try:
|
|
resp = oauth.post(url, data=params, timeout=30)
|
|
if resp.status_code in (200, 202):
|
|
return resp.json()
|
|
logger.error(
|
|
"Twitter media INIT failed: HTTP %d %s",
|
|
resp.status_code,
|
|
resp.text[:200],
|
|
)
|
|
except Exception as e:
|
|
logger.error("Twitter media INIT error: %s", e)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _append_chunk(
|
|
oauth: OAuth1Session, media_id: str, segment_index: int, chunk: bytes
|
|
) -> bool:
|
|
url = f"{X_UPLOAD_BASE}/1.1/media/upload.json"
|
|
params = {
|
|
"command": "APPEND",
|
|
"media_id": media_id,
|
|
"segment_index": segment_index,
|
|
}
|
|
try:
|
|
resp = oauth.post(url, data=params, files={"media": chunk}, timeout=60)
|
|
return resp.status_code in (200, 204)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Twitter media APPEND error (segment %d): %s", segment_index, e
|
|
)
|
|
return False
|
|
|
|
@staticmethod
|
|
def _finalize_upload(oauth: OAuth1Session, media_id: str) -> dict | None:
|
|
url = f"{X_UPLOAD_BASE}/1.1/media/upload.json"
|
|
params = {
|
|
"command": "FINALIZE",
|
|
"media_id": media_id,
|
|
}
|
|
try:
|
|
resp = oauth.post(url, data=params, timeout=30)
|
|
if resp.status_code in (200, 202):
|
|
return resp.json()
|
|
logger.error(
|
|
"Twitter media FINALIZE failed: HTTP %d %s",
|
|
resp.status_code,
|
|
resp.text[:200],
|
|
)
|
|
except Exception as e:
|
|
logger.error("Twitter media FINALIZE error: %s", e)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _wait_for_video_processing(
|
|
oauth: OAuth1Session, media_id: str, max_wait_sec: int = 300
|
|
) -> str | None:
|
|
url = f"{X_UPLOAD_BASE}/1.1/media/upload.json"
|
|
params = {"command": "STATUS", "media_id": media_id}
|
|
start = time.monotonic()
|
|
while time.monotonic() - start < max_wait_sec:
|
|
try:
|
|
resp = oauth.get(url, params=params, timeout=15)
|
|
if resp.status_code != 200:
|
|
break
|
|
data = resp.json()
|
|
processing_info = data.get("processing_info", {})
|
|
state = processing_info.get("state", "succeeded")
|
|
if state == "succeeded":
|
|
return media_id
|
|
if state == "failed":
|
|
logger.error(
|
|
"Twitter media: video processing failed for %s", media_id
|
|
)
|
|
return None
|
|
check_after = processing_info.get("check_after_secs", 5)
|
|
time.sleep(check_after)
|
|
except Exception as e:
|
|
logger.error("Twitter media STATUS error: %s", e)
|
|
return None
|
|
logger.error("Twitter media: video processing timeout for %s", media_id)
|
|
return None
|
|
|
|
@staticmethod
|
|
async def _resolve_file(media_url_or_path: str) -> str | None:
|
|
if media_url_or_path.startswith(("http://", "https://")):
|
|
try:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
resp = await client.get(media_url_or_path)
|
|
if resp.status_code == 200:
|
|
import tempfile
|
|
|
|
suffix = ".jpg"
|
|
content_type = resp.headers.get("content-type", "")
|
|
if "png" in content_type:
|
|
suffix = ".png"
|
|
elif "gif" in content_type:
|
|
suffix = ".gif"
|
|
elif "mp4" in content_type:
|
|
suffix = ".mp4"
|
|
elif "webp" in content_type:
|
|
suffix = ".webp"
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
|
tmp.write(resp.content)
|
|
tmp.close()
|
|
return tmp.name
|
|
except Exception as e:
|
|
logger.error("Twitter media download error: %s", e)
|
|
return None
|
|
path = Path(media_url_or_path)
|
|
if path.is_file():
|
|
return str(path)
|
|
return None
|