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).
|
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:
|
Setup:
|
||||||
1. Create a Slack App, enable Interactivity, set Request URL to:
|
1. Create a Slack App, enable Interactivity, set Request URL to:
|
||||||
https://your-domain.com/slack/actions
|
https://your-domain.com/slack/actions
|
||||||
@ -13,7 +25,9 @@ import hmac
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
@ -24,6 +38,43 @@ import slack_notifier
|
|||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
baserow = BaserowClient()
|
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:
|
def _verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool:
|
||||||
if abs(time.time() - int(timestamp)) > 300:
|
if abs(time.time() - int(timestamp)) > 300:
|
||||||
@ -54,21 +105,28 @@ async def slack_actions(request: Request):
|
|||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
action = actions[0]
|
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)
|
channel = payload.get("channel", {}).get("id", config.SLACK_CHANNEL_ID)
|
||||||
message_ts = payload.get("message", {}).get("ts")
|
message_ts = payload.get("message", {}).get("ts")
|
||||||
|
|
||||||
|
parts = value.split(":")
|
||||||
try:
|
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)
|
row_id = int(row_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail=f"Unexpected action value: {value}")
|
raise HTTPException(status_code=400, detail=f"Unexpected action value: {value}")
|
||||||
|
|
||||||
if verb == "approve":
|
if verb == "approve":
|
||||||
baserow.update_action_status(row_id, "approved")
|
_update_action_status(project, row_id, "approved")
|
||||||
status_text = "aprobada"
|
status_text = "aprobada"
|
||||||
elif verb == "reject":
|
elif verb == "reject":
|
||||||
baserow.update_action_status(row_id, "rejected")
|
_update_action_status(project, row_id, "rejected")
|
||||||
status_text = "rechazada"
|
status_text = "rechazada"
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown verb: {verb}")
|
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"
|
_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 = {
|
_STRATEGY_LABELS = {
|
||||||
"LOWEST_COST_WITHOUT_CAP": "Menor coste",
|
"LOWEST_COST_WITHOUT_CAP": "Menor coste",
|
||||||
"LOWEST_COST_WITH_BID_CAP": "Cap. puja",
|
"LOWEST_COST_WITH_BID_CAP": "Cap. puja",
|
||||||
@ -401,15 +411,15 @@ def send_daily_report(
|
|||||||
"type": "button",
|
"type": "button",
|
||||||
"text": {"type": "plain_text", "text": "✅ Aprobar"},
|
"text": {"type": "plain_text", "text": "✅ Aprobar"},
|
||||||
"style": "primary",
|
"style": "primary",
|
||||||
"value": f"approve:{act['row_id']}",
|
"value": _btn_value("approve", act["row_id"]),
|
||||||
"action_id": f"approve_{act['row_id']}",
|
"action_id": f"approve_{PROJECT_TAG}_{act['row_id']}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "button",
|
"type": "button",
|
||||||
"text": {"type": "plain_text", "text": "❌ Rechazar"},
|
"text": {"type": "plain_text", "text": "❌ Rechazar"},
|
||||||
"style": "danger",
|
"style": "danger",
|
||||||
"value": f"reject:{act['row_id']}",
|
"value": _btn_value("reject", act["row_id"]),
|
||||||
"action_id": f"reject_{act['row_id']}",
|
"action_id": f"reject_{PROJECT_TAG}_{act['row_id']}",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@ -443,14 +453,14 @@ def send_daily_report(
|
|||||||
"type": "button",
|
"type": "button",
|
||||||
"text": {"type": "plain_text", "text": "⛔ Pausar anuncio"},
|
"text": {"type": "plain_text", "text": "⛔ Pausar anuncio"},
|
||||||
"style": "danger",
|
"style": "danger",
|
||||||
"value": f"approve:{ad['row_id']}",
|
"value": _btn_value("approve", ad["row_id"]),
|
||||||
"action_id": f"approve_{ad['row_id']}",
|
"action_id": f"approve_{PROJECT_TAG}_{ad['row_id']}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "button",
|
"type": "button",
|
||||||
"text": {"type": "plain_text", "text": "❌ Rechazar"},
|
"text": {"type": "plain_text", "text": "❌ Rechazar"},
|
||||||
"value": f"reject:{ad['row_id']}",
|
"value": _btn_value("reject", ad["row_id"]),
|
||||||
"action_id": f"reject_{ad['row_id']}",
|
"action_id": f"reject_{PROJECT_TAG}_{ad['row_id']}",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user