Added new features to the project
This commit is contained in:
@@ -1,26 +1,32 @@
|
||||
"""
|
||||
Twitter platform adapter implementation.
|
||||
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):
|
||||
"""Twitter platform adapter."""
|
||||
"""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."""
|
||||
"""Initialize Twitter API client with enhanced error handling."""
|
||||
try:
|
||||
# Initialize OAuth handler
|
||||
auth = tweepy.OAuthHandler(
|
||||
self.config['api_key'],
|
||||
self.config['api_secret']
|
||||
@@ -29,31 +35,54 @@ class TwitterAdapter(PlatformAdapter):
|
||||
self.config['access_token'],
|
||||
self.config['access_token_secret']
|
||||
)
|
||||
self.client = tweepy.API(auth)
|
||||
self.client.verify_credentials()
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
f"Failed to initialize Twitter client: {str(e)}"
|
||||
|
||||
# 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."""
|
||||
"""Publish content to Twitter with enhanced error handling."""
|
||||
try:
|
||||
# Validate content
|
||||
# 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:
|
||||
if 'media' in content and content['media']:
|
||||
for media in content['media']:
|
||||
media_id = self._upload_media(media)
|
||||
if media_id:
|
||||
@@ -65,37 +94,348 @@ class TwitterAdapter(PlatformAdapter):
|
||||
media_ids=media_ids if media_ids else None
|
||||
)
|
||||
|
||||
return self._format_success_response({
|
||||
'id': tweet.id_str,
|
||||
'text': tweet.text,
|
||||
'created_at': tweet.created_at.isoformat()
|
||||
})
|
||||
# Update rate limit tracker
|
||||
self._update_rate_limit_tracker('tweets')
|
||||
|
||||
except Exception as e:
|
||||
return self._format_error_response(
|
||||
e,
|
||||
{'content': content, 'schedule_time': schedule_time}
|
||||
)
|
||||
|
||||
async def get_content_status(
|
||||
self,
|
||||
content_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Get status of a tweet."""
|
||||
try:
|
||||
tweet = self.client.get_status(content_id)
|
||||
return self._format_success_response({
|
||||
# Format response with comprehensive data
|
||||
tweet_data = {
|
||||
'id': tweet.id_str,
|
||||
'text': tweet.text,
|
||||
'created_at': tweet.created_at.isoformat(),
|
||||
'favorite_count': tweet.favorite_count,
|
||||
'retweet_count': tweet.retweet_count
|
||||
})
|
||||
except Exception as e:
|
||||
'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(
|
||||
e,
|
||||
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,
|
||||
@@ -134,68 +474,6 @@ class TwitterAdapter(PlatformAdapter):
|
||||
}
|
||||
)
|
||||
|
||||
async def get_analytics(
|
||||
self,
|
||||
content_id: str,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get analytics for a tweet."""
|
||||
try:
|
||||
tweet = self.client.get_status(content_id)
|
||||
return self._format_success_response({
|
||||
'id': tweet.id_str,
|
||||
'metrics': {
|
||||
'favorites': tweet.favorite_count,
|
||||
'retweets': tweet.retweet_count,
|
||||
'replies': tweet.reply_count if hasattr(tweet, 'reply_count') else 0,
|
||||
'impressions': tweet.impression_count if hasattr(tweet, 'impression_count') else 0
|
||||
},
|
||||
'engagement_rate': self._calculate_engagement_rate(tweet)
|
||||
})
|
||||
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]:
|
||||
"""Validate content before publishing."""
|
||||
try:
|
||||
# Check text length
|
||||
text = content.get('text', '')
|
||||
if len(text) > 280:
|
||||
return self._format_error_response(
|
||||
ValueError("Tweet text exceeds 280 characters"),
|
||||
{'content': content}
|
||||
)
|
||||
|
||||
# Check media attachments
|
||||
media = content.get('media', [])
|
||||
if len(media) > 4:
|
||||
return self._format_error_response(
|
||||
ValueError("Maximum 4 media attachments allowed"),
|
||||
{'content': content}
|
||||
)
|
||||
|
||||
return self._format_success_response({
|
||||
'valid': True,
|
||||
'content': content
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return self._format_error_response(
|
||||
e,
|
||||
{'content': content}
|
||||
)
|
||||
|
||||
async def get_optimal_publish_time(
|
||||
self,
|
||||
content_type: str,
|
||||
@@ -245,19 +523,6 @@ class TwitterAdapter(PlatformAdapter):
|
||||
except Exception as e:
|
||||
return self._format_error_response(e)
|
||||
|
||||
def _calculate_engagement_rate(self, tweet: Status) -> float:
|
||||
"""Calculate engagement rate for a tweet."""
|
||||
try:
|
||||
total_engagement = (
|
||||
tweet.favorite_count +
|
||||
tweet.retweet_count +
|
||||
(tweet.reply_count if hasattr(tweet, 'reply_count') else 0)
|
||||
)
|
||||
followers = tweet.user.followers_count
|
||||
return (total_engagement / followers * 100) if followers > 0 else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _upload_media(self, media: Dict[str, Any]) -> Optional[str]:
|
||||
"""Upload media to Twitter."""
|
||||
try:
|
||||
|
||||
337
lib/integrations/twitter_auth_bridge.py
Normal file
337
lib/integrations/twitter_auth_bridge.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Twitter Authentication Bridge
|
||||
Connects the platform adapter with the UI authentication system for secure Twitter integration.
|
||||
"""
|
||||
|
||||
import streamlit as st
|
||||
import tweepy
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import base64
|
||||
from cryptography.fernet import Fernet
|
||||
import logging
|
||||
|
||||
from .platform_adapters.twitter import TwitterAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TwitterAuthBridge:
|
||||
"""Bridge between Twitter authentication and platform adapter."""
|
||||
|
||||
def __init__(self):
|
||||
self.config_dir = Path("config/twitter")
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.encryption_key = self._get_or_create_encryption_key()
|
||||
|
||||
def _get_or_create_encryption_key(self) -> bytes:
|
||||
"""Get or create encryption key for secure credential storage."""
|
||||
key_file = self.config_dir / "encryption.key"
|
||||
|
||||
if key_file.exists():
|
||||
with open(key_file, 'rb') as f:
|
||||
return f.read()
|
||||
else:
|
||||
key = Fernet.generate_key()
|
||||
with open(key_file, 'wb') as f:
|
||||
f.write(key)
|
||||
return key
|
||||
|
||||
def encrypt_credentials(self, credentials: Dict[str, str]) -> str:
|
||||
"""Encrypt Twitter credentials for secure storage."""
|
||||
try:
|
||||
fernet = Fernet(self.encryption_key)
|
||||
credentials_json = json.dumps(credentials)
|
||||
encrypted_data = fernet.encrypt(credentials_json.encode())
|
||||
return base64.b64encode(encrypted_data).decode()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encrypt credentials: {str(e)}")
|
||||
raise
|
||||
|
||||
def decrypt_credentials(self, encrypted_data: str) -> Dict[str, str]:
|
||||
"""Decrypt Twitter credentials from secure storage."""
|
||||
try:
|
||||
fernet = Fernet(self.encryption_key)
|
||||
encrypted_bytes = base64.b64decode(encrypted_data.encode())
|
||||
decrypted_data = fernet.decrypt(encrypted_bytes)
|
||||
return json.loads(decrypted_data.decode())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt credentials: {str(e)}")
|
||||
raise
|
||||
|
||||
def save_credentials(self, user_id: str, credentials: Dict[str, str]) -> bool:
|
||||
"""Save encrypted Twitter credentials to file."""
|
||||
try:
|
||||
# Create user-specific credentials file
|
||||
user_hash = hashlib.sha256(user_id.encode()).hexdigest()[:16]
|
||||
creds_file = self.config_dir / f"user_{user_hash}.enc"
|
||||
|
||||
# Add timestamp and validation
|
||||
credentials_with_meta = {
|
||||
**credentials,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'user_id_hash': user_hash
|
||||
}
|
||||
|
||||
# Encrypt and save
|
||||
encrypted_data = self.encrypt_credentials(credentials_with_meta)
|
||||
with open(creds_file, 'w') as f:
|
||||
f.write(encrypted_data)
|
||||
|
||||
logger.info(f"Credentials saved for user {user_hash}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save credentials: {str(e)}")
|
||||
return False
|
||||
|
||||
def load_credentials(self, user_id: str) -> Optional[Dict[str, str]]:
|
||||
"""Load and decrypt Twitter credentials from file."""
|
||||
try:
|
||||
user_hash = hashlib.sha256(user_id.encode()).hexdigest()[:16]
|
||||
creds_file = self.config_dir / f"user_{user_hash}.enc"
|
||||
|
||||
if not creds_file.exists():
|
||||
logger.warning(f"No credentials found for user {user_hash}")
|
||||
return None
|
||||
|
||||
# Load and decrypt
|
||||
with open(creds_file, 'r') as f:
|
||||
encrypted_data = f.read()
|
||||
|
||||
credentials = self.decrypt_credentials(encrypted_data)
|
||||
|
||||
# Validate credentials are not expired (optional)
|
||||
created_at = datetime.fromisoformat(credentials.get('created_at', ''))
|
||||
if datetime.now() - created_at > timedelta(days=365): # 1 year expiry
|
||||
logger.warning(f"Credentials expired for user {user_hash}")
|
||||
return None
|
||||
|
||||
# Remove metadata before returning
|
||||
clean_credentials = {k: v for k, v in credentials.items()
|
||||
if k not in ['created_at', 'user_id_hash']}
|
||||
|
||||
return clean_credentials
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load credentials: {str(e)}")
|
||||
return None
|
||||
|
||||
def delete_credentials(self, user_id: str) -> bool:
|
||||
"""Delete stored Twitter credentials."""
|
||||
try:
|
||||
user_hash = hashlib.sha256(user_id.encode()).hexdigest()[:16]
|
||||
creds_file = self.config_dir / f"user_{user_hash}.enc"
|
||||
|
||||
if creds_file.exists():
|
||||
creds_file.unlink()
|
||||
logger.info(f"Credentials deleted for user {user_hash}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete credentials: {str(e)}")
|
||||
return False
|
||||
|
||||
def validate_credentials(self, credentials: Dict[str, str]) -> Tuple[bool, str]:
|
||||
"""Validate Twitter API credentials."""
|
||||
try:
|
||||
# Check required fields
|
||||
required_fields = ['api_key', 'api_secret', 'access_token', 'access_token_secret']
|
||||
missing_fields = [field for field in required_fields if not credentials.get(field)]
|
||||
|
||||
if missing_fields:
|
||||
return False, f"Missing required fields: {', '.join(missing_fields)}"
|
||||
|
||||
# Test connection
|
||||
auth = tweepy.OAuthHandler(
|
||||
credentials['api_key'],
|
||||
credentials['api_secret']
|
||||
)
|
||||
auth.set_access_token(
|
||||
credentials['access_token'],
|
||||
credentials['access_token_secret']
|
||||
)
|
||||
|
||||
api = tweepy.API(auth)
|
||||
user = api.verify_credentials()
|
||||
|
||||
if user:
|
||||
return True, f"Valid credentials for @{user.screen_name}"
|
||||
else:
|
||||
return False, "Failed to verify credentials"
|
||||
|
||||
except tweepy.Unauthorized:
|
||||
return False, "Invalid API credentials"
|
||||
except tweepy.Forbidden:
|
||||
return False, "Access forbidden - check API permissions"
|
||||
except tweepy.TooManyRequests:
|
||||
return False, "Rate limit exceeded - try again later"
|
||||
except Exception as e:
|
||||
return False, f"Connection error: {str(e)}"
|
||||
|
||||
def get_twitter_adapter(self, user_id: str) -> Optional[TwitterAdapter]:
|
||||
"""Get configured Twitter adapter for user."""
|
||||
try:
|
||||
# First check session state
|
||||
if 'twitter_adapter' in st.session_state:
|
||||
return st.session_state.twitter_adapter
|
||||
|
||||
# Load credentials
|
||||
credentials = self.load_credentials(user_id)
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
# Validate credentials
|
||||
is_valid, message = self.validate_credentials(credentials)
|
||||
if not is_valid:
|
||||
logger.error(f"Invalid credentials: {message}")
|
||||
return None
|
||||
|
||||
# Create adapter
|
||||
adapter = TwitterAdapter(credentials)
|
||||
|
||||
# Cache in session state
|
||||
st.session_state.twitter_adapter = adapter
|
||||
|
||||
return adapter
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Twitter adapter: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get Twitter user information."""
|
||||
try:
|
||||
adapter = self.get_twitter_adapter(user_id)
|
||||
if not adapter:
|
||||
return None
|
||||
|
||||
# Get user info from Twitter
|
||||
user = adapter.client.verify_credentials()
|
||||
|
||||
user_info = {
|
||||
'id': user.id_str,
|
||||
'screen_name': user.screen_name,
|
||||
'name': user.name,
|
||||
'description': user.description,
|
||||
'followers_count': user.followers_count,
|
||||
'friends_count': user.friends_count,
|
||||
'statuses_count': user.statuses_count,
|
||||
'profile_image_url': user.profile_image_url_https,
|
||||
'profile_banner_url': getattr(user, 'profile_banner_url', ''),
|
||||
'verified': user.verified,
|
||||
'created_at': user.created_at.isoformat(),
|
||||
'location': user.location or '',
|
||||
'url': user.url or ''
|
||||
}
|
||||
|
||||
return user_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user info: {str(e)}")
|
||||
return None
|
||||
|
||||
def setup_session_state(self, user_id: str) -> bool:
|
||||
"""Setup session state with Twitter authentication."""
|
||||
try:
|
||||
# Load credentials
|
||||
credentials = self.load_credentials(user_id)
|
||||
if not credentials:
|
||||
return False
|
||||
|
||||
# Get user info
|
||||
user_info = self.get_user_info(user_id)
|
||||
if not user_info:
|
||||
return False
|
||||
|
||||
# Setup session state
|
||||
st.session_state.twitter_authenticated = True
|
||||
st.session_state.twitter_user_id = user_id
|
||||
st.session_state.twitter_user_info = user_info
|
||||
st.session_state.twitter_config = credentials
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to setup session state: {str(e)}")
|
||||
return False
|
||||
|
||||
def clear_session_state(self) -> None:
|
||||
"""Clear Twitter authentication from session state."""
|
||||
keys_to_clear = [
|
||||
'twitter_authenticated',
|
||||
'twitter_user_id',
|
||||
'twitter_user_info',
|
||||
'twitter_config',
|
||||
'twitter_adapter'
|
||||
]
|
||||
|
||||
for key in keys_to_clear:
|
||||
if key in st.session_state:
|
||||
del st.session_state[key]
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if user is authenticated with Twitter."""
|
||||
return (
|
||||
st.session_state.get('twitter_authenticated', False) and
|
||||
st.session_state.get('twitter_user_info') is not None and
|
||||
st.session_state.get('twitter_config') is not None
|
||||
)
|
||||
|
||||
def get_rate_limit_status(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get current rate limit status."""
|
||||
try:
|
||||
adapter = self.get_twitter_adapter(user_id)
|
||||
if not adapter:
|
||||
return None
|
||||
|
||||
rate_limits = adapter.client.get_rate_limit_status()
|
||||
|
||||
# Extract relevant rate limits
|
||||
relevant_limits = {
|
||||
'tweets': rate_limits['resources']['statuses']['/statuses/update'],
|
||||
'user_timeline': rate_limits['resources']['statuses']['/statuses/user_timeline'],
|
||||
'verify_credentials': rate_limits['resources']['account']['/account/verify_credentials']
|
||||
}
|
||||
|
||||
return relevant_limits
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get rate limit status: {str(e)}")
|
||||
return None
|
||||
|
||||
# Global instance
|
||||
twitter_auth = TwitterAuthBridge()
|
||||
|
||||
# Convenience functions for UI
|
||||
def save_twitter_credentials(user_id: str, credentials: Dict[str, str]) -> bool:
|
||||
"""Save Twitter credentials (convenience function)."""
|
||||
return twitter_auth.save_credentials(user_id, credentials)
|
||||
|
||||
def load_twitter_credentials(user_id: str) -> Optional[Dict[str, str]]:
|
||||
"""Load Twitter credentials (convenience function)."""
|
||||
return twitter_auth.load_credentials(user_id)
|
||||
|
||||
def get_twitter_adapter(user_id: str) -> Optional[TwitterAdapter]:
|
||||
"""Get Twitter adapter (convenience function)."""
|
||||
return twitter_auth.get_twitter_adapter(user_id)
|
||||
|
||||
def is_twitter_authenticated() -> bool:
|
||||
"""Check if Twitter is authenticated (convenience function)."""
|
||||
return twitter_auth.is_authenticated()
|
||||
|
||||
def setup_twitter_session(user_id: str) -> bool:
|
||||
"""Setup Twitter session (convenience function)."""
|
||||
return twitter_auth.setup_session_state(user_id)
|
||||
|
||||
def clear_twitter_session() -> None:
|
||||
"""Clear Twitter session (convenience function)."""
|
||||
twitter_auth.clear_session_state()
|
||||
|
||||
def validate_twitter_credentials(credentials: Dict[str, str]) -> Tuple[bool, str]:
|
||||
"""Validate Twitter credentials (convenience function)."""
|
||||
return twitter_auth.validate_credentials(credentials)
|
||||
Reference in New Issue
Block a user