package api import ( "log/slog" "net/http" "github.com/gin-gonic/gin" "thuanle.me/claw-email-bridge/internal/database" "thuanle.me/claw-email-bridge/internal/logging" "gorm.io/gorm" ) // CallbackRequest is the payload received from OpenClaw. type CallbackRequest struct { Metadata struct { TaskUUID string `json:"task_uuid" binding:"required"` } `json:"metadata" binding:"required"` Result string `json:"result" binding:"required"` Status string `json:"status"` } // OnCallbackDone is called after a task transitions to CALLBACK_DONE. // This will be wired to SMTP egress. type OnCallbackDone func(task *database.Task) // NewRouter creates the Gin engine with health and callback routes. func NewRouter(db *gorm.DB, callbackToken string, onDone OnCallbackDone) *gin.Engine { gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) r.GET("/readyz", readyzHandler(db, callbackToken)) r.POST("/callback", tokenAuthMiddleware(callbackToken), callbackHandler(db, onDone)) return r } // tokenAuthMiddleware validates X-Bridge-Token header. func tokenAuthMiddleware(expected string) gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("X-Bridge-Token") if token == "" || token != expected { slog.Warn("callback: unauthorized request", "remote_addr", c.ClientIP(), "has_token", token != "", ) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "unauthorized", }) return } c.Next() } } // callbackHandler processes the callback from OpenClaw. func callbackHandler(db *gorm.DB, onDone OnCallbackDone) gin.HandlerFunc { return func(c *gin.Context) { var req CallbackRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()}) return } taskUUID := req.Metadata.TaskUUID log := logging.TaskLogger(taskUUID, "", "") // Find task. task, err := database.FindByTaskUUID(db, taskUUID) if err != nil { log.Error("callback: db lookup failed", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } if task == nil { log.Warn("callback: task not found") c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } log = logging.TaskLogger(task.TaskUUID, task.ThreadID, task.MessageID) // Idempotent: if already COMPLETED, return 200 without changes. if task.Status == database.StatusCompleted { log.Info("callback: task already completed, idempotent response") c.JSON(http.StatusOK, gin.H{"status": "already_completed"}) return } // Update task with AI response. task.AIResponse = req.Result task.Status = database.StatusCallbackDone if err := database.UpdateTask(db, task); err != nil { log.Error("callback: failed to update task", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } log.Info("callback: task updated", "status", database.StatusCallbackDone, ) c.JSON(http.StatusOK, gin.H{"status": "ok"}) // Trigger SMTP egress (async, after response is sent). if onDone != nil { go onDone(task) } } } // readyzHandler checks DB writable + required config present. func readyzHandler(db *gorm.DB, callbackToken string) gin.HandlerFunc { return func(c *gin.Context) { if err := database.Healthy(db); err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "status": "error", "detail": "database not writable", }) return } if callbackToken == "" { c.JSON(http.StatusServiceUnavailable, gin.H{ "status": "error", "detail": "missing BRIDGE_CALLBACK_TOKEN", }) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } }