meta-optimizer/slack_notifier.py
José Manuel Gómez ac7a0a9738 Fix Slack block limit: send one message per campaign + error logging
- Refactor send_daily_report: summary in one message, each campaign in its own
  message — eliminates the 50-block Slack limit that was silently dropping campaigns
- _post now raises RuntimeError on Slack API errors (was silently returning None)
- send_slack_report.py accepts optional date argument (defaults to yesterday)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 23:04:51 +02:00

373 lines
15 KiB
Python

"""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,
) -> 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"})
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)