"""Slack notifier β€” Web API (Bot Token) con botones interactivos.""" from datetime import datetime import requests import config _SLACK_API = "https://slack.com/api" _STRATEGY_LABELS = { "LOWEST_COST_WITHOUT_CAP": "Menor coste", "LOWEST_COST_WITH_BID_CAP": "Cap. puja", "COST_CAP": "Cap. coste", "MINIMUM_ROAS": "ROAS mΓ­n.", } _ACTION_DISPLAY = { "INCREASE_BUDGET": ("🟒", "AUMENTAR PRESUPUESTO"), "REDUCE_BUDGET": ("πŸ”΄", "REDUCIR PRESUPUESTO"), "PAUSE": ("β›”", "PAUSAR CAMPAΓ‘A"), "REVIEW_CREATIVES": ("πŸ”", "REVISAR CREATIVIDADES"), "MAINTAIN": ("βœ…", "MANTENER"), } # Solo estas acciones ejecutan algo real en Meta API β†’ botones _ACTIONABLE = {"INCREASE_BUDGET", "REDUCE_BUDGET", "PAUSE"} def _effect_text(action: dict, budget: float | None) -> str: """Texto que describe exactamente quΓ© ocurrirΓ‘ si se aprueba la acciΓ³n.""" atype = action.get("action_type", "") param = float(action.get("parameter") or 1.0) if atype == "PAUSE": return "⚠️ *La campaΓ±a serΓ‘ pausada en Meta Ads*" if atype in ("INCREASE_BUDGET", "REDUCE_BUDGET"): pct = round((param - 1) * 100) if pct == 0: return "" # parameter not available, skip misleading text sign = "+" if pct >= 0 else "" if budget: new_b = round(budget * param, 2) return f"πŸ“Š Presupuesto diario: *{budget:.0f}€ β†’ {new_b:.0f}€* ({sign}{pct}%)" return f"πŸ“Š Ajuste de presupuesto: *{sign}{pct}%*" return "" def _post(method: str, **payload) -> dict: resp = requests.post( f"{_SLACK_API}/{method}", headers={"Authorization": f"Bearer {config.SLACK_BOT_TOKEN}"}, json=payload, timeout=10, ) return resp.json() def update_message(channel: str, ts: str, text: str): """Reemplaza un mensaje con texto plano tras aprobaciΓ³n/rechazo.""" _post("chat.update", channel=channel, ts=ts, text=text, blocks=[]) def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str: """Genera tabla monoespaciada de adsets o anuncios para Slack.""" if not items: return "" lines = [f"*{label}*"] lines.append("```") if show_bid: lines.append(f"{'Nombre':<32} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'Cap':>7}") lines.append("─" * 66) else: lines.append(f"{'Nombre':<32} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}") lines.append("─" * 58) for it in items: name = it["name"][:32] leads_str = f"{it['leads']:>5}" if it["leads"] > 0 else " β€”" cpl_str = f"{it['cpl']:>5.2f}€" if it["leads"] > 0 else " β€”" if show_bid: cost_cap = it.get("cost_cap_eur") cap_str = f" {cost_cap:>5.2f}€" if cost_cap else " Auto" else: cap_str = "" lines.append( f"{name:<32} {it['spend']:>5.0f}€ {leads_str} {cpl_str} {it['ctr']:>4.1f}%{cap_str}" ) lines.append("```") # Evaluations below the table for it in items: ev = it.get("evaluacion", "") rec = it.get("recomendacion", "") if ev or rec: lines.append(f"β€’ *{it['name'][:40]}*") if ev: lines.append(f" _{ev}_") if rec: lines.append(f" β†’ {rec}") return "\n".join(lines) def send_daily_report( daily_totals: list, best_campaigns: list, worst_campaigns: list, actions: list, target_cpl: float, campaigns_analyzed: int, mode: str = "DRY_RUN", verticals: dict = None, campaign_details: dict = None, ) -> str | None: """EnvΓ­a el informe diario consolidado. Devuelve el ts del mensaje.""" now = datetime.now() date_label = now.strftime("%d/%m/%Y") month_name = now.strftime("%B %Y").capitalize() prefix = config.META_CAMPAIGN_PREFIX mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIΓ“N" target_str = f"{target_cpl:.2f}€" if target_cpl > 0 else "β€”" blocks: list = [ { "type": "header", "text": {"type": "plain_text", "text": f"Meta Optimizer β€” {prefix} β€” {date_label} ({mode_label})"}, }, ] # ── Rentabilidad mensual con margen ─────────────────────────────────────── if daily_totals: lines = [f"{'DΓ­a':<5} {'Gasto':>7} {'Leads':>5} {'CPL':>7} {'Margen':>8} Est"] lines.append("─" * 42) total_spend = total_leads = total_margin = 0.0 for d in daily_totals: day = d["date"][8:10] + "/" + d["date"][5:7] margin = d.get("margin", 0.0) total_spend += d["spend"] total_leads += d["leads"] total_margin += margin icon = "βœ…" if d["leads"] > 0 else ("❌" if d["spend"] > 0 else "β€”") m_sign = f"+{margin:.0f}€" if margin >= 0 else f"{margin:.0f}€" lines.append( f"{day:<5} {d['spend']:>6.0f}€ {d['leads']:>5} {d['cpl']:>6.2f}€ {m_sign:>8} {icon}" ) lines.append("─" * 42) total_cpl = round(total_spend / total_leads, 2) if total_leads > 0 else 0.0 m_tot_sign = f"+{total_margin:.0f}€" if total_margin >= 0 else f"{total_margin:.0f}€" lines.append( f"{'TOTAL':<5} {total_spend:>6.0f}€ {int(total_leads):>5} {total_cpl:>6.2f}€ {m_tot_sign:>8}" ) blocks.append({ "type": "section", "text": { "type": "mrkdwn", "text": f"*Rentabilidad {month_name}*\n```" + "\n".join(lines) + "```", }, }) else: blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": "_Sin datos del mes en curso aΓΊn._"}, }) # ── Resumen por vertical ────────────────────────────────────────────────── if verticals: blocks.append({"type": "divider"}) lines = [f"{'Vertical':<16} {'Gasto':>7} {'Leads':>5} {'CPL':>7} {'Obj':>7} {'Margen':>9}"] lines.append("─" * 57) for v, data in sorted(verticals.items(), key=lambda x: -x[1]["margin"]): v_leads = data["leads"] v_spend = data["spend"] v_cpl = round(v_spend / v_leads, 2) if v_leads > 0 else 0.0 v_m = data["margin"] v_obj = data.get("target_cpl", 0) m_sign = f"+{v_m:.0f}€" if v_m >= 0 else f"{v_m:.0f}€" obj_str = f"{v_obj:.2f}€" if v_obj else " β€”" lines.append(f"{v:<16} {v_spend:>6.0f}€ {v_leads:>5} {v_cpl:>6.2f}€ {obj_str:>7} {m_sign:>9}") blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": "*Resumen por vertical (ayer)*\n```" + "\n".join(lines) + "```"}, }) blocks.append({"type": "divider"}) # ── Top 10 mejores ──────────────────────────────────────────────────────── if best_campaigns: lines = [] for i, m in enumerate(best_campaigns, 1): lines.append( f"{i}. *{m['name'][:46]}* CPL: {m['cpl']:.2f}€ | {m['leads']} leads | {m['spend']:.0f}€" ) blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": "*Top 10 mejores (ayer)*\n" + "\n".join(lines)}, }) blocks.append({"type": "divider"}) # ── Top 10 peores ───────────────────────────────────────────────────────── if worst_campaigns: lines = [] for i, m in enumerate(worst_campaigns, 1): if m["leads"] == 0: lines.append(f"{i}. *{m['name'][:46]}* 0 leads | {m['spend']:.0f}€ gastado") else: lines.append( f"{i}. *{m['name'][:46]}* CPL: {m['cpl']:.2f}€ | {m['leads']} leads | {m['spend']:.0f}€" ) blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": "*Top 10 peores (ayer)*\n" + "\n".join(lines)}, }) blocks.append({"type": "divider"}) # ── AnΓ‘lisis por campaΓ±a: decisiΓ³n + adsets + anuncios ──────────────────── if campaign_details: blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": f"*AnΓ‘lisis por campaΓ±a ({campaigns_analyzed} activas ayer)*"}, }) # Build lookup: campaign_name β†’ action info action_map = {a["campaign_name"]: a for a in actions} for cid, detail in campaign_details.items(): name = detail["name"] vertical = detail["vertical"] margin = detail["margin"] adsets = detail.get("adsets", []) ads = detail.get("ads", []) bid_cfg = detail.get("bid_config", {}) m_str = f"+{margin:.2f}€" if margin >= 0 else f"{margin:.2f}€" action = action_map.get(name) strategy = bid_cfg.get("bid_strategy", "") strategy_label = _STRATEGY_LABELS.get(strategy, strategy or "β€”") budget = bid_cfg.get("daily_budget_eur") budget_str = f"{budget:.0f}€/dΓ­a" if budget else "β€”" # Icono y etiqueta de acciΓ³n atype = action["action_type"] if action else "MAINTAIN" emoji, alabel = _ACTION_DISPLAY.get(atype, ("βšͺ", atype)) # Campaign header header_text = ( f"{emoji} *{name}*\n" f"Vertical: _{vertical}_ | Margen: *{m_str}* | " f"Estrategia: `{strategy_label}` | Presupuesto: {budget_str}\n" f"*DecisiΓ³n: {alabel}*" ) if action: justification = action.get("justification", "")[:500] header_text += f"\n_{justification}_" if action.get("alert"): header_text += f"\n:warning: {action['alert']}" blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": header_text}, }) # Botones solo para acciones que ejecutan algo en Meta API if action and atype in _ACTIONABLE: effect = _effect_text(action, budget) if effect: blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": effect}, }) blocks.append({ "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "βœ… Aprobar"}, "style": "primary", "value": f"approve:{action['row_id']}", "action_id": f"approve_{action['row_id']}", }, { "type": "button", "text": {"type": "plain_text", "text": "❌ Rechazar"}, "style": "danger", "value": f"reject:{action['row_id']}", "action_id": f"reject_{action['row_id']}", }, ], }) # Tabla adsets + anuncios detail_parts = [] adset_table = _adset_ad_table(adsets, "Conjuntos de anuncios", show_bid=True) ad_table = _adset_ad_table(ads, "Anuncios") if adset_table: detail_parts.append(adset_table) if ad_table: detail_parts.append(ad_table) if detail_parts: combined = "\n\n".join(detail_parts) # Split into chunks if too long (Slack block text limit: 3000 chars) for chunk in [combined[i:i+2900] for i in range(0, len(combined), 2900)]: blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": chunk}, }) blocks.append({"type": "divider"}) if len(blocks) >= 45: # Safety margin before Slack's 50-block limit blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": "_... mΓ‘s campaΓ±as disponibles en Baserow._"}, }) break # ── Pie ─────────────────────────────────────────────────────────────────── blocks.append({ "type": "context", "elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campaΓ±as analizadas ayer"}], }) result = _post( "chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=blocks, text=f"Meta Optimizer β€” {prefix} β€” {date_label}", ) return result.get("ts") if result.get("ok") else None def send_execution_summary(log: dict): """Resumen plano de ejecuciΓ³n (fallback).""" mode_label = "DRY RUN" if log.get("mode") == "DRY_RUN" else "PRODUCCIΓ“N" text = ( f":bar_chart: *Meta Optimizer β€” Resumen diario* ({mode_label})\n" f"β€’ CampaΓ±as analizadas: {log.get('campaigns_analyzed', 0)}\n" f"β€’ Acciones propuestas: {log.get('actions_proposed', 0)}\n" f"β€’ Acciones ejecutadas: {log.get('actions_executed', 0)}\n" f"β€’ DuraciΓ³n: {log.get('duration_seconds', 0):.1f}s" ) _post("chat.postMessage", channel=config.SLACK_CHANNEL_ID, text=text)