# xcom/tasks.py from celery import shared_task from django.utils import timezone from django.utils.dateparse import parse_datetime from apps.social.models import SocialAccount, SocialContent, SocialComment from apps.social.services.x import XService, XAPIError, XRateLimitError import logging logger = logging.getLogger(__name__) def parse_x_timestamp(ts): if not ts: return timezone.now() try: dt = parse_datetime(ts) return dt if dt else timezone.now() except: return timezone.now() @shared_task def sync_all_accounts_periodic(): logger.info("Starting Periodic Polling for X (Twitter)...") accounts = SocialAccount.objects.filter(platform_type='X', is_active=True) for account in accounts: try: poll_new_replies_task.delay(account.id) except Exception as e: logger.error(f"Failed to trigger sync for account {account.id}: {e}") @shared_task(bind=True, max_retries=6) # Allow retries for rate limits def poll_new_replies_task(self, account_id): """ DELTA SYNC TASK with intelligent Rate Limit handling. """ try: account = SocialAccount.objects.get(id=account_id, platform_type='X') tweets = SocialContent.objects.filter(platform_type='X', account=account) for tweet in tweets: if not tweet.last_comment_sync_at: continue # Skip unsynced tweets try: replies_data = XService.fetch_tweet_replies( account, tweet.content_id, since_datetime=tweet.last_comment_sync_at, owner_id=account.platform_id ) except XRateLimitError as e: # CRITICAL: Don't block. Retry later using ETA (Estimated Time of Arrival) # Add a buffer of 60 seconds to ensure limit is reset retry_time = timezone.make_aware(datetime.datetime.fromtimestamp(e.reset_at)) wait_seconds = (retry_time - timezone.now()).total_seconds() + 60 logger.warning(f"Rate limit hit. Retrying in {wait_seconds} seconds.") raise self.retry(exc=XAPIError("Rate limit triggered"), countdown=wait_seconds) if not replies_data: continue newest_timestamp_found = tweet.last_comment_sync_at for r_data in replies_data: # r_data['author'] is now populated by _attach_expansions author = r_data.get('author') if not author: # Fallback if expansion failed (shouldn't happen) continue r_time = parse_x_timestamp(r_data['created_at']) if r_time <= tweet.last_comment_sync_at: continue SocialComment.objects.update_or_create( platform_type='X', comment_id=r_data['id'], defaults={ 'account': account, 'content': tweet, 'text': r_data.get('text', ''), 'author_id': author.get('id'), 'author_name': author.get('username', 'Unknown'), # Now correctly fetched 'created_at': r_time, 'comment_data': r_data } ) if r_time > newest_timestamp_found: newest_timestamp_found = r_time if newest_timestamp_found > tweet.last_comment_sync_at: tweet.last_comment_sync_at = newest_timestamp_found tweet.save() account.last_synced_at = timezone.now() account.save() except SocialAccount.DoesNotExist: logger.error(f"Account {account_id} not found") except XRateLimitError: # Raise it again if we haven't retried enough times raise except Exception as e: logger.error(f"Polling Error for account {account_id}: {e}") @shared_task(bind=True) def extract_all_replies_task(self, account_id): """ MANUAL FULL SYNC (Backfill) """ try: account = SocialAccount.objects.get(id=account_id, platform_type='X') tweets_data = XService.get_user_tweets(account) if not tweets_data: logger.info(f"No tweets found for account {account.name}") return for t_data in tweets_data: tweet_id = t_data['id'] tweet, created = SocialContent.objects.get_or_create( platform_type='X', content_id=tweet_id, defaults={ 'account': account, 'text': t_data.get('text', ''), 'created_at': parse_x_timestamp(t_data['created_at']), 'content_data': t_data } ) # Rate limit handling for fetch inside backfill try: replies_data = XService.fetch_tweet_replies( account, tweet_id, since_datetime=None, owner_id=account.platform_id ) except XRateLimitError as e: # Retry backfill later retry_time = timezone.make_aware(datetime.datetime.fromtimestamp(e.reset_at)) wait_seconds = (retry_time - timezone.now()).total_seconds() + 60 raise self.retry(exc=XAPIError("Rate limit triggered"), countdown=wait_seconds) if not replies_data: continue for r_data in replies_data: author = r_data.get('author') if not author: continue SocialComment.objects.update_or_create( platform_type='X', comment_id=r_data['id'], defaults={ 'account': account, 'content': tweet, 'text': r_data.get('text', ''), 'author_id': author.get('id'), 'author_name': author.get('username', 'Unknown'), 'created_at': parse_x_timestamp(r_data['created_at']), 'comment_data': r_data } ) if created: tweet.last_comment_sync_at = tweet.created_at tweet.save() account.last_synced_at = timezone.now() account.save() except SocialAccount.DoesNotExist: logger.error(f"Account {account_id} not found") except XRateLimitError: raise except Exception as e: logger.error(f"Backfill Error for account {account_id}: {e}")