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.
This commit is contained in:
Jose Manuel 2026-07-09 15:10:30 +02:00
parent 788c00256f
commit 921000c47e
2 changed files with 80 additions and 12 deletions

View File

@ -1,6 +1,18 @@
"""
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
@ -13,7 +25,9 @@ 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
@ -24,6 +38,43 @@ 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:
@ -54,21 +105,28 @@ async def slack_actions(request: Request):
return JSONResponse({"ok": True})
action = actions[0]
value = action.get("value", "") # "approve:42" or "reject:42"
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:
verb, row_id_str = value.split(":", 1)
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":
baserow.update_action_status(row_id, "approved")
_update_action_status(project, row_id, "approved")
status_text = "aprobada"
elif verb == "reject":
baserow.update_action_status(row_id, "rejected")
_update_action_status(project, row_id, "rejected")
status_text = "rechazada"
else:
raise HTTPException(status_code=400, detail=f"Unknown verb: {verb}")

View File

@ -19,6 +19,16 @@ def _table_name(name: str, width: int) -> str:
_SLACK_API = "https://slack.com/api"
# El bot de Slack es compartido con meta-optimizer (Viviful) — solo puede haber
# UNA Interactivity Request URL por app, así que un mismo approval_server debe
# poder distinguir a qué proyecto pertenece cada acción. El valor del botón
# incluye este tag: "approve:formacion:<row_id>" en vez de "approve:<row_id>".
PROJECT_TAG = "formacion"
def _btn_value(verb: str, row_id) -> str:
return f"{verb}:{PROJECT_TAG}:{row_id}"
_STRATEGY_LABELS = {
"LOWEST_COST_WITHOUT_CAP": "Menor coste",
"LOWEST_COST_WITH_BID_CAP": "Cap. puja",
@ -401,15 +411,15 @@ def send_daily_report(
"type": "button",
"text": {"type": "plain_text", "text": "✅ Aprobar"},
"style": "primary",
"value": f"approve:{act['row_id']}",
"action_id": f"approve_{act['row_id']}",
"value": _btn_value("approve", act["row_id"]),
"action_id": f"approve_{PROJECT_TAG}_{act['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"style": "danger",
"value": f"reject:{act['row_id']}",
"action_id": f"reject_{act['row_id']}",
"value": _btn_value("reject", act["row_id"]),
"action_id": f"reject_{PROJECT_TAG}_{act['row_id']}",
},
],
})
@ -443,14 +453,14 @@ def send_daily_report(
"type": "button",
"text": {"type": "plain_text", "text": "⛔ Pausar anuncio"},
"style": "danger",
"value": f"approve:{ad['row_id']}",
"action_id": f"approve_{ad['row_id']}",
"value": _btn_value("approve", ad["row_id"]),
"action_id": f"approve_{PROJECT_TAG}_{ad['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"value": f"reject:{ad['row_id']}",
"action_id": f"reject_{ad['row_id']}",
"value": _btn_value("reject", ad["row_id"]),
"action_id": f"reject_{PROJECT_TAG}_{ad['row_id']}",
},
],
})