""" 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})