meta-optimizer-formacion/approval_server.py
José Manuel Gómez 921000c47e Make approval buttons project-aware for the shared Slack app
meta-optimizer (Viviful) and this project share one Slack bot/app, but a
Slack app only supports a single Interactivity Request URL — so a single
approval_server must be able to tell which project's Baserow a button click
belongs to. Button values now carry a project tag ("approve:formacion:<id>"
instead of bare "approve:<id>"); approval_server.py parses it and routes
accordingly, with a registry slot to add other projects (e.g. "viviful") via
their Baserow REST credentials without importing their code.

Confirmed via api.slack.com/apps that the current Interactivity Request URL
points at a dead ngrok tunnel (ERR_NGROK_3200) — no approval server is
actually reachable in production today, for either project. Deployment is
being handled separately as part of the general app rollout.
2026-07-09 15:10:30 +02:00

143 lines
5.0 KiB
Python

"""
FastAPI server that receives Slack interactive button callbacks (Approve / Reject).
El bot de Slack (meta_optimizer) es COMPARTIDO con meta-optimizer (Viviful) —
una app de Slack solo admite una Interactivity Request URL, así que este
servidor debe poder enrutar por proyecto en vez de asumir que todo lo que
recibe es de Formación. El valor del botón lleva el proyecto: p.ej.
"approve:formacion:42" (formato antiguo sin proyecto "approve:42" se trata
como "formacion" por compatibilidad).
Para que este servidor también gestione los botones de Viviful, añade sus
credenciales de Baserow a _PROJECTS (BASEROW_URL/TOKEN/TABLE_ACTIONS de ese
proyecto) — no requiere importar código de ese repo, solo habla su misma API
REST de Baserow.
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 datetime import datetime
import requests
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()
# Proyecto por defecto para valores de botón sin tag (formato antiguo/legado).
DEFAULT_PROJECT = "formacion"
# Otros proyectos que comparten este mismo servidor de aprobación. Formación
# usa el BaserowClient local (arriba); cualquier otro proyecto se resuelve
# por REST directo contra su propia instancia/tabla de Baserow, sin depender
# de su código. Añadir aquí, p.ej., "viviful" si se decide unificar con
# meta-optimizer en vez de desplegar un servidor separado para cada uno.
_EXTERNAL_PROJECTS: dict = {
# "viviful": {
# "baserow_url": "https://baserow.roiserver.com",
# "baserow_token": "...",
# "table_actions": 0,
# },
}
def _update_action_status(project: str, row_id: int, status: str) -> None:
if project == "formacion":
baserow.update_action_status(row_id, status)
return
cfg = _EXTERNAL_PROJECTS.get(project)
if not cfg:
raise HTTPException(status_code=400, detail=f"Unknown project tag: {project}")
data = {"status": status}
if status == "executed":
data["executed_at"] = datetime.now().strftime("%Y-%m-%d")
url = f"{cfg['baserow_url'].rstrip('/')}/api/database/rows/table/{cfg['table_actions']}/{row_id}/"
resp = requests.patch(
url,
headers={"Authorization": f"Token {cfg['baserow_token']}", "Content-Type": "application/json"},
params={"user_field_names": "true"},
json=data,
timeout=15,
)
resp.raise_for_status()
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:formacion:42" (o legado "approve:42")
channel = payload.get("channel", {}).get("id", config.SLACK_CHANNEL_ID)
message_ts = payload.get("message", {}).get("ts")
parts = value.split(":")
try:
if len(parts) == 3:
verb, project, row_id_str = parts
elif len(parts) == 2:
verb, row_id_str = parts
project = DEFAULT_PROJECT
else:
raise ValueError
row_id = int(row_id_str)
except ValueError:
raise HTTPException(status_code=400, detail=f"Unexpected action value: {value}")
if verb == "approve":
_update_action_status(project, row_id, "approved")
status_text = "aprobada"
elif verb == "reject":
_update_action_status(project, 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})