"""Slack notifier β€” Web API (Bot Token) con botones interactivos.""" 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" _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, ) 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 _ad_action_blocks(ads: list) -> list: """Genera bloques Slack con botΓ³n de pausa para anuncios que Claude recomienda pausar.""" blocks = [] for ad in ads: if not ad.get("row_id"): continue name = ad["name"] ev = ad.get("evaluacion", "") rec = ad.get("recomendacion", "") text = f"β›” *{name[:55]}*" if ev: text += f"\n_{ev}_" if rec: text += f"\nβ†’ {rec}" text += "\n⚠️ *El anuncio serΓ‘ pausado en Meta Ads si se aprueba*" blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}}) blocks.append({ "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "β›” Pausar anuncio"}, "style": "danger", "value": f"approve:{ad['row_id']}", "action_id": f"approve_{ad['row_id']}", }, { "type": "button", "text": {"type": "plain_text", "text": "❌ Rechazar"}, "value": f"reject:{ad['row_id']}", "action_id": f"reject_{ad['row_id']}", }, ], }) return 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':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'Cap':>7}") lines.append("─" * 79) 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 " β€”" 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:<45} {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'][:55]}*") 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, monthly_verticals: 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}" ) # Margen mensual por vertical if monthly_verticals: mv_lines = ["", f"{'Vertical':<16} {'Gasto':>7} {'Leads':>5} {'CPL':>7} {'Obj':>5} {'Margen':>8}"] mv_lines.append("─" * 54) for v, mv in sorted(monthly_verticals.items(), key=lambda x: -x[1]["margin"]): v_cpl = round(mv["spend"] / mv["leads"], 2) if mv["leads"] > 0 else 0.0 m_sign = f"+{mv['margin']:.0f}€" if mv["margin"] >= 0 else f"{mv['margin']:.0f}€" obj = mv.get("target_cpl", 0) mv_lines.append( f"{v:<16} {mv['spend']:>6.0f}€ {mv['leads']:>5} {v_cpl:>6.2f}€ {obj:>4.1f}€ {m_sign:>8}" ) lines += mv_lines 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"}) blocks.append({ "type": "context", "elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campaΓ±as analizadas β€” detalle por campaΓ±a a continuaciΓ³n"}], }) # ── Enviar resumen ──────────────────────────────────────────────────────── result = _post( "chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=blocks, text=f"Meta Optimizer β€” {prefix} β€” {date_label}", ) ts = result.get("ts") # ── Un mensaje por campaΓ±a (sin lΓ­mite de bloques) ──────────────────────── if campaign_details: 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 "β€”" atype = action["action_type"] if action else "MAINTAIN" emoji, alabel = _ACTION_DISPLAY.get(atype, ("βšͺ", atype)) 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: header_text += f"\n_{action.get('justification', '')[:500]}_" if action.get("alert"): header_text += f"\n:warning: {action['alert']}" camp_blocks: list = [ {"type": "section", "text": {"type": "mrkdwn", "text": header_text}}, ] # Botones de acciΓ³n a nivel campaΓ±a if action and atype in _ACTIONABLE: effect = _effect_text(action, budget) if effect: camp_blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": effect}, }) camp_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 adset_table = _adset_ad_table(adsets, "Conjuntos de anuncios", show_bid=True) ad_table = _adset_ad_table(ads, "Anuncios") combined = "\n\n".join(filter(None, [adset_table, ad_table])) for chunk in [combined[i:i+2900] for i in range(0, len(combined), 2900)]: camp_blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": chunk}, }) # Botones de pausa por anuncio camp_blocks.extend(_ad_action_blocks(ads)) _post( "chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=camp_blocks, text=name, ) return ts 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)