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:
parent
788c00256f
commit
921000c47e
@ -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}")
|
||||
|
||||
@ -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']}",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user