ForcePilot/backend/package/yuxi/channel/extensions/twitter/outbound.py
Kris 1c590097be feat(channel): 添加 Twitter 和 Viber 渠道扩展
新增 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: 媒体资源处理
2026-05-21 11:57:22 +08:00

315 lines
10 KiB
Python

from __future__ import annotations
import asyncio
import logging
import tweepy
from requests_oauthlib import OAuth1Session
from yuxi.channel.extensions.twitter.auth import create_tweepy_client
from yuxi.channel.extensions.twitter.errors import MAX_RETRIES
from yuxi.channel.extensions.twitter.format import (
markdown_to_plain_text,
split_text_chunks,
)
logger = logging.getLogger(__name__)
class TwitterOutbound:
delivery_mode = "direct"
chunker_mode = "length"
text_chunk_limit: int = 10000
supports_polls = False
extract_markdown_images = False
presentation_capabilities = None
delivery_capabilities = None
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> dict | None:
account = await self._resolve_account(account_id)
if not account or not account.get("is_configured"):
logger.error("Twitter send_text: account not configured")
return None
plain = markdown_to_plain_text(content)
chunks = split_text_chunks(plain, self.text_chunk_limit)
result = None
for chunk in chunks:
result = await self._send_dm(
account, target_id, chunk, reply_to_id=reply_to_id
)
return result
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> dict | None:
account = await self._resolve_account(account_id)
if not account or not account.get("is_configured"):
return None
from yuxi.channel.extensions.twitter.media import TwitterMedia
media_category = {
"image": "dm_image",
"video": "dm_video",
"gif": "dm_gif",
}.get(media_type, "dm_image")
media_id = await TwitterMedia.upload(account, media_url, media_category)
if not media_id:
return None
return await self._send_dm(
account,
target_id,
"",
media_id=media_id,
reply_to_id=reply_to_id,
)
async def send_reaction(
self,
target_id: str,
message_id: str,
emoji: str,
account_id: str | None = None,
) -> dict | None:
account = await self._resolve_account(account_id)
if not account or not account.get("is_configured"):
return None
from yuxi.channel.extensions.twitter.reactions import normalize_emoji
emoji = normalize_emoji(emoji)
if emoji is None:
logger.warning("Twitter send_reaction: unsupported emoji, skipping")
return None
try:
client = create_tweepy_client(account)
response = await asyncio.to_thread(
client.create_dm_event_reaction,
dm_conversation_id=target_id,
dm_event_id=message_id,
emoji=emoji,
)
return response
except Exception as e:
logger.warning("Twitter send_reaction error: %s", e)
return None
async def _send_dm(
self,
account: dict,
target_id: str,
text: str,
*,
media_id: str | None = None,
reply_to_id: str | None = None,
) -> dict | None:
client = create_tweepy_client(account)
for attempt in range(MAX_RETRIES):
try:
payload: dict = {"text": text[:10000]}
if media_id:
payload["attachments"] = [{"media_id": media_id}]
if reply_to_id:
payload["reply_to"] = {"dm_event_id": reply_to_id}
extra = {}
if "attachments" in payload:
extra["attachments"] = payload["attachments"]
if "reply_to" in payload:
extra["reply_to"] = payload["reply_to"]
response = await asyncio.to_thread(
client.create_direct_message,
participant_id=int(target_id),
text=payload["text"],
**extra,
)
data = response.get("data", {})
return {
"msg_id": str(data.get("dm_event_id", "")),
"conversation_id": str(data.get("dm_conversation_id", "")),
"success": True,
}
except tweepy.TooManyRequests:
await asyncio.sleep(min(2**attempt * 60, 300))
continue
except tweepy.BadRequest as e:
logger.warning("Twitter DM send bad request: %s", e)
return {"msg_id": None, "success": False, "error": str(e)}
except tweepy.Unauthorized:
logger.error("Twitter DM send: OAuth credentials invalid")
return {"msg_id": None, "success": False, "error": "unauthorized"}
except Exception as e:
logger.warning("Twitter DM send error (attempt %d): %s", attempt + 1, e)
if attempt < MAX_RETRIES - 1:
await asyncio.sleep(min(2**attempt, 10))
else:
return {"msg_id": None, "success": False, "error": str(e)}
return None
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
return split_text_chunks(
markdown_to_plain_text(text), limit or self.text_chunk_limit
)
def sanitize_text(self, text: str, payload: object) -> str:
return markdown_to_plain_text(text)[: self.text_chunk_limit]
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
return False
def resolve_target(
self,
to: str | None = None,
*,
config: dict | None = None,
allow_from: list[str] | None = None,
account_id: str | None = None,
mode: str | None = None,
) -> tuple[bool, str]:
if not to:
return False, "target required"
return True, to
async def _resolve_account(self, account_id: str | None) -> dict | None:
from yuxi.channel.extensions.twitter.config import TwitterConfigAdapter
adapter = TwitterConfigAdapter()
aid = account_id or "default"
account = await adapter.resolve_account(aid)
account["is_configured"] = adapter.is_configured(account)
return account
async def create_dm_conversation(
self,
participant_ids: list[str],
*,
conversation_name: str | None = None,
account_id: str | None = None,
) -> dict | None:
account = await self._resolve_account(account_id)
if not account or not account.get("is_configured"):
return None
client = create_tweepy_client(account)
try:
payload: dict = {"participant_ids": [int(pid) for pid in participant_ids]}
if conversation_name:
payload["conversation_name"] = conversation_name
response = await asyncio.to_thread(
client.create_dm_conversation,
**payload,
)
return response.get("data", {})
except Exception as e:
logger.warning("Twitter create_dm_conversation error: %s", e)
return None
async def add_dm_participants(
self,
conversation_id: str,
participant_ids: list[str],
*,
account_id: str | None = None,
) -> bool:
account = await self._resolve_account(account_id)
if not account or not account.get("is_configured"):
return False
oauth = self._build_oauth_session(account)
url = f"https://api.x.com/2/dm_conversations/{conversation_id}/participants"
try:
body = {"participant_ids": [int(pid) for pid in participant_ids]}
resp = await asyncio.to_thread(oauth.post, url, json=body, timeout=15)
return resp.status_code in (200, 201)
except Exception as e:
logger.warning("Twitter add_dm_participants error: %s", e)
return False
async def remove_dm_participants(
self,
conversation_id: str,
participant_ids: list[str],
*,
account_id: str | None = None,
) -> bool:
account = await self._resolve_account(account_id)
if not account or not account.get("is_configured"):
return False
oauth = self._build_oauth_session(account)
url = f"https://api.x.com/2/dm_conversations/{conversation_id}/participants"
try:
body = {"participant_ids": [int(pid) for pid in participant_ids]}
resp = await asyncio.to_thread(oauth.delete, url, json=body, timeout=15)
return resp.status_code in (200, 204)
except Exception as e:
logger.warning("Twitter remove_dm_participants error: %s", e)
return False
async def get_dm_history(
self,
participant_id: str,
*,
max_results: int = 50,
account_id: str | None = None,
) -> list[dict] | None:
account = await self._resolve_account(account_id)
if not account or not account.get("is_configured"):
return None
client = create_tweepy_client(account)
try:
response = await asyncio.to_thread(
client.get_dm_events,
participant_id=int(participant_id),
max_results=max_results,
dm_event_fields=[
"id",
"text",
"event_type",
"created_at",
"sender_id",
"participant_ids",
"dm_conversation_id",
"attachments",
],
expansions=["sender_id", "attachments.media_key"],
)
return response.get("data", [])
except Exception as e:
logger.warning("Twitter get_dm_history error: %s", e)
return None
@staticmethod
def _build_oauth_session(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"],
)