From b6823651e095ce49a5b88e3d74e9a67eac7168dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20AS?= Date: Thu, 29 Jan 2026 23:07:28 +0000 Subject: [PATCH] feat: Add follower tracking to interactions - Add get_followers() method to X publisher - Track new followers as "follow" interaction type - Update daily reports to show followers separately - Store follower name, username, bio, and profile image Co-Authored-By: Claude Opus 4.5 --- app/publishers/x_publisher.py | 35 +++++++++++++++++++++ worker/tasks/daily_reports.py | 50 +++++++++++++++++++++++++----- worker/tasks/fetch_interactions.py | 37 +++++++++++++++++++++- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/app/publishers/x_publisher.py b/app/publishers/x_publisher.py index 4cbcf1c..2172322 100644 --- a/app/publishers/x_publisher.py +++ b/app/publishers/x_publisher.py @@ -277,3 +277,38 @@ class XPublisher(BasePublisher): return True except tweepy.TweepyException: return False + + async def get_followers(self, max_results: int = 100) -> List[Dict]: + """Obtener lista de followers recientes.""" + if not self.client: + return [] + + try: + # Obtener ID del usuario autenticado + me = self.client.get_me() + user_id = me.data.id + + # Obtener followers + followers = self.client.get_users_followers( + id=user_id, + max_results=max_results, + user_fields=['username', 'name', 'created_at', 'profile_image_url', 'description'] + ) + + if not followers.data: + return [] + + return [ + { + "id": str(user.id), + "username": user.username, + "name": user.name, + "description": user.description, + "profile_image_url": user.profile_image_url, + "created_at": user.created_at.isoformat() if user.created_at else None + } + for user in followers.data + ] + + except tweepy.TweepyException: + return [] diff --git a/worker/tasks/daily_reports.py b/worker/tasks/daily_reports.py index 8fbc282..a2b894d 100644 --- a/worker/tasks/daily_reports.py +++ b/worker/tasks/daily_reports.py @@ -67,14 +67,30 @@ def morning_report(): else: message += " _No hay posts programados para hoy_\n\n" - # Sección de interacciones + # Separar follows de otras interacciones + follows = [i for i in new_interactions if i.interaction_type == "follow"] + other_interactions = [i for i in new_interactions if i.interaction_type != "follow"] + + # Sección de nuevos followers + if follows: + message += "━━━━━━━━━━━━━━━━━━━━━\n" + message += f"👥 *Nuevos followers ({len(follows)}):*\n\n" + for f in follows[:5]: + name = f.author_name or f.author_username + name = name.replace("_", "\\_").replace("*", "\\*") if name else "Usuario" + message += f" • @{f.author_username} ({name})\n" + if len(follows) > 5: + message += f" _...y {len(follows) - 5} más_\n" + message += "\n" + + # Sección de otras interacciones message += "━━━━━━━━━━━━━━━━━━━━━\n" message += "💬 *Nuevas interacciones (24h):*\n\n" - if new_interactions: + if other_interactions: # Agrupar por plataforma by_platform = {} - for interaction in new_interactions: + for interaction in other_interactions: platform = interaction.platform if platform not in by_platform: by_platform[platform] = [] @@ -85,7 +101,8 @@ def morning_report(): for i in interactions[:3]: # Mostrar máximo 3 por plataforma content_preview = i.content[:50] + "..." if i.content and len(i.content) > 50 else (i.content or "N/A") content_preview = content_preview.replace("_", "\\_").replace("*", "\\*") - message += f" • @{i.author_username}: {content_preview}\n" + type_emoji = "💬" if i.interaction_type == "comment" else "📢" + message += f" {type_emoji} @{i.author_username}: {content_preview}\n" if len(interactions) > 3: message += f" _...y {len(interactions) - 3} más_\n" message += "\n" @@ -154,13 +171,29 @@ def afternoon_report(): message += f" ✅ Publicados hoy: {published_today}\n" message += f" 📅 Programados mañana: {scheduled_tomorrow}\n\n" + # Separar follows de otras interacciones + follows = [i for i in new_interactions if i.interaction_type == "follow"] + other_interactions = [i for i in new_interactions if i.interaction_type != "follow"] + + # Nuevos followers del día + if follows: + message += "━━━━━━━━━━━━━━━━━━━━━\n" + message += f"👥 *Nuevos followers hoy ({len(follows)}):*\n\n" + for f in follows[:5]: + name = f.author_name or f.author_username + name = name.replace("_", "\\_").replace("*", "\\*") if name else "Usuario" + message += f" • @{f.author_username} ({name})\n" + if len(follows) > 5: + message += f" _...y {len(follows) - 5} más_\n" + message += "\n" + # Interacciones pendientes message += "━━━━━━━━━━━━━━━━━━━━━\n" message += "💬 *Interacciones pendientes:*\n\n" - if new_interactions: + if other_interactions: by_platform = {} - for interaction in new_interactions: + for interaction in other_interactions: platform = interaction.platform if platform not in by_platform: by_platform[platform] = [] @@ -171,13 +204,14 @@ def afternoon_report(): for i in interactions[:5]: # Mostrar más en el reporte de la tarde content_preview = i.content[:50] + "..." if i.content and len(i.content) > 50 else (i.content or "N/A") content_preview = content_preview.replace("_", "\\_").replace("*", "\\*") - message += f" • @{i.author_username}: {content_preview}\n" + type_emoji = "💬" if i.interaction_type == "comment" else "📢" + message += f" {type_emoji} @{i.author_username}: {content_preview}\n" if len(interactions) > 5: message += f" _...y {len(interactions) - 5} más_\n" message += "\n" message += "━━━━━━━━━━━━━━━━━━━━━\n" - message += f"⚠️ *{len(new_interactions)} interacciones sin responder*" + message += f"⚠️ *{len(other_interactions)} interacciones sin responder*" else: message += " ✅ _Todas las interacciones han sido atendidas_\n\n" message += "━━━━━━━━━━━━━━━━━━━━━\n" diff --git a/worker/tasks/fetch_interactions.py b/worker/tasks/fetch_interactions.py index 9afc785..1e7df28 100644 --- a/worker/tasks/fetch_interactions.py +++ b/worker/tasks/fetch_interactions.py @@ -138,9 +138,44 @@ def fetch_platform_interactions(platform: str): processed_ids.add(external_id) new_comments += 1 + # Obtener nuevos followers (solo para X por ahora) + new_follows = 0 + if platform == "x" and hasattr(publisher, 'get_followers'): + followers = run_async(publisher.get_followers(max_results=100)) + + for follower in followers: + # Usar "follow_" prefix para distinguir de tweet IDs + follower_id = follower.get("id") + external_id = f"follow_{follower_id}" + + if external_id in processed_ids: + continue + + existing = db.query(Interaction).filter( + Interaction.external_id == external_id + ).first() + + if not existing: + interaction = Interaction( + platform=platform, + interaction_type="follow", + external_id=external_id, + author_username=follower.get("username", "unknown"), + author_name=follower.get("name"), + author_avatar_url=follower.get("profile_image_url"), + content=follower.get("description"), # Bio del usuario + interaction_at=datetime.utcnow() # No sabemos cuándo siguió exactamente + ) + db.add(interaction) + processed_ids.add(external_id) + new_follows += 1 + db.commit() - return f"{platform}: {new_mentions} menciones, {new_comments} comentarios nuevos" + result = f"{platform}: {new_mentions} menciones, {new_comments} comentarios" + if new_follows > 0: + result += f", {new_follows} follows nuevos" + return result except Exception as e: db.rollback()