Add interactif slack message

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-10-20 17:22:02 +02:00
parent de004ce8d7
commit 0073846d3b
23 changed files with 1833 additions and 173 deletions

View File

@@ -110,6 +110,8 @@ func NewMux(
r.Post("/auth/authenticate", authTokenHandler(trustSvc, trustAuthCfg))
r.Delete("/auth/logout", trustCenterLogoutHandler(authCfg, trustAuthCfg))
r.Post("/slack", slackHandler(trustSvc, trustSvc.GetSlackSigningSecret(), logger))
return r
}

View File

@@ -0,0 +1,238 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package trust_v1
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/slack"
"github.com/getprobo/probo/pkg/trust"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
)
type (
SlackInteractivePayload struct {
ResponseURL string `json:"response_url"`
Actions []struct {
ActionID string `json:"action_id"`
Value string `json:"value"`
} `json:"actions"`
Container struct {
MessageTS string `json:"message_ts"`
ChannelID string `json:"channel_id"`
} `json:"container"`
}
SlackInteractiveResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
)
func slackHandler(trustSvc *trust.Service, slackSigningSecret string, logger *log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "cannot read request body"})
return
}
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
timestamp := r.Header.Get("X-Slack-Request-Timestamp")
signature := r.Header.Get("X-Slack-Signature")
if timestamp == "" || signature == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "missing Slack signature headers"})
return
}
if err := slack.VerifySignature(slackSigningSecret, timestamp, signature, bodyBytes); err != nil {
logger.ErrorCtx(ctx, "invalid Slack signature", log.Error(err))
httpserver.RenderJSON(w, http.StatusUnauthorized, SlackInteractiveResponse{Success: false, Message: "invalid Slack signature"})
return
}
var slackPayload SlackInteractivePayload
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "unsupported content type"})
return
}
if err := r.ParseForm(); err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "cannot parse form"})
return
}
raw := r.FormValue("payload")
if raw == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "empty payload field"})
return
}
if err := json.NewDecoder(strings.NewReader(raw)).Decode(&slackPayload); err != nil {
logger.ErrorCtx(ctx, "cannot parse Slack payload", log.Error(err))
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "cannot parse Slack payload"})
return
}
// Slack sends empty action for url button clicks
if len(slackPayload.Actions) == 0 {
httpserver.RenderJSON(w, http.StatusOK, SlackInteractiveResponse{Success: true, Message: "no action required"})
return
}
action := slackPayload.Actions[0]
if action.Value == "" {
httpserver.RenderJSON(w, http.StatusOK, SlackInteractiveResponse{Success: true, Message: "no action required"})
return
}
if slackPayload.Container.MessageTS == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "missing message_ts"})
return
}
if slackPayload.Container.ChannelID == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "missing channel_id"})
return
}
if slackPayload.ResponseURL == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "missing response_url"})
return
}
var documentIDs []gid.GID
var reportIDs []gid.GID
switch action.ActionID {
case "accept_all":
var acceptAllData struct {
DocumentIDs []string `json:"document_ids"`
ReportIDs []string `json:"report_ids"`
}
if err := json.NewDecoder(strings.NewReader(action.Value)).Decode(&acceptAllData); err != nil {
logger.ErrorCtx(ctx, "failed to parse accept_all value", log.Error(err))
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid accept_all value"})
return
}
for _, idStr := range acceptAllData.DocumentIDs {
docID, err := gid.ParseGID(idStr)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid document ID"})
return
}
documentIDs = append(documentIDs, docID)
}
for _, idStr := range acceptAllData.ReportIDs {
repID, err := gid.ParseGID(idStr)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid report ID"})
return
}
reportIDs = append(reportIDs, repID)
}
case "accept_document":
docID, err := gid.ParseGID(action.Value)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid document ID"})
return
}
documentIDs = []gid.GID{docID}
case "accept_report":
repID, err := gid.ParseGID(action.Value)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid report ID"})
return
}
reportIDs = []gid.GID{repID}
default:
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: fmt.Sprintf("unknown action: %s", action.ActionID)})
return
}
// Load the slack message without tenant scope
slackMessage, err := trustSvc.WithTenant(gid.TenantID{}).SlackMessages.LoadSlackMessageUnscoped(
ctx,
slackPayload.Container.ChannelID,
slackPayload.Container.MessageTS,
)
if err != nil {
logger.ErrorCtx(ctx, "cannot load slack message", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
fourteenDaysAgo := time.Now().Add(-14 * 24 * time.Hour)
if slackMessage.CreatedAt.Before(fourteenDaysAgo) {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "this message is too old to be updated (older than 14 days)"})
return
}
if slackMessage.RequesterEmail == nil || *slackMessage.RequesterEmail == "" {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "missing requester_email"})
return
}
requesterEmail := *slackMessage.RequesterEmail
tenantSvc := trustSvc.WithTenant(slackMessage.OrganizationID.TenantID())
trustCenter, err := tenantSvc.TrustCenters.GetByOrganizationID(ctx, slackMessage.OrganizationID)
if err != nil {
logger.ErrorCtx(ctx, "cannot load trust center", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
if err := tenantSvc.TrustCenterAccesses.AcceptByIDs(
ctx,
trustCenter.ID,
requesterEmail,
documentIDs,
reportIDs,
); err != nil {
logger.ErrorCtx(ctx, "failed to grant access", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
if err := tenantSvc.SlackMessages.UpdateSlackAccessMessage(
ctx,
slackMessage.ID,
action.ActionID,
action.Value,
slackPayload.ResponseURL,
); err != nil {
logger.ErrorCtx(ctx, "failed to update Slack message", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
httpserver.RenderJSON(w, http.StatusOK, SlackInteractiveResponse{Success: true, Message: "Access granted"})
}
}

View File

@@ -140,7 +140,7 @@ func (r *mutationResolver) RequestAllAccesses(ctx context.Context, input types.R
return nil, fmt.Errorf("email is required for unauthenticated users")
}
access, err := publicTrustService.TrustCenterAccesses.Request(ctx, &trust.RequestTrustCenterAccessRequest{
access, err := publicTrustService.TrustCenterAccesses.Request(ctx, &trust.TrustCenterAccessRequest{
TrustCenterID: input.TrustCenterID,
Email: *email,
Name: input.Name,
@@ -370,7 +370,7 @@ func (r *mutationResolver) RequestDocumentAccess(ctx context.Context, input type
return nil, fmt.Errorf("email is required for unauthenticated users")
}
access, err := publicTrustService.TrustCenterAccesses.Request(ctx, &trust.RequestTrustCenterAccessRequest{
access, err := publicTrustService.TrustCenterAccesses.Request(ctx, &trust.TrustCenterAccessRequest{
TrustCenterID: input.TrustCenterID,
Email: *email,
Name: input.Name,
@@ -423,7 +423,7 @@ func (r *mutationResolver) RequestReportAccess(ctx context.Context, input types.
return nil, fmt.Errorf("email is required for unauthenticated users")
}
access, err := publicTrustService.TrustCenterAccesses.Request(ctx, &trust.RequestTrustCenterAccessRequest{
access, err := publicTrustService.TrustCenterAccesses.Request(ctx, &trust.TrustCenterAccessRequest{
TrustCenterID: input.TrustCenterID,
Email: *email,
Name: input.Name,