新增 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: 媒体资源处理
315 lines
10 KiB
Python
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"],
|
|
)
|