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.
143 lines
5.0 KiB
Python
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})
|