""" Twitter platform adapter implementation with enhanced error handling and real metrics. """ from typing import Dict, Any, Optional, List from datetime import datetime import tweepy from tweepy.models import Status import logging import time from .base import PlatformAdapter logger = logging.getLogger(__name__) class TwitterAdapter(PlatformAdapter): """Enhanced Twitter platform adapter with real metrics and error handling.""" def __init__(self, config: Dict[str, Any]): """Initialize Twitter adapter with configuration.""" super().__init__(config) self._validate_config() self._initialize_client() self.rate_limit_tracker = {} def _initialize_client(self) -> None: """Initialize Twitter API client with enhanced error handling.""" try: # Initialize OAuth handler auth = tweepy.OAuthHandler( self.config['api_key'], self.config['api_secret'] ) auth.set_access_token( self.config['access_token'], self.config['access_token_secret'] ) # Create API client with wait_on_rate_limit self.client = tweepy.API( auth, wait_on_rate_limit=True, retry_count=3, retry_delay=5 ) # Verify credentials user = self.client.verify_credentials() if not user: raise Exception("Failed to verify Twitter credentials") logger.info(f"Twitter client initialized for @{user.screen_name}") except tweepy.Unauthorized: raise Exception("Invalid Twitter API credentials") except tweepy.Forbidden: raise Exception("Access forbidden - check API permissions") except Exception as e: raise Exception(f"Failed to initialize Twitter client: {str(e)}") async def publish_content( self, content: Dict[str, Any], schedule_time: Optional[datetime] = None ) -> Dict[str, Any]: """Publish content to Twitter with enhanced error handling.""" try: # Validate content first validation = await self.validate_content(content) if not validation.get('success'): return validation # Check rate limits if not self._check_rate_limit('tweets'): return self._format_error_response( Exception("Rate limit exceeded for tweets"), {'content': content} ) # Prepare tweet content tweet_text = content.get('text', '') media_ids = [] # Handle media attachments if present if 'media' in content and content['media']: for media in content['media']: media_id = self._upload_media(media) if media_id: media_ids.append(media_id) # Create tweet tweet = self.client.update_status( status=tweet_text, media_ids=media_ids if media_ids else None ) # Update rate limit tracker self._update_rate_limit_tracker('tweets') # Format response with comprehensive data tweet_data = { 'id': tweet.id_str, 'text': tweet.text, 'created_at': tweet.created_at.isoformat(), 'user': { 'screen_name': tweet.user.screen_name, 'name': tweet.user.name, 'followers_count': tweet.user.followers_count }, 'metrics': { 'retweet_count': tweet.retweet_count, 'favorite_count': tweet.favorite_count, 'reply_count': getattr(tweet, 'reply_count', 0) }, 'urls': { 'tweet_url': f"https://twitter.com/{tweet.user.screen_name}/status/{tweet.id_str}" } } return self._format_success_response(tweet_data) except tweepy.Unauthorized: return self._format_error_response( Exception("Authentication failed - please reconnect your account"), {'content': content} ) except tweepy.Forbidden as e: error_msg = "Access forbidden" if "duplicate" in str(e).lower(): error_msg = "Duplicate tweet detected - please modify your content" elif "automated" in str(e).lower(): error_msg = "Tweet appears automated - please make it more personal" return self._format_error_response( Exception(error_msg), {'content': content} ) except tweepy.TooManyRequests: return self._format_error_response( Exception("Rate limit exceeded - please wait before posting again"), {'content': content} ) except Exception as e: return self._format_error_response(e, {'content': content}) async def get_content_status(self, content_id: str) -> Dict[str, Any]: """Get status of a tweet with real metrics.""" try: tweet = self.client.get_status( content_id, include_entities=True, tweet_mode='extended' ) tweet_data = { 'id': tweet.id_str, 'text': tweet.full_text, 'created_at': tweet.created_at.isoformat(), 'metrics': { 'retweet_count': tweet.retweet_count, 'favorite_count': tweet.favorite_count, 'reply_count': getattr(tweet, 'reply_count', 0), 'quote_count': getattr(tweet, 'quote_count', 0) }, 'engagement': { 'engagement_rate': self._calculate_engagement_rate(tweet), 'total_engagement': tweet.retweet_count + tweet.favorite_count + getattr(tweet, 'reply_count', 0) }, 'user': { 'screen_name': tweet.user.screen_name, 'followers_count': tweet.user.followers_count } } return self._format_success_response(tweet_data) except tweepy.NotFound: return self._format_error_response( Exception("Tweet not found - it may have been deleted"), {'content_id': content_id} ) except Exception as e: return self._format_error_response(e, {'content_id': content_id}) async def get_analytics( self, content_id: str, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> Dict[str, Any]: """Get comprehensive analytics for a tweet.""" try: # Get tweet details tweet = self.client.get_status( content_id, include_entities=True, tweet_mode='extended' ) # Calculate engagement metrics total_engagement = ( tweet.retweet_count + tweet.favorite_count + getattr(tweet, 'reply_count', 0) + getattr(tweet, 'quote_count', 0) ) engagement_rate = self._calculate_engagement_rate(tweet) # Get time-based metrics (if tweet is recent) time_metrics = self._calculate_time_metrics(tweet) analytics_data = { 'tweet_id': tweet.id_str, 'metrics': { 'likes': tweet.favorite_count, 'retweets': tweet.retweet_count, 'replies': getattr(tweet, 'reply_count', 0), 'quotes': getattr(tweet, 'quote_count', 0), 'total_engagement': total_engagement, 'impressions': getattr(tweet, 'impression_count', 0) # May not be available }, 'engagement': { 'engagement_rate': engagement_rate, 'likes_rate': (tweet.favorite_count / tweet.user.followers_count * 100) if tweet.user.followers_count > 0 else 0, 'retweets_rate': (tweet.retweet_count / tweet.user.followers_count * 100) if tweet.user.followers_count > 0 else 0 }, 'timing': time_metrics, 'audience': { 'followers_at_post': tweet.user.followers_count, 'reach_percentage': (total_engagement / tweet.user.followers_count * 100) if tweet.user.followers_count > 0 else 0 }, 'content_analysis': { 'character_count': len(tweet.full_text), 'hashtag_count': len([entity for entity in tweet.entities.get('hashtags', [])]), 'mention_count': len([entity for entity in tweet.entities.get('user_mentions', [])]), 'url_count': len([entity for entity in tweet.entities.get('urls', [])]) } } return self._format_success_response(analytics_data) except Exception as e: return self._format_error_response(e, { 'content_id': content_id, 'start_date': start_date, 'end_date': end_date }) async def validate_content(self, content: Dict[str, Any]) -> Dict[str, Any]: """Enhanced content validation.""" try: errors = [] warnings = [] # Check text text = content.get('text', '') if not text.strip(): errors.append("Tweet text cannot be empty") # Check length if len(text) > 280: errors.append(f"Tweet text exceeds 280 characters ({len(text)}/280)") elif len(text) > 270: warnings.append("Tweet is close to character limit") # Check for very short tweets if len(text) < 10: warnings.append("Very short tweets may get less engagement") # Check media media = content.get('media', []) if len(media) > 4: errors.append("Maximum 4 media attachments allowed") # Check for spam indicators if text.count('#') > 3: warnings.append("Too many hashtags may reduce engagement") if text.count('@') > 5: warnings.append("Too many mentions may appear spammy") # Check for duplicate content (basic check) if self._is_potential_duplicate(text): warnings.append("Content may be similar to recent tweets") if errors: return self._format_error_response( ValueError(f"Validation failed: {'; '.join(errors)}"), {'content': content, 'warnings': warnings} ) validation_data = { 'valid': True, 'content': content, 'warnings': warnings, 'suggestions': self._get_content_suggestions(text) } return self._format_success_response(validation_data) except Exception as e: return self._format_error_response(e, {'content': content}) def _calculate_engagement_rate(self, tweet: Status) -> float: """Calculate engagement rate for a tweet.""" try: total_engagement = ( tweet.favorite_count + tweet.retweet_count + getattr(tweet, 'reply_count', 0) + getattr(tweet, 'quote_count', 0) ) followers = tweet.user.followers_count return (total_engagement / followers * 100) if followers > 0 else 0.0 except Exception: return 0.0 def _calculate_time_metrics(self, tweet: Status) -> Dict[str, Any]: """Calculate time-based metrics for a tweet.""" try: now = datetime.now() tweet_time = tweet.created_at.replace(tzinfo=None) age_hours = (now - tweet_time).total_seconds() / 3600 # Calculate engagement velocity (engagement per hour) total_engagement = ( tweet.favorite_count + tweet.retweet_count + getattr(tweet, 'reply_count', 0) ) engagement_velocity = total_engagement / max(age_hours, 1) return { 'age_hours': round(age_hours, 2), 'engagement_velocity': round(engagement_velocity, 2), 'peak_engagement_period': self._estimate_peak_period(tweet_time), 'posted_at': tweet_time.isoformat() } except Exception: return {} def _estimate_peak_period(self, tweet_time: datetime) -> str: """Estimate if tweet was posted during peak engagement period.""" hour = tweet_time.hour if 9 <= hour <= 10: return "Morning Peak (9-10 AM)" elif 12 <= hour <= 13: return "Lunch Peak (12-1 PM)" elif 19 <= hour <= 21: return "Evening Peak (7-9 PM)" else: return "Off-Peak Hours" def _check_rate_limit(self, endpoint: str) -> bool: """Check if we're within rate limits for an endpoint.""" try: rate_limits = self.client.get_rate_limit_status() endpoint_map = { 'tweets': '/statuses/update', 'user_timeline': '/statuses/user_timeline', 'verify_credentials': '/account/verify_credentials' } if endpoint in endpoint_map: limit_info = rate_limits['resources']['statuses'].get(endpoint_map[endpoint]) if limit_info: return limit_info['remaining'] > 0 return True # Default to allowing if we can't check except Exception: return True # Default to allowing if check fails def _update_rate_limit_tracker(self, endpoint: str) -> None: """Update internal rate limit tracker.""" now = time.time() if endpoint not in self.rate_limit_tracker: self.rate_limit_tracker[endpoint] = [] # Add current request self.rate_limit_tracker[endpoint].append(now) # Clean old requests (older than 15 minutes) self.rate_limit_tracker[endpoint] = [ timestamp for timestamp in self.rate_limit_tracker[endpoint] if now - timestamp < 900 # 15 minutes ] def _is_potential_duplicate(self, text: str) -> bool: """Basic check for potential duplicate content.""" # This is a simplified check - in production, you'd want more sophisticated detection try: # Get recent tweets from user recent_tweets = self.client.user_timeline(count=20, tweet_mode='extended') for tweet in recent_tweets: # Simple similarity check if self._calculate_text_similarity(text, tweet.full_text) > 0.8: return True return False except Exception: return False # If we can't check, assume it's not a duplicate def _calculate_text_similarity(self, text1: str, text2: str) -> float: """Calculate simple text similarity.""" # Simple word-based similarity words1 = set(text1.lower().split()) words2 = set(text2.lower().split()) if not words1 or not words2: return 0.0 intersection = words1.intersection(words2) union = words1.union(words2) return len(intersection) / len(union) if union else 0.0 def _get_content_suggestions(self, text: str) -> List[str]: """Get suggestions for improving tweet content.""" suggestions = [] if len(text) < 50: suggestions.append("Consider adding more context to increase engagement") if not any(char in text for char in '!?'): suggestions.append("Adding punctuation can make tweets more engaging") if '#' not in text: suggestions.append("Consider adding 1-2 relevant hashtags") if not any(emoji_char in text for emoji_char in '😀😃😄😁😆😅😂🤣'): suggestions.append("Emojis can increase engagement and visual appeal") return suggestions async def delete_content( self, content_id: str ) -> Dict[str, Any]: """Delete a tweet.""" try: self.client.destroy_status(content_id) return self._format_success_response({ 'id': content_id, 'deleted': True }) except Exception as e: return self._format_error_response( e, {'content_id': content_id} ) async def update_content( self, content_id: str, updates: Dict[str, Any] ) -> Dict[str, Any]: """Update a tweet.""" try: # Twitter doesn't support updating tweets # We'll delete the old one and create a new one await self.delete_content(content_id) return await self.publish_content(updates) except Exception as e: return self._format_error_response( e, { 'content_id': content_id, 'updates': updates } ) async def get_optimal_publish_time( self, content_type: str, target_audience: Optional[Dict[str, Any]] = None ) -> datetime: """Get optimal publish time for content.""" # Implement optimal time calculation based on: # - Content type # - Target audience timezone # - Historical engagement data # For now, return current time return datetime.now() async def get_platform_limits( self ) -> Dict[str, Any]: """Get Twitter platform limits.""" return self._format_success_response({ 'tweet_length': 280, 'media_attachments': 4, 'poll_options': 4, 'poll_duration': 10080, # 7 days in minutes 'rate_limits': { 'tweets_per_day': 2000, 'tweets_per_hour': 100 } }) async def get_supported_content_types( self ) -> List[str]: """Get list of supported content types.""" return ['TWEET', 'THREAD', 'POLL'] async def get_platform_metrics( self ) -> Dict[str, Any]: """Get Twitter platform metrics.""" try: account = self.client.verify_credentials() return self._format_success_response({ 'followers_count': account.followers_count, 'following_count': account.friends_count, 'tweets_count': account.statuses_count, 'account_created_at': account.created_at.isoformat() }) except Exception as e: return self._format_error_response(e) def _upload_media(self, media: Dict[str, Any]) -> Optional[str]: """Upload media to Twitter.""" try: if 'url' in media: # Download media from URL response = requests.get(media['url']) media_file = BytesIO(response.content) elif 'file' in media: # Use local file media_file = open(media['file'], 'rb') else: return None # Upload media media_upload = self.client.media_upload( filename=media.get('filename', 'media'), file=media_file ) return media_upload.media_id_string except Exception as e: logger.error(f"Failed to upload media: {str(e)}") return None @classmethod def get_required_config_fields(cls) -> List[str]: """Get list of required configuration fields.""" return [ 'api_key', 'api_secret', 'access_token', 'access_token_secret' ] @classmethod def get_platform_description(cls) -> str: """Get platform description.""" return "Twitter platform adapter for posting and managing tweets" @classmethod def get_platform_version(cls) -> str: """Get platform adapter version.""" return "1.0.0"