175 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|
|
|