- Migrate from Airtable to Baserow: BaserowClient with snapshots, actions, creatives, logs - Claude agents (Haiku for decisions/units, Sonnet for creatives) with cost_cap_eur vs CPL comparison - Slack bot with colored action emojis, effect text before approval buttons, 500-char justifications - Streamlit dashboard with date-range navigation, campaign drill-down (adsets/ads), Histórico tab - Approval server (FastAPI + ngrok) for Slack button callbacks - backfill.py for historical snapshot regeneration with Claude re-analysis - Margin fix: 0-lead campaigns contribute -spend (not 0) to margin - CTR fix: Meta returns CTR as percentage already, removed *100 - Parameter fix: pass decision parameter to Slack action for correct budget effect display Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
341 lines
14 KiB
Python
341 lines
14 KiB
Python
"""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)
|