diff --git a/approval_server.py b/approval_server.py index 1f3c693..d3c6a83 100644 --- a/approval_server.py +++ b/approval_server.py @@ -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}") diff --git a/slack_notifier.py b/slack_notifier.py index d05f8c5..30d5a2c 100644 --- a/slack_notifier.py +++ b/slack_notifier.py @@ -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:" en vez de "approve:". +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']}", }, ], })