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) } }