"""Slack notifier — Web API (Bot Token) con botones interactivos. Agrupa por Familia (campo de Airtable/Cursos) en vez de por vertical, y muestra PPL/capping mensual en vez de un CPL objetivo único: cada curso tiene su propio PPL, así que ya no hay un número de "objetivo" comparable entre campañas de la misma familia. """ from datetime import datetime import unicodedata import requests import config def _table_name(name: str, width: int) -> str: """Strip diacritics so accented chars don't break Slack's monospace alignment.""" normalized = unicodedata.normalize("NFKD", name) ascii_name = "".join(c for c in normalized if not unicodedata.combining(c)) return ascii_name[:width] _SLACK_API = "https://slack.com/api" # El bot de Slack es compartido con meta-optimizer (Viviful) — solo puede haber # UNA Interactivity Request URL por app, así que un mismo approval_server debe # poder distinguir a qué proyecto pertenece cada acción. El valor del botón # incluye este tag: "approve:formacion:" en vez de "approve:". PROJECT_TAG = "formacion" def _btn_value(verb: str, row_id) -> str: return f"{verb}:{PROJECT_TAG}:{row_id}" _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"), "MAINTAIN": ("✅", "MANTENER"), } # Solo estas acciones ejecutan algo real en Meta API → botones _ACTIONABLE = {"INCREASE_BUDGET", "REDUCE_BUDGET", "PAUSE"} _URGENCIA_EMOJI = { "PAUSAR": "⛔", "SPRINT": "🚨", "ACELERAR": "🟢", "FRENAR": "🔴", "EN_RITMO": "✅", } 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: # Las tablas monoespaciadas usan abreviaturas tipo "CPL.AT" que Slack puede # confundir con un dominio (.AT = Austria) y expandir como link preview # (ver caso real: "CPL.AT" → unfurl de cpl.at). Desactivar unfurl siempre, # como defensa adicional a renombrar las abreviaturas problemáticas. if method == "chat.postMessage": payload.setdefault("unfurl_links", False) payload.setdefault("unfurl_media", False) resp = requests.post( f"{_SLACK_API}/{method}", headers={"Authorization": f"Bearer {config.SLACK_BOT_TOKEN}"}, json=payload, timeout=10, ) data = resp.json() if not data.get("ok"): raise RuntimeError(f"Slack {method} error: {data.get('error')} — {data.get('response_metadata', {}).get('messages', '')}") return data 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, show_7d: bool = False) -> str: """Genera tabla monoespaciada de adsets o anuncios para Slack.""" if not items: return "" lines = [f"*{label}*"] lines.append("```") if show_7d: lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL(3d)':>8} {'CPL(7d)':>8} {'CTR':>5}") lines.append("─" * 82) elif show_bid: lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'PPL':>6} {'Margen':>8} {'Cap':>7}") lines.append("─" * 95) else: lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}") lines.append("─" * 71) for it in items: name = _table_name(it["name"], 45) leads_str = f"{it['leads']:>5}" if it["leads"] > 0 else " —" if show_7d: cpl_3d = it.get("cpl_3d", 0.0) cpl_7d = it.get("cpl_7d", it.get("cpl", 0.0)) cpl_3d_str = f"{cpl_3d:>6.2f}€" if cpl_3d > 0 else " —" cpl_7d_str = f"{cpl_7d:>6.2f}€" if cpl_7d > 0 else " —" lines.append( f"{name:<45} {it['spend']:>5.0f}€ {leads_str} {cpl_3d_str:>8} {cpl_7d_str:>8} {it['ctr']:>4.1f}%" ) elif show_bid: cpl_str = f"{it['cpl']:>5.2f}€" if it["leads"] > 0 else " —" cost_cap = it.get("cost_cap_eur") cap_str = f"{cost_cap:>6.2f}€" if cost_cap else " Auto" ppl = it.get("ppl", 0) or 0 margin = it["leads"] * ppl - it["spend"] ppl_str = f"{ppl:>5.2f}€" if ppl else " —" margin_str = f"{margin:>+7.2f}€" if ppl else " —" lines.append( f"{name:<45} {it['spend']:>5.0f}€ {leads_str} {cpl_str} {it['ctr']:>4.1f}% " f"{ppl_str} {margin_str} {cap_str}" ) else: cpl_str = f"{it['cpl']:>5.2f}€" if it["leads"] > 0 else " —" lines.append( f"{name:<45} {it['spend']:>5.0f}€ {leads_str} {cpl_str} {it['ctr']:>4.1f}%" ) lines.append("```") return "\n".join(lines) def _marg(v: float) -> str: v_int = round(v) return ("+" if v_int >= 0 else "") + f"{v_int:,.0f}€".replace(",", ".") def _pct(v: float) -> str: return ("+" if v >= 0 else "") + f"{v:.1f}%" def _curso_label(cs: dict, width: int = 26) -> str: names = cs.get("campaigns", []) if not names: return "?" label = names[0] if len(names) > 1: label += f" (+{len(names) - 1})" return _table_name(label, width) CURSO_TABLE_TOP_N = 25 def _curso_summary_blocks(curso_summary: dict) -> list[dict]: """ Resumen y contraste por curso: PPL, CPL según Meta vs según Airtable, y el desglose de leads de Airtable por vía (LF=leadform nativo, Land=landing page) — ambas 100% atribuibles a Meta, confirmado con datos reales (fbclid en todos los 'landingpage', cero 'gclid'). Δ = leads_meta - leads_airtable_total (discrepancia de tracking, +N = Meta ve más que Airtable). """ if not curso_summary: return [] rows = sorted(curso_summary.items(), key=lambda kv: -kv[1]["spend"]) overflow = max(0, len(rows) - CURSO_TABLE_TOP_N) rows = rows[:CURSO_TABLE_TOP_N] hdr = f"{'Cod':>5} {'Curso':<26} {'PPL':>6} {'L·Meta':>6} {'L·LF':>5} {'L·Land':>6} {'L·AT':>5} {'CPL·Meta':>8} {'CPL·AT':>7} {'Δ':>4}" sep = "─" * len(hdr) lines = [hdr, sep] for cursoid, cs in rows: label = _curso_label(cs) cpl_meta_s = f"{cs['cpl_meta']:.2f}€" if cs["cpl_meta"] else " —" cpl_at_s = f"{cs['cpl_at']:.2f}€" if cs["cpl_at"] else " —" lines.append( f"{cursoid:>5} {label:<26} {cs['ppl']:>5.2f}€ {cs['leads_meta']:>6} " f"{cs['leads_at_leadform']:>5} {cs['leads_at_landing']:>6} {cs['leads_at_total']:>5} " f"{cpl_meta_s:>8} {cpl_at_s:>7} {cs['discrepancia']:>+4}" ) footer = f"\n_+{overflow} cursos más con menos gasto, no mostrados_" if overflow else "" text = ( "*Resumen y contraste por curso — Meta vs Airtable* " "_(LF=leadform nativo · Land=landing page, ambas atribuibles a Meta · " "Δ=leads_meta−leads_airtable)_\n```\n" + "\n".join(lines) + "\n```" + footer ) return [{"type": "section", "text": {"type": "mrkdwn", "text": chunk}} for chunk in [text[j:j + 2900] for j in range(0, len(text), 2900)]] def _daily_table_block(daily_totals: list, month_name: str) -> dict | None: """Tabla diaria Leads Airtable vs Leads Meta, coste, ingreso×PPL de cada fuente, y margen (calculado sobre el tracking propio de Meta, igual convención que leads-optimizer usa con Google Ads; Airtable se muestra en paralelo para contrastar discrepancias de tracking, no sustituye el margen oficial).""" if not daily_totals: return None hdr = f"{'Día':<5} {'L·AT':>5} {'L·Meta':>6} {'Coste':>7} {'€·AT':>7} {'€·Meta':>7} {'Margen':>9} {'%':>7}" sep = "─" * len(hdr) lines = [hdr, sep] tot = {"spend": 0.0, "leads_at": 0, "leads_meta": 0, "ing_at": 0.0, "ing_meta": 0.0} for d in daily_totals: day = d["date"][8:10] + "/" + d["date"][5:7] for k in tot: tot[k] += d.get(k, 0) lines.append( f"{day:<5} {d['leads_at']:>5} {d['leads_meta']:>6} " f"{d['spend']:>6.0f}€ {d['ing_at']:>6.0f}€ {d['ing_meta']:>6.0f}€ " f"{_marg(d['margin']):>9} {_pct(d['margin_pct']):>7}" ) lines.append(sep) tot_margin = tot["ing_meta"] - tot["spend"] tot_pct = round(tot_margin / tot["ing_meta"] * 100, 1) if tot["ing_meta"] > 0 else 0.0 lines.append( f"{'TOT':<5} {tot['leads_at']:>5} {tot['leads_meta']:>6} " f"{tot['spend']:>6.0f}€ {tot['ing_at']:>6.0f}€ {tot['ing_meta']:>6.0f}€ " f"{_marg(tot_margin):>9} {_pct(tot_pct):>7}" ) text = ( f"*Métricas por día — {month_name}* " "_(L·AT=leads Airtable · L·Meta=conversión propia Meta · €·AT/€·Meta=leads×PPL de cada fuente)_\n" "```\n" + "\n".join(lines) + "\n```" ) return {"type": "section", "text": {"type": "mrkdwn", "text": text}} def send_daily_report( daily_totals: list, best_campaigns: list, worst_campaigns: list, actions: list, campaigns_analyzed: int, mode: str = "DRY_RUN", campaign_details: dict = None, curso_summary: dict = None, portfolio_analysis_text: str = None, ) -> str | None: """Envía el informe diario consolidado (un único bloque de Formación, sin agrupar por familia). Devuelve el ts del primer mensaje.""" now = datetime.now() date_label = now.strftime("%d/%m/%Y") month_name = now.strftime("%B %Y").capitalize() mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIÓN" action_map = {a["campaign_name"]: a for a in actions} details_map = campaign_details or {} campaigns = [(cid, detail, action_map.get(detail["name"])) for cid, detail in details_map.items()] # ── Message 1: Dashboard ───────────────────────────────────────────────── blocks: list = [ { "type": "header", "text": {"type": "plain_text", "text": f"Meta Optimizer Formación — {date_label} ({mode_label})"}, }, ] if daily_totals: tot_spend = sum(d["spend"] for d in daily_totals) tot_leads_m = sum(d["leads_meta"] for d in daily_totals) tot_leads_at = sum(d["leads_at"] for d in daily_totals) tot_ing_m = sum(d["ing_meta"] for d in daily_totals) tot_ing_at = sum(d["ing_at"] for d in daily_totals) margen_m = tot_ing_m - tot_spend margen_at = tot_ing_at - tot_spend pct_m = round(margen_m / tot_ing_m * 100, 1) if tot_ing_m > 0 else 0.0 pct_at = round(margen_at / tot_ing_at * 100, 1) if tot_ing_at > 0 else 0.0 blocks.append({ "type": "section", "text": { "type": "mrkdwn", "text": ( f"📊 *RESUMEN {month_name.upper()}*\n" f"Inversión: *{tot_spend:,.0f}€* | Leads Meta: *{int(tot_leads_m)}* | " f"Leads Airtable: *{int(tot_leads_at)}*\n" f"Ingreso según Meta: *{tot_ing_m:,.0f}€* | Margen: *{_marg(margen_m)}* ({_pct(pct_m)})\n" f"Ingreso según Airtable: *{tot_ing_at:,.0f}€* | Margen: *{_marg(margen_at)}* ({_pct(pct_at)})\n" f"_El margen oficial usa el tracking propio de Meta; Airtable se muestra en paralelo " f"para contrastar discrepancias de tracking._" ).replace(",", "."), }, }) blocks.append({"type": "divider"}) daily_block = _daily_table_block(daily_totals, month_name) if daily_block: blocks.append(daily_block) else: blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": "_Sin datos del mes en curso aún._"}, }) blocks.append({ "type": "context", "elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campañas analizadas"}], }) result = _post( "chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=blocks, text=f"Meta Optimizer Formación — {date_label}", ) ts = result.get("ts") # ── Message 2: resumen y contraste por curso ─────────────────────────────── curso_blocks = _curso_summary_blocks(curso_summary or {}) if curso_blocks: _post("chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=curso_blocks, text="Resumen y contraste por curso") # ── Message 3: diagnóstico estratégico ────────────────────────────────────── if portfolio_analysis_text: _post( "chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=[ {"type": "header", "text": {"type": "plain_text", "text": "🤖 Diagnóstico estratégico"}}, {"type": "section", "text": {"type": "mrkdwn", "text": portfolio_analysis_text[:2950]}}, ], text="Diagnóstico estratégico", ) # ── Mensajes 4..N: tarjetas por campaña, en lotes (sin agrupar por familia) ─ def _has_pause_ads(detail): return any(ad.get("accion") == "PAUSE" and ad.get("row_id") for ad in detail.get("ads", [])) def _priority_key(item): _, detail, act = item urgencia = detail.get("urgencia", "EN_RITMO") atype = act["action_type"] if act else "MAINTAIN" if urgencia in ("PAUSAR", "SPRINT"): p = 0 elif atype != "MAINTAIN" or _has_pause_ads(detail): p = 1 else: p = 2 return (p, -detail.get("spend_1d", 0)) sorted_campaigns = sorted(campaigns, key=_priority_key) BATCH_SIZE = 6 for batch_start in range(0, len(sorted_campaigns), BATCH_SIZE): batch = sorted_campaigns[batch_start:batch_start + BATCH_SIZE] c_blocks: list = [] for i, (cid, detail, act) in enumerate(batch): if i > 0: c_blocks.append({"type": "divider"}) name = detail["name"] familia = detail.get("familia", "") spend_1d = detail.get("spend_1d", 0.0) leads_1d = detail.get("leads_1d", 0) margin = detail["margin"] m_str2 = f"+{margin:.2f}€" if margin >= 0 else f"{margin:.2f}€" urgencia = detail.get("urgencia", "EN_RITMO") u_emoji = _URGENCIA_EMOJI.get(urgencia, "⚪") leads_mes = detail.get("leads_mes", 0) capping = detail.get("capping", 0) cap_str = f"{leads_mes}/{capping}" if capping else f"{leads_mes}/∞" adsets = detail.get("adsets", []) ads = detail.get("ads", []) bid_cfg = detail.get("bid_config", {}) budget = bid_cfg.get("daily_budget_eur") strategy = bid_cfg.get("bid_strategy", "") strat_label = _STRATEGY_LABELS.get(strategy, strategy or "—") atype = act["action_type"] if act else "MAINTAIN" cemoji, alabel = _ACTION_DISPLAY.get(atype, ("⚪", atype)) camp_text = ( f"{cemoji} *{name}*" + (f" _{familia}_" if familia and familia != "Sin familia" else "") + "\n" f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · " f"{u_emoji} {urgencia} · Cap mes: {cap_str}" + (f" · `{strat_label}`" if strategy else "") + (f" · {budget:.0f}€/día" if budget else "") ) if atype != "MAINTAIN": camp_text += f"\n*{alabel}*" if act and act.get("justification"): camp_text += f" — _{act['justification'][:160]}_" if act and act.get("advice"): camp_text += f"\n💡 {act['advice'][:160]}" if act and act.get("alert"): camp_text += f"\n:warning: {act['alert'][:130]}" c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}}) # Approve/Reject buttons if act and atype in _ACTIONABLE: effect = _effect_text(act, budget) if effect: c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}}) c_blocks.append({ "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "✅ Aprobar"}, "style": "primary", "value": _btn_value("approve", act["row_id"]), "action_id": f"approve_{PROJECT_TAG}_{act['row_id']}", }, { "type": "button", "text": {"type": "plain_text", "text": "❌ Rechazar"}, "style": "danger", "value": _btn_value("reject", act["row_id"]), "action_id": f"reject_{PROJECT_TAG}_{act['row_id']}", }, ], }) # Adsets: tabla + recomendación de cada uno if adsets: tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True) if tbl: for chunk in [tbl[j:j+2900] for j in range(0, len(tbl), 2900)]: c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}}) rec_lines = [ f"• _{_table_name(a['name'], 40)}_: {a['recomendacion'][:110]}" for a in adsets[:3] if a.get("recomendacion") ] if rec_lines: c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "*Recomendaciones (conjuntos):*\n" + "\n".join(rec_lines)}}) # Anuncios: recomendaciones de los marcados para pausa + botón pause_ads = [a for a in ads if a.get("accion") == "PAUSE" and a.get("row_id")] for ad in pause_ads: rec = ad.get("recomendacion") or "Sin leads en 7 días." c_blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": f"⛔ *{ad['name'][:80]}*\n{rec[:150]}"}, }) c_blocks.append({ "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "⛔ Pausar anuncio"}, "style": "danger", "value": _btn_value("approve", ad["row_id"]), "action_id": f"approve_{PROJECT_TAG}_{ad['row_id']}", }, { "type": "button", "text": {"type": "plain_text", "text": "❌ Rechazar"}, "value": _btn_value("reject", ad["row_id"]), "action_id": f"reject_{PROJECT_TAG}_{ad['row_id']}", }, ], }) _post( "chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=c_blocks, text=f"Campañas {batch_start + 1}–{batch_start + len(batch)} de {len(sorted_campaigns)}", ) return ts def _score_emoji(score: float) -> str: if score >= 8: return "🟢" if score >= 6: return "🟡" if score >= 4: return "🟠" return "🔴" def _brief_action(score: float, fatigue: bool) -> str: if score == 0: return "sin imagen" if fatigue: return "renovar urgente" if score >= 8: return "mantener" if score >= 6: return "optimizar" if score >= 4: return "renovar" return "reemplazar" def send_creative_analysis_report(all_results: dict) -> None: """Envía scorecard compacto de creatividades a Slack. Un mensaje por campaña.""" now = datetime.now() date_label = now.strftime("%d/%m/%Y %H:%M") total_ads = sum(len(as_d["ads"]) for c in all_results.values() for as_d in c["adsets"].values()) total_fatigue = sum( 1 for c in all_results.values() for as_d in c["adsets"].values() for ad in as_d["ads"] if ad.get("fatigue") ) scored = [ ad.get("score", 0) for c in all_results.values() for as_d in c["adsets"].values() for ad in as_d["ads"] if ad.get("score", 0) > 0 ] avg_score = round(sum(scored) / len(scored), 1) if scored else 0.0 summary = f"*{len(all_results)} campañas* · *{total_ads} anuncios* · score medio *{avg_score}/10*" if total_fatigue: summary += f"\n⚠️ *{total_fatigue} con fatiga creativa detectada*" _post( "chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=[ {"type": "header", "text": {"type": "plain_text", "text": f"Creatividades — {date_label}"}}, {"type": "section", "text": {"type": "mrkdwn", "text": summary}}, ], text=f"Creatividades — {date_label}", ) # ── One message per campaign ────────────────────────────────────────────── for cid, camp_data in all_results.items(): camp_name = camp_data["name"] adsets = camp_data.get("adsets", {}) if not adsets: continue def _flush(buf: list) -> list: if len(buf) > 1: try: _post("chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=buf, text=camp_name) except RuntimeError as e: print(f" [WARN] Slack: {e}") return [{"type": "header", "text": {"type": "plain_text", "text": f"{camp_name} (cont.)"}}] blocks: list = [{"type": "header", "text": {"type": "plain_text", "text": camp_name}}] for as_data in adsets.values(): adset_name = as_data["name"] ads = as_data["ads"] ads_sorted = sorted(ads, key=lambda x: -x.get("score", 0)) # Compact monospace table — one line per ad lines = [ f"*{adset_name}* _({len(ads)} anuncios)_", "```", f"{'Nombre':<33} {'Sc':>4} {'CTR':>5} {'CPL':>6} Acción", "─" * 63, ] for ad in ads_sorted: name = _table_name(ad["ad_name"], 33) score = ad.get("score", 0) ctr = ad.get("ctr_7d", 0) cpl = ad.get("cpl_7d", 0) fat = "⚠" if ad.get("fatigue") else " " action = _brief_action(score, ad.get("fatigue", False)) cpl_s = f"{cpl:.2f}€" if cpl > 0 else " —" sc_s = f"{score:.1f}" if score > 0 else " —" lines.append(f"{name:<33}{fat} {sc_s:>4} {ctr:>4.1f}% {cpl_s:>6} {action}") lines.append("```") winner = as_data.get("comparison", {}) or {} if winner.get("winner"): lines.append(f"🏆 _{winner['winner'][:70]}_") ab = [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}] if len(blocks) + len(ab) > 48: blocks = _flush(blocks) blocks.extend(ab) _flush(blocks) 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 Formación — 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)