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>
This commit is contained in:
Jose Manuel 2026-06-13 23:04:51 +02:00
parent 6ec3342661
commit ac7a0a9738
2 changed files with 42 additions and 56 deletions

View File

@ -17,7 +17,8 @@ def _extract_vertical(name: str) -> str:
def main(): def main():
run_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") import sys as _sys
run_date = _sys.argv[1] if len(_sys.argv) > 1 else (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
print(f"Reenviando informe para {run_date}...") print(f"Reenviando informe para {run_date}...")
meta = MetaAdsClient() meta = MetaAdsClient()

View File

@ -57,7 +57,10 @@ def _post(method: str, **payload) -> dict:
json=payload, json=payload,
timeout=10, timeout=10,
) )
return resp.json() 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): def update_message(channel: str, ts: str, text: str):
@ -253,15 +256,23 @@ def send_daily_report(
}) })
blocks.append({"type": "divider"}) blocks.append({"type": "divider"})
# ── Análisis por campaña: decisión + adsets + anuncios ────────────────────
if campaign_details:
blocks.append({ blocks.append({
"type": "section", "type": "context",
"text": {"type": "mrkdwn", "elements": [{"type": "mrkdwn",
"text": f"*Análisis por campaña ({campaigns_analyzed} activas ayer)*"}, "text": f"{campaigns_analyzed} campañas analizadas — detalle por campaña a continuación"}],
}) })
# Build lookup: campaign_name → action info
# ── 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} action_map = {a["campaign_name"]: a for a in actions}
for cid, detail in campaign_details.items(): for cid, detail in campaign_details.items():
@ -279,11 +290,9 @@ def send_daily_report(
budget = bid_cfg.get("daily_budget_eur") budget = bid_cfg.get("daily_budget_eur")
budget_str = f"{budget:.0f}€/día" if budget else "" budget_str = f"{budget:.0f}€/día" if budget else ""
# Icono y etiqueta de acción
atype = action["action_type"] if action else "MAINTAIN" atype = action["action_type"] if action else "MAINTAIN"
emoji, alabel = _ACTION_DISPLAY.get(atype, ("", atype)) emoji, alabel = _ACTION_DISPLAY.get(atype, ("", atype))
# Campaign header
header_text = ( header_text = (
f"{emoji} *{name}*\n" f"{emoji} *{name}*\n"
f"Vertical: _{vertical}_ | Margen: *{m_str}* | " f"Vertical: _{vertical}_ | Margen: *{m_str}* | "
@ -291,25 +300,23 @@ def send_daily_report(
f"*Decisión: {alabel}*" f"*Decisión: {alabel}*"
) )
if action: if action:
justification = action.get("justification", "")[:500] header_text += f"\n_{action.get('justification', '')[:500]}_"
header_text += f"\n_{justification}_"
if action.get("alert"): if action.get("alert"):
header_text += f"\n:warning: {action['alert']}" header_text += f"\n:warning: {action['alert']}"
blocks.append({ camp_blocks: list = [
"type": "section", {"type": "section", "text": {"type": "mrkdwn", "text": header_text}},
"text": {"type": "mrkdwn", "text": header_text}, ]
})
# Botones solo para acciones que ejecutan algo en Meta API # Botones de acción a nivel campaña
if action and atype in _ACTIONABLE: if action and atype in _ACTIONABLE:
effect = _effect_text(action, budget) effect = _effect_text(action, budget)
if effect: if effect:
blocks.append({ camp_blocks.append({
"type": "section", "type": "section",
"text": {"type": "mrkdwn", "text": effect}, "text": {"type": "mrkdwn", "text": effect},
}) })
blocks.append({ camp_blocks.append({
"type": "actions", "type": "actions",
"elements": [ "elements": [
{ {
@ -330,48 +337,26 @@ def send_daily_report(
}) })
# Tabla adsets + anuncios # Tabla adsets + anuncios
detail_parts = []
adset_table = _adset_ad_table(adsets, "Conjuntos de anuncios", show_bid=True) adset_table = _adset_ad_table(adsets, "Conjuntos de anuncios", show_bid=True)
ad_table = _adset_ad_table(ads, "Anuncios") ad_table = _adset_ad_table(ads, "Anuncios")
if adset_table: combined = "\n\n".join(filter(None, [adset_table, ad_table]))
detail_parts.append(adset_table)
if ad_table:
detail_parts.append(ad_table)
if detail_parts:
combined = "\n\n".join(detail_parts)
for chunk in [combined[i:i+2900] for i in range(0, len(combined), 2900)]: for chunk in [combined[i:i+2900] for i in range(0, len(combined), 2900)]:
blocks.append({ camp_blocks.append({
"type": "section", "type": "section",
"text": {"type": "mrkdwn", "text": chunk}, "text": {"type": "mrkdwn", "text": chunk},
}) })
# Botones de pausa por anuncio (solo los que Claude recomienda pausar) # Botones de pausa por anuncio
blocks.extend(_ad_action_blocks(ads)) camp_blocks.extend(_ad_action_blocks(ads))
blocks.append({"type": "divider"}) _post(
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", "chat.postMessage",
channel=config.SLACK_CHANNEL_ID, channel=config.SLACK_CHANNEL_ID,
blocks=blocks, blocks=camp_blocks,
text=f"Meta Optimizer — {prefix}{date_label}", text=name,
) )
return result.get("ts") if result.get("ok") else None
return ts
def send_execution_summary(log: dict): def send_execution_summary(log: dict):