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"], )