package v1 import ( "context" "encoding/json" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) // IntegrationHookHandler handles IntegrationHook CRUD + ProcessEvent endpoints. // Reference: Chatwoot Integrations::HooksController + AppsController type IntegrationHookHandler struct { svc *service.IntegrationHookService } // NewIntegrationHookHandler creates a new IntegrationHookHandler. func NewIntegrationHookHandler(svc *service.IntegrationHookService) *IntegrationHookHandler { return &IntegrationHookHandler{svc: svc} } // --- Integration Apps (catalog) --- // ListApps retrieves all available integration apps. // GET /api/v1/accounts/:account_id/integrations/apps func (h *IntegrationHookHandler) ListApps(c *gin.Context) { if !h.svc.Ready() { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to list integration apps") return } apps, err := h.svc.ListApps(c.Request.Context()) if err != nil { handleServiceError(c, err) return } accountID := getAccountID(c) c.JSON(http.StatusOK, gin.H{"payload": h.serializeIntegrationApps(c, accountID, apps)}) } // GetApp retrieves a single integration app by ID. // GET /api/v1/accounts/:account_id/integrations/apps/:id func (h *IntegrationHookHandler) GetApp(c *gin.Context) { app, svcErr := h.svc.GetAppByID(c.Request.Context(), c.Param("id")) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, h.serializeIntegrationApp(c, getAccountID(c), *app)) } // --- Integration Hooks CRUD --- // ListHooks retrieves all integration hooks for an account, paginated. // GET /api/v1/accounts/:account_id/integrations/hooks func (h *IntegrationHookHandler) ListHooks(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } page := pagination.Parse(c) hooks, total, svcErr := h.svc.List(c.Request.Context(), accountID, page.Offset, page.PerPage) if svcErr != nil { handleServiceError(c, svcErr) return } response.OKWithMeta(c, hooks, page.Page, page.PerPage, total) } // GetHook retrieves a single integration hook by ID. // GET /api/v1/accounts/:account_id/integrations/hooks/:id func (h *IntegrationHookHandler) GetHook(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid hook id") return } hook, svcErr := h.svc.GetScoped(c.Request.Context(), getAccountID(c), id) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, serializeIntegrationHook(*hook)) } // CreateHook creates a new integration hook. // POST /api/v1/accounts/:account_id/integrations/hooks func (h *IntegrationHookHandler) CreateHook(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } var req service.CreateHookRequest if err := bindJSONWrappedOrRaw(c, "hook", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } hook, svcErr := h.svc.Create(c.Request.Context(), accountID, req) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, serializeIntegrationHook(*hook)) } // UpdateHook updates an existing integration hook. // PUT /api/v1/accounts/:account_id/integrations/hooks/:id func (h *IntegrationHookHandler) UpdateHook(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid hook id") return } accountID := getAccountID(c) var req service.UpdateHookRequest if err := bindJSONWrappedOrRaw(c, "hook", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } hook, svcErr := h.svc.UpdateScoped(c.Request.Context(), accountID, id, req) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, serializeIntegrationHook(*hook)) } // DeleteHook deletes an integration hook. // DELETE /api/v1/accounts/:account_id/integrations/hooks/:id func (h *IntegrationHookHandler) DeleteHook(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid hook id") return } if svcErr := h.svc.DeleteScoped(c.Request.Context(), getAccountID(c), id); svcErr != nil { handleServiceError(c, svcErr) return } c.Status(http.StatusOK) } // ProcessHookEvent processes an incoming event for a hook. // POST /api/v1/accounts/:account_id/integrations/hooks/:id/process_event func (h *IntegrationHookHandler) ProcessHookEvent(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid hook id") return } var eventData map[string]interface{} if err := c.ShouldBindJSON(&eventData); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } if svcErr := h.svc.ProcessEvent(c.Request.Context(), id, eventData); svcErr != nil { if strings.Contains(strings.ToLower(svcErr.Error()), "not found") { response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, svcErr.Error()) return } response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, svcErr.Error()) return } c.JSON(http.StatusOK, gin.H{"message": "event processed"}) } func (h *IntegrationHookHandler) serializeIntegrationApps(c *gin.Context, accountID uint, apps []model.IntegrationApp) []gin.H { payload := make([]gin.H, 0, len(apps)) for _, app := range apps { payload = append(payload, h.serializeIntegrationApp(c, accountID, app)) } return payload } func (h *IntegrationHookHandler) serializeIntegrationApp(c *gin.Context, accountID uint, app model.IntegrationApp) gin.H { appID := integrationAppID(app) hooks, _ := h.svc.ListHooksForApp(c.Request.Context(), accountID, appID) serializedHooks := make([]gin.H, 0, len(hooks)) for _, hook := range hooks { serializedHooks = append(serializedHooks, serializeIntegrationHook(hook)) } return gin.H{ "id": appID, "name": app.Name, "description": app.Description, "short_description": app.Description, "enabled": len(serializedHooks) > 0, "action": app.ActionURL, "button": app.ActionURL, "hook_type": integrationAppHookType(appID), "allow_multiple_hooks": integrationAppAllowsMultipleHooks(appID), "settings_form_schema": integrationAppSettingsFormSchema(appID), "visible_properties": integrationAppVisibleProperties(appID), "hooks": serializedHooks, } } func serializeIntegrationHook(hook model.IntegrationHook) gin.H { settings := gin.H{} if len(hook.Settings) > 0 { _ = json.Unmarshal(hook.Settings, &settings) } settings = gin.H(channel.SanitizeConfig(context.Background(), nil, channel.ChannelConfig(settings))) payload := gin.H{ "id": hook.ID, "app_id": integrationHookAppID(hook), "status": hook.Status != model.HookStatusInactive, "account_id": hook.AccountID, "hook_type": integrationHookKind(hook), "settings": settings, } if hook.ReferenceID != "" { payload["reference_id"] = hook.ReferenceID } if hook.InboxID != nil && *hook.InboxID != 0 { inbox := gin.H{"id": *hook.InboxID} if hook.Inbox != nil && hook.Inbox.ID != 0 { inbox["name"] = hook.Inbox.Name } payload["inbox"] = inbox } return payload } func integrationAppID(app model.IntegrationApp) string { if app.HookType != "" { return string(app.HookType) } return strings.ToLower(strings.ReplaceAll(app.Name, " ", "_")) } func integrationHookAppID(hook model.IntegrationHook) string { if hook.AppID != "" { return hook.AppID } return string(hook.HookType) } func integrationAppHookType(appID string) string { if appID == "dialogflow" { return "inbox" } return "account" } func integrationAppAllowsMultipleHooks(appID string) bool { return appID == "webhook" || appID == "dashboard_apps" || appID == "dialogflow" } func integrationAppSettingsFormSchema(appID string) []gin.H { schemas := map[string][]gin.H{ "openai": { {"label": "API Key", "type": "text", "name": "api_key", "validation": "required"}, {"label": "Base URL", "type": "text", "name": "base_url", "validation": "required", "help": "OpenAI 兼容 API 地址,例如 https://api.openai.com/v1"}, {"label": "模型", "type": "text", "name": "model", "validation": "required", "help": "自定义模型名称,例如 gpt-4o-mini、deepseek-chat 等"}, {"label": "启用标签建议", "type": "checkbox", "name": "label_suggestion", "validation": "", "help": "勾选后,在对话视图中显示 AI 生成的标签建议"}, }, "dialogflow": { {"label": "Dialogflow Project ID", "type": "text", "name": "project_id", "validation": "required", "validationName": "Project Id"}, {"label": "Dialogflow Project Key File", "type": "textarea", "name": "credentials", "validation": "required|JSON", "validationName": "Credentials", "validation-messages": gin.H{"JSON": "Invalid JSON", "required": "Credentials is required"}}, {"label": "Dialogflow Region", "type": "select", "name": "region", "default": "global", "options": []gin.H{{"label": "Global - Default", "value": "global"}, {"label": "AS-NE1 - Tokyo, Japan", "value": "asia-northeast1"}, {"label": "AU-SE1 - Sydney, Australia", "value": "australia-southeast1"}, {"label": "EU-W1 - St. Ghislain, Belgium", "value": "europe-west1"}, {"label": "EU-W2 - London, England", "value": "europe-west2"}}}, {"label": "Language Code", "type": "select", "name": "language_code", "default": "en-US", "help": "Language code for Dialogflow agent. Use \"auto\" to detect from contact language.", "options": []gin.H{{"label": "Auto-detect from contact", "value": "auto"}, {"label": "English (US)", "value": "en-US"}, {"label": "English (UK)", "value": "en-GB"}, {"label": "Spanish (Spain)", "value": "es-ES"}, {"label": "Spanish (Latin America)", "value": "es-419"}, {"label": "French", "value": "fr-FR"}, {"label": "German", "value": "de-DE"}, {"label": "Portuguese (Brazil)", "value": "pt-BR"}, {"label": "Portuguese (Portugal)", "value": "pt-PT"}, {"label": "Italian", "value": "it-IT"}, {"label": "Japanese", "value": "ja-JP"}, {"label": "Korean", "value": "ko-KR"}, {"label": "Chinese (Simplified)", "value": "zh-CN"}, {"label": "Chinese (Traditional)", "value": "zh-TW"}, {"label": "Hindi", "value": "hi-IN"}, {"label": "Arabic", "value": "ar"}, {"label": "Russian", "value": "ru-RU"}, {"label": "Dutch", "value": "nl-NL"}, {"label": "Polish", "value": "pl-PL"}, {"label": "Turkish", "value": "tr-TR"}, {"label": "Thai", "value": "th-TH"}, {"label": "Vietnamese", "value": "vi-VN"}, {"label": "Indonesian", "value": "id-ID"}}}, }, "google_translate": { {"label": "Google Cloud Project ID", "type": "text", "name": "project_id", "validation": "required", "validationName": "Project Id"}, {"label": "Google Cloud Project Key File", "type": "textarea", "name": "credentials", "validation": "required|JSON", "validationName": "Credentials", "validation-messages": gin.H{"JSON": "Invalid JSON", "required": "Credentials is required"}}, }, "dyte": { {"label": "Organization ID", "type": "text", "name": "organization_id", "validation": "required"}, {"label": "API Key", "type": "text", "name": "api_key", "validation": "required"}, }, "leadsquared": { {"label": "Access Key", "type": "text", "name": "access_key", "validation": "required"}, {"label": "Secret Key", "type": "text", "name": "secret_key", "validation": "required"}, {"label": "Endpoint URL", "type": "text", "name": "endpoint_url", "validation": "required|url"}, {"label": "App URL", "type": "text", "name": "app_url", "validation": "required|url"}, }, } if schema, ok := schemas[appID]; ok { return schema } return []gin.H{} } func integrationAppVisibleProperties(appID string) []string { properties := map[string][]string{ "openai": {"api_key", "base_url", "model", "label_suggestion"}, "dialogflow": {"project_id", "region", "language_code"}, "google_translate": {"project_id"}, "dyte": {"organization_id"}, "leadsquared": {"access_key", "endpoint_url", "app_url"}, } if visibleProperties, ok := properties[appID]; ok { return visibleProperties } return []string{} } func integrationHookKind(hook model.IntegrationHook) string { if hook.InboxID != nil && *hook.InboxID != 0 { return "inbox" } return "account" } // RegisterIntegrationHookRoutes registers integration hook routes on a router group. func RegisterIntegrationHookRoutes(g *gin.RouterGroup, h *IntegrationHookHandler) { // Integration apps catalog apps := g.Group("/apps") { apps.GET("", h.ListApps) apps.GET("/", h.ListApps) apps.GET("/:id", h.GetApp) } // Integration hooks CRUD + process event hooks := g.Group("/hooks") { hooks.GET("", h.ListHooks) hooks.GET("/", h.ListHooks) hooks.GET("/:id", h.GetHook) hooks.POST("", h.CreateHook) hooks.POST("/", h.CreateHook) hooks.PUT("/:id", h.UpdateHook) hooks.PATCH("/:id", h.UpdateHook) hooks.DELETE("/:id", h.DeleteHook) hooks.POST("/:id/process_event", h.ProcessHookEvent) } }