- Facebook/Instagram/TikTok 渠道 OAuth 授权流程改进 - 新增 oauth/credentialstore 包 - 集成应用 OpenAI 重命名为 OpenAI 兼容 (migration 061) - 消息生命周期与 AI 流程架构文档 - inbox 管理界面 i18n 更新
345 lines
13 KiB
Go
345 lines
13 KiB
Go
package v1
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"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)
|
|
}
|
|
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)
|
|
}
|
|
}
|