meta-optimizer/approval_server.py
José Manuel Gómez 8fb0b69896 Add Baserow persistence, Slack interactive reports, Streamlit dashboard, and full analysis pipeline
- 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>
2026-06-07 09:30:13 +02:00

85 lines
2.6 KiB
Python

"""
FastAPI server that receives Slack interactive button callbacks (Approve / Reject).
Setup:
1. Create a Slack App, enable Interactivity, set Request URL to:
https://your-domain.com/slack/actions
2. Set SLACK_SIGNING_SECRET in your .env
3. Run: uvicorn approval_server:app --host 0.0.0.0 --port 3000
(for local dev: use ngrok to expose port 3000)
"""
import hashlib
import hmac
import json
import time
import urllib.parse
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import config
from baserow_client import BaserowClient
import slack_notifier
app = FastAPI()
baserow = BaserowClient()
def _verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
basestring = f"v0:{timestamp}:{body.decode()}"
computed = "v0=" + hmac.new(
config.SLACK_SIGNING_SECRET.encode(),
basestring.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(computed, signature)
@app.post("/slack/actions")
async def slack_actions(request: Request):
body = await request.body()
timestamp = request.headers.get("X-Slack-Request-Timestamp", "0")
signature = request.headers.get("X-Slack-Signature", "")
if not _verify_slack_signature(body, timestamp, signature):
raise HTTPException(status_code=401, detail="Invalid Slack signature")
form = urllib.parse.parse_qs(body.decode())
payload = json.loads(form.get("payload", ["{}"])[0])
actions = payload.get("actions", [])
if not actions:
return JSONResponse({"ok": True})
action = actions[0]
value = action.get("value", "") # "approve:42" or "reject:42"
channel = payload.get("channel", {}).get("id", config.SLACK_CHANNEL_ID)
message_ts = payload.get("message", {}).get("ts")
try:
verb, row_id_str = value.split(":", 1)
row_id = int(row_id_str)
except ValueError:
raise HTTPException(status_code=400, detail=f"Unexpected action value: {value}")
if verb == "approve":
baserow.update_action_status(row_id, "approved")
status_text = "aprobada"
elif verb == "reject":
baserow.update_action_status(row_id, "rejected")
status_text = "rechazada"
else:
raise HTTPException(status_code=400, detail=f"Unknown verb: {verb}")
if message_ts:
user = payload.get("user", {}).get("name", "unknown")
slack_notifier.update_message(
channel,
message_ts,
f"Accion {status_text} por {user}.",
)
return JSONResponse({"ok": True})