Files
IRC-kosmi-relay/bridge/jackbox/webhook.go
2025-11-01 10:40:53 -04:00

175 lines
4.5 KiB
Go

package jackbox
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/sirupsen/logrus"
)
// WebhookServer handles incoming webhooks from the Jackbox API
type WebhookServer struct {
port int
secret string
messageCallback func(string) // Callback to broadcast messages
log *logrus.Entry
server *http.Server
}
// WebhookPayload represents the webhook event from the API
type WebhookPayload struct {
Event string `json:"event"`
Timestamp string `json:"timestamp"`
Data struct {
Session struct {
ID int `json:"id"`
IsActive bool `json:"is_active"`
GamesPlayed int `json:"games_played"`
} `json:"session"`
Game struct {
ID int `json:"id"`
Title string `json:"title"`
PackName string `json:"pack_name"`
MinPlayers int `json:"min_players"`
MaxPlayers int `json:"max_players"`
ManuallyAdded bool `json:"manually_added"`
} `json:"game"`
} `json:"data"`
}
// NewWebhookServer creates a new webhook server
func NewWebhookServer(port int, secret string, messageCallback func(string), log *logrus.Entry) *WebhookServer {
return &WebhookServer{
port: port,
secret: secret,
messageCallback: messageCallback,
log: log,
}
}
// Start starts the webhook HTTP server
func (w *WebhookServer) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/webhook/jackbox", w.handleWebhook)
// Health check endpoint
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
w.server = &http.Server{
Addr: fmt.Sprintf(":%d", w.port),
Handler: mux,
}
w.log.Infof("Starting Jackbox webhook server on port %d", w.port)
go func() {
if err := w.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
w.log.Errorf("Webhook server error: %v", err)
}
}()
return nil
}
// Stop stops the webhook server
func (w *WebhookServer) Stop() error {
if w.server != nil {
w.log.Info("Stopping Jackbox webhook server")
return w.server.Close()
}
return nil
}
// handleWebhook processes incoming webhook requests
func (w *WebhookServer) handleWebhook(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Read the raw body for signature verification
body, err := io.ReadAll(r.Body)
if err != nil {
w.log.Errorf("Failed to read webhook body: %v", err)
http.Error(rw, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Verify signature
signature := r.Header.Get("X-Webhook-Signature")
if !w.verifySignature(signature, body) {
w.log.Warn("Webhook signature verification failed")
http.Error(rw, "Invalid signature", http.StatusUnauthorized)
return
}
// Parse the webhook payload
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
w.log.Errorf("Failed to parse webhook payload: %v", err)
http.Error(rw, "Invalid payload", http.StatusBadRequest)
return
}
// Handle the event
w.handleEvent(&payload)
// Always respond with 200 OK
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
}
// verifySignature verifies the HMAC-SHA256 signature of the webhook
func (w *WebhookServer) verifySignature(signature string, body []byte) bool {
if signature == "" || len(signature) < 7 || signature[:7] != "sha256=" {
return false
}
// Extract the hex signature (after "sha256=")
receivedSig := signature[7:]
// Compute expected signature
mac := hmac.New(sha256.New, []byte(w.secret))
mac.Write(body)
expectedSig := hex.EncodeToString(mac.Sum(nil))
// Timing-safe comparison
return subtle.ConstantTimeCompare([]byte(receivedSig), []byte(expectedSig)) == 1
}
// handleEvent processes webhook events
func (w *WebhookServer) handleEvent(payload *WebhookPayload) {
switch payload.Event {
case "game.added":
w.handleGameAdded(payload)
default:
w.log.Debugf("Unhandled webhook event: %s", payload.Event)
}
}
// handleGameAdded handles the game.added event
func (w *WebhookServer) handleGameAdded(payload *WebhookPayload) {
game := payload.Data.Game
w.log.Infof("Game added: %s from %s", game.Title, game.PackName)
// Format the announcement message
message := fmt.Sprintf("🎮 Coming up next: %s!", game.Title)
// Broadcast to both chats via callback
if w.messageCallback != nil {
w.messageCallback(message)
}
}