meta-optimizer-formacion/approval_server.py
José Manuel Gómez 9239e2f67f Initial scaffold: Meta Optimizer for RoiFormacion campaigns
Ports meta-optimizer's Meta Ads execution/approval/creative-analysis layer
(agent.py, meta_ads_client.py, baserow_client.py, slack_notifier.py,
approval_server.py) and replaces the per-vertical CPL model with the
PPL + monthly-capping-per-course model already used by leads-optimizer,
via a new airtable_client.py that shares Cursos/Familias/CentroCurso/
CursoMes/Leads Lake with that project and adds Meta Ads Campaigns /
MetaCampaignMes alongside its Google Ads Campaigns / GACampaignMes.
2026-07-07 16:53:03 +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})