package service import ( "bytes" "context" "crypto/rand" "encoding/json" "errors" "fmt" "io" "math/big" "net/http" "regexp" "strings" "text/template" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // CaptainCustomToolService implements business logic for CaptainCustomTool operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/custom_tools_controller.rb type CaptainCustomToolService struct { toolRepo *repository.CaptainCustomToolRepo accountRepo *repository.AccountRepo httpClient HTTPDoer } const ( maxCaptainCustomToolsPerAccount = 15 maxCaptainCustomToolSlugLength = 64 customToolSlugCollisionSuffix = 7 ) var ErrCaptainCustomToolLimitExceeded = errors.New("You can create a maximum of 15 custom tools per account") type CaptainCustomToolValidationError struct { Message string Attributes []string } func (e *CaptainCustomToolValidationError) Error() string { return e.Message } type HTTPDoer interface { Do(req *http.Request) (*http.Response, error) } // NewCaptainCustomToolService creates a new CaptainCustomToolService. func NewCaptainCustomToolService(toolRepo *repository.CaptainCustomToolRepo, accountRepo ...*repository.AccountRepo) *CaptainCustomToolService { svc := &CaptainCustomToolService{toolRepo: toolRepo, httpClient: &http.Client{Timeout: 30 * time.Second}} if len(accountRepo) > 0 { svc.accountRepo = accountRepo[0] } return svc } func (s *CaptainCustomToolService) SetHTTPClient(client HTTPDoer) { if client != nil { s.httpClient = client } } func (s *CaptainCustomToolService) CustomToolsEnabled(ctx context.Context, accountID uint) bool { if s.accountRepo == nil { return true } account, err := s.accountRepo.FindByID(ctx, accountID) if err != nil { return false } return featureFlagStringEnabled(account.FeatureFlags, "custom_tools") || featureFlagStringEnabled(account.FeatureFlags, "captain_integration_v2") } // --- Request DTOs --- // CreateCustomToolRequest is the DTO for creating a custom tool. type CreateCustomToolRequest struct { Title string `json:"title" validate:"required"` Slug string `json:"slug"` Description string `json:"description"` EndpointURL string `json:"endpoint_url" validate:"required"` HTTPMethod string `json:"http_method"` AuthType string `json:"auth_type"` AuthConfig json.RawMessage `json:"auth_config"` ParamSchema json.RawMessage `json:"param_schema"` RequestTemplate string `json:"request_template"` ResponseTemplate string `json:"response_template"` } // UpdateCustomToolRequest is the DTO for updating a custom tool. type UpdateCustomToolRequest struct { Title string `json:"title"` Description string `json:"description"` EndpointURL string `json:"endpoint_url"` HTTPMethod string `json:"http_method"` AuthType string `json:"auth_type"` AuthConfig json.RawMessage `json:"auth_config"` ParamSchema json.RawMessage `json:"param_schema"` RequestTemplate string `json:"request_template"` ResponseTemplate string `json:"response_template"` Enabled *bool `json:"enabled"` } // --- CRUD Operations --- // Create creates a new CaptainCustomTool. func (s *CaptainCustomToolService) Create(ctx context.Context, accountID uint, req *CreateCustomToolRequest) (*model.CaptainCustomTool, error) { var err error // Default values httpMethod := req.HTTPMethod if httpMethod == "" { httpMethod = "GET" } authType := req.AuthType if authType == "" { authType = "none" } slug := req.Slug if slug == "" { slug, err = s.uniqueCustomToolSlug(ctx, accountID, req.Title) if err != nil { return nil, err } } tool := &model.CaptainCustomTool{ AccountID: accountID, Title: req.Title, Slug: slug, Description: req.Description, EndpointURL: req.EndpointURL, HTTPMethod: httpMethod, AuthType: model.ToolAuthType(authType), AuthConfig: req.AuthConfig, ParamSchema: req.ParamSchema, RequestTemplate: req.RequestTemplate, ResponseTemplate: req.ResponseTemplate, Enabled: true, } if err := validateCaptainCustomTool(tool); err != nil { return nil, err } if req.Slug != "" && s.customToolSlugExists(ctx, accountID, req.Slug) { return nil, newCaptainCustomToolValidationError("Slug has already been taken", "slug") } count, err := s.toolRepo.CountByAccount(ctx, accountID) if err != nil { return nil, fmt.Errorf("count custom tools: %w", err) } if count >= maxCaptainCustomToolsPerAccount { return nil, ErrCaptainCustomToolLimitExceeded } if err := s.toolRepo.Create(ctx, tool); err != nil { applogger.L().Errorf("Create captain custom tool: %v", err) return nil, fmt.Errorf("create custom tool: %w", err) } return tool, nil } // Get retrieves a custom tool by ID. func (s *CaptainCustomToolService) Get(ctx context.Context, id uint) (*model.CaptainCustomTool, error) { tool, err := s.toolRepo.GetByID(ctx, id) if err != nil { applogger.L().Errorf("Get captain custom tool: %v", err) return nil, fmt.Errorf("get custom tool: %w", err) } return tool, nil } func (s *CaptainCustomToolService) GetByAccount(ctx context.Context, accountID, id uint) (*model.CaptainCustomTool, error) { tool, err := s.toolRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { applogger.L().Errorf("Get captain custom tool: %v", err) return nil, fmt.Errorf("get custom tool: %w", err) } return tool, nil } // Update updates an existing custom tool. func (s *CaptainCustomToolService) Update(ctx context.Context, id uint, req *UpdateCustomToolRequest) (*model.CaptainCustomTool, error) { tool, err := s.toolRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("custom tool not found: %w", err) } if req.Title != "" { tool.Title = req.Title } if req.Description != "" { tool.Description = req.Description } if req.EndpointURL != "" { tool.EndpointURL = req.EndpointURL } if req.HTTPMethod != "" { tool.HTTPMethod = req.HTTPMethod } if req.AuthType != "" { tool.AuthType = model.ToolAuthType(req.AuthType) } if len(req.AuthConfig) > 0 && string(req.AuthConfig) != "null" { tool.AuthConfig = req.AuthConfig } if len(req.ParamSchema) > 0 && string(req.ParamSchema) != "null" { tool.ParamSchema = req.ParamSchema } if req.RequestTemplate != "" { tool.RequestTemplate = req.RequestTemplate } if req.ResponseTemplate != "" { tool.ResponseTemplate = req.ResponseTemplate } if req.Enabled != nil { tool.Enabled = *req.Enabled } if err := s.toolRepo.Update(ctx, tool); err != nil { applogger.L().Errorf("Update captain custom tool: %v", err) return nil, fmt.Errorf("update custom tool: %w", err) } return tool, nil } func (s *CaptainCustomToolService) UpdateByAccount(ctx context.Context, accountID, id uint, req *UpdateCustomToolRequest) (*model.CaptainCustomTool, error) { tool, err := s.toolRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { return nil, fmt.Errorf("custom tool not found: %w", err) } applyCustomToolUpdate(tool, req) if err := validateCaptainCustomTool(tool); err != nil { return nil, err } if err := s.toolRepo.Update(ctx, tool); err != nil { applogger.L().Errorf("Update captain custom tool: %v", err) return nil, fmt.Errorf("update custom tool: %w", err) } return tool, nil } // Delete deletes a custom tool by ID. func (s *CaptainCustomToolService) Delete(ctx context.Context, id uint) error { if err := s.toolRepo.Delete(ctx, id); err != nil { applogger.L().Errorf("Delete captain custom tool: %v", err) return fmt.Errorf("delete custom tool: %w", err) } return nil } func (s *CaptainCustomToolService) DeleteByAccount(ctx context.Context, accountID, id uint) error { if _, err := s.toolRepo.GetByAccountAndID(ctx, accountID, id); err != nil { return fmt.Errorf("custom tool not found: %w", err) } if err := s.toolRepo.DeleteByAccount(ctx, accountID, id); err != nil { applogger.L().Errorf("Delete captain custom tool: %v", err) return fmt.Errorf("delete custom tool: %w", err) } return nil } // List retrieves custom tools for an account with pagination. func (s *CaptainCustomToolService) List(ctx context.Context, accountID uint, offset, limit int) ([]model.CaptainCustomTool, int64, error) { tools, count, err := s.toolRepo.ListByAccount(ctx, accountID, offset, limit) if err != nil { applogger.L().Errorf("List captain custom tools: %v", err) return nil, 0, fmt.Errorf("list custom tools: %w", err) } return tools, count, nil } func applyCustomToolUpdate(tool *model.CaptainCustomTool, req *UpdateCustomToolRequest) { if req.Title != "" { tool.Title = req.Title } if req.Description != "" { tool.Description = req.Description } if req.EndpointURL != "" { tool.EndpointURL = req.EndpointURL } if req.HTTPMethod != "" { tool.HTTPMethod = req.HTTPMethod } if req.AuthType != "" { tool.AuthType = model.ToolAuthType(req.AuthType) } if len(req.AuthConfig) > 0 && string(req.AuthConfig) != "null" { tool.AuthConfig = req.AuthConfig } if len(req.ParamSchema) > 0 && string(req.ParamSchema) != "null" { tool.ParamSchema = req.ParamSchema } if req.RequestTemplate != "" { tool.RequestTemplate = req.RequestTemplate } if req.ResponseTemplate != "" { tool.ResponseTemplate = req.ResponseTemplate } if req.Enabled != nil { tool.Enabled = *req.Enabled } } func (s *CaptainCustomToolService) uniqueCustomToolSlug(ctx context.Context, accountID uint, title string) (string, error) { baseSlug := customToolSlug(title) if !s.customToolSlugExists(ctx, accountID, baseSlug) { return baseSlug, nil } truncated := truncateString(baseSlug, maxCaptainCustomToolSlugLength-customToolSlugCollisionSuffix) for i := 0; i < 5; i++ { candidate := truncated + "_" + randomLowerAlphanumeric(6) if !s.customToolSlugExists(ctx, accountID, candidate) { return candidate, nil } } return "", fmt.Errorf("slug generation failed") } func (s *CaptainCustomToolService) customToolSlugExists(ctx context.Context, accountID uint, slug string) bool { _, err := s.toolRepo.GetBySlug(ctx, accountID, slug) return err == nil } func validateCaptainCustomTool(tool *model.CaptainCustomTool) error { var messages []string var attributes []string add := func(attr, message string) { messages = append(messages, message) attributes = append(attributes, attr) } if strings.TrimSpace(tool.Title) == "" { add("title", "Title can't be blank") } if strings.TrimSpace(tool.EndpointURL) == "" { add("endpoint_url", "Endpoint url can't be blank") } if len(tool.Slug) > maxCaptainCustomToolSlugLength { add("slug", "Slug is too long (maximum is 64 characters)") } if tool.HTTPMethod != "GET" && tool.HTTPMethod != "POST" { add("http_method", "Http method is not included in the list") } switch tool.AuthType { case model.ToolAuthTypeNone, model.ToolAuthTypeBearer, model.ToolAuthTypeBasic, model.ToolAuthTypeApiKey: default: add("auth_type", "Auth type is not included in the list") } for _, validation := range validateCaptainCustomToolParamSchema(tool.ParamSchema) { add(validation.attribute, validation.message) } if len(messages) == 0 { return nil } return &CaptainCustomToolValidationError{Message: strings.Join(messages, ", "), Attributes: uniqueStrings(attributes)} } type customToolParamSchemaValidation struct { attribute string message string } func validateCaptainCustomToolParamSchema(raw json.RawMessage) []customToolParamSchemaValidation { if len(raw) == 0 || string(raw) == "null" { return nil } var items []map[string]any if err := json.Unmarshal(raw, &items); err != nil { return []customToolParamSchemaValidation{{attribute: "param_schema", message: "Param schema must be of type array"}} } allowed := map[string]bool{"name": true, "type": true, "description": true, "required": true} var validations []customToolParamSchemaValidation for _, item := range items { for _, field := range []string{"name", "type", "description"} { value, ok := item[field] if !ok { validations = append(validations, customToolParamSchemaValidation{attribute: field, message: customToolFieldLabel(field) + " is required"}) continue } if _, ok := value.(string); !ok { validations = append(validations, customToolParamSchemaValidation{attribute: field, message: customToolFieldLabel(field) + " must be of type string"}) } } if value, ok := item["required"]; ok { if _, ok := value.(bool); !ok { validations = append(validations, customToolParamSchemaValidation{attribute: "required", message: "Required must be of type boolean"}) } } for field := range item { if !allowed[field] { validations = append(validations, customToolParamSchemaValidation{attribute: field, message: customToolFieldLabel(field) + " is not permitted"}) } } } return validations } func customToolFieldLabel(field string) string { if field == "" { return field } return strings.ToUpper(field[:1]) + field[1:] } func newCaptainCustomToolValidationError(message, attribute string) error { return &CaptainCustomToolValidationError{Message: message, Attributes: []string{attribute}} } func uniqueStrings(values []string) []string { seen := make(map[string]bool, len(values)) unique := make([]string, 0, len(values)) for _, value := range values { if seen[value] { continue } seen[value] = true unique = append(unique, value) } return unique } func customToolSlug(title string) string { slug := strings.ToLower(strings.TrimSpace(title)) slug = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(slug, "_") slug = strings.Trim(slug, "_") return truncateString("custom_"+slug, maxCaptainCustomToolSlugLength) } func truncateString(value string, maxLen int) string { if len(value) <= maxLen { return value } return value[:maxLen] } func randomLowerAlphanumeric(length int) string { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" var b strings.Builder b.Grow(length) for i := 0; i < length; i++ { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) if err != nil { b.WriteByte(alphabet[time.Now().UnixNano()%int64(len(alphabet))]) continue } b.WriteByte(alphabet[n.Int64()]) } return b.String() } // --- Tool Execution --- // ExecuteToolResult holds the result of executing a custom tool. type ExecuteToolResult struct { Success bool `json:"success"` Data interface{} `json:"data"` Error string `json:"error,omitempty"` } type TestToolResult struct { Status int `json:"status"` Body string `json:"body"` } // ExecuteTool calls the external HTTP endpoint configured in the custom tool. // Reference: Chatwoot Captain::CustomTool#execute func (s *CaptainCustomToolService) ExecuteTool(ctx context.Context, id uint, params map[string]interface{}) (*ExecuteToolResult, error) { tool, err := s.toolRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("custom tool not found: %w", err) } if !tool.Enabled { return nil, fmt.Errorf("custom tool is disabled") } // Build request body from template + params requestBody, err := buildRequestBody(tool.RequestTemplate, params) if err != nil { return nil, fmt.Errorf("build request body: %w", err) } // Create HTTP request method := strings.ToUpper(tool.HTTPMethod) var httpReq *http.Request if method == "GET" || method == "DELETE" { httpReq, err = http.NewRequestWithContext(ctx, method, tool.EndpointURL, nil) } else { httpReq, err = http.NewRequestWithContext(ctx, method, tool.EndpointURL, bytes.NewReader(requestBody)) } if err != nil { return nil, fmt.Errorf("create HTTP request: %w", err) } // Set content type for methods with body if method != "GET" && method != "DELETE" { httpReq.Header.Set("Content-Type", "application/json") } // Apply authentication if err := applyAuth(httpReq, tool); err != nil { return nil, fmt.Errorf("apply auth: %w", err) } // Execute HTTP call resp, err := s.httpClient.Do(httpReq) if err != nil { applogger.L().Errorf("ExecuteTool HTTP call: %v", err) return &ExecuteToolResult{Success: false, Error: err.Error()}, nil } defer resp.Body.Close() respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("read response: %w", err) } // Parse response through response template if defined var resultData interface{} if tool.ResponseTemplate != "" { parsed, err := parseResponseTemplate(tool.ResponseTemplate, respBody) if err != nil { applogger.L().Errorf("ExecuteTool parse response template: %v", err) resultData = string(respBody) } else { resultData = parsed } } else { // Try to parse as JSON; fall back to raw string var jsonData interface{} if err := json.Unmarshal(respBody, &jsonData); err == nil { resultData = jsonData } else { resultData = string(respBody) } } success := resp.StatusCode >= 200 && resp.StatusCode < 300 return &ExecuteToolResult{ Success: success, Data: resultData, }, nil } // buildRequestBody renders the Go template with params to produce the HTTP request body. func buildRequestBody(templateStr string, params map[string]interface{}) ([]byte, error) { if templateStr == "" { // No template — send params as JSON directly return json.Marshal(params) } tmpl, err := template.New("request").Parse(templateStr) if err != nil { return nil, fmt.Errorf("parse request template: %w", err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, params); err != nil { return nil, fmt.Errorf("execute request template: %w", err) } return buf.Bytes(), nil } // parseResponseTemplate renders the response template with the raw response body. func parseResponseTemplate(templateStr string, rawBody []byte) (interface{}, error) { // Parse the raw body as a map for template rendering var data interface{} if err := json.Unmarshal(rawBody, &data); err != nil { data = map[string]interface{}{"raw": string(rawBody)} } tmpl, err := template.New("response").Parse(templateStr) if err != nil { return nil, fmt.Errorf("parse response template: %w", err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return nil, fmt.Errorf("execute response template: %w", err) } // Try to parse the rendered template output as JSON var result interface{} rendered := buf.Bytes() if err := json.Unmarshal(rendered, &result); err != nil { return string(rendered), nil } return result, nil } // applyAuth sets authentication headers on the HTTP request based on tool config. func applyAuth(req *http.Request, tool *model.CaptainCustomTool) error { switch tool.AuthType { case model.ToolAuthTypeNone: // No auth required return nil case model.ToolAuthTypeBasic: var authCfg struct { Username string `json:"username"` Password string `json:"password"` } if err := json.Unmarshal(tool.AuthConfig, &authCfg); err != nil { return fmt.Errorf("parse basic auth config: %w", err) } req.SetBasicAuth(authCfg.Username, authCfg.Password) return nil case model.ToolAuthTypeBearer: var authCfg struct { Token string `json:"token"` } if err := json.Unmarshal(tool.AuthConfig, &authCfg); err != nil { return fmt.Errorf("parse bearer auth config: %w", err) } req.Header.Set("Authorization", "Bearer "+authCfg.Token) return nil case model.ToolAuthTypeApiKey: var authCfg struct { Key string `json:"key"` Value string `json:"value"` Header string `json:"header"` // default: "X-API-Key" } if err := json.Unmarshal(tool.AuthConfig, &authCfg); err != nil { return fmt.Errorf("parse api_key auth config: %w", err) } headerName := authCfg.Header if headerName == "" { headerName = "X-API-Key" } req.Header.Set(headerName, authCfg.Value) return nil default: return fmt.Errorf("unsupported auth type: %s", tool.AuthType) } } // --- Tool Testing --- // Reference: Chatwoot Captain::CustomToolsController#test // TestTool allows testing a custom tool with parameters before enabling it. // Unlike ExecuteTool, TestTool does not require the tool to be enabled // and returns detailed error information for debugging. // TestToolRequest is the DTO for testing a custom tool. type TestToolRequest struct { ToolID uint `json:"tool_id"` Title string `json:"title"` Description string `json:"description"` EndpointURL string `json:"endpoint_url"` HTTPMethod string `json:"http_method"` AuthType string `json:"auth_type"` AuthConfig json.RawMessage `json:"auth_config"` ParamSchema json.RawMessage `json:"param_schema"` RequestTemplate string `json:"request_template"` ResponseTemplate string `json:"response_template"` Params map[string]interface{} `json:"params"` } // TestTool tests a custom tool with given parameters without requiring it to be enabled. func (s *CaptainCustomToolService) TestTool(ctx context.Context, accountID uint, req *TestToolRequest) (*TestToolResult, error) { tool := &model.CaptainCustomTool{ AccountID: accountID, Title: req.Title, Description: req.Description, EndpointURL: req.EndpointURL, HTTPMethod: req.HTTPMethod, AuthType: model.ToolAuthType(req.AuthType), AuthConfig: req.AuthConfig, ParamSchema: req.ParamSchema, RequestTemplate: req.RequestTemplate, ResponseTemplate: req.ResponseTemplate, Enabled: true, } if req.ToolID != 0 { stored, err := s.toolRepo.GetByID(ctx, req.ToolID) if err != nil { return nil, fmt.Errorf("custom tool not found: %w", err) } if stored.AccountID != accountID { return nil, fmt.Errorf("custom tool does not belong to this account") } tool = stored } if tool.EndpointURL == "" { return nil, fmt.Errorf("endpoint_url is required") } if tool.HTTPMethod == "" { tool.HTTPMethod = "GET" } if tool.AuthType == "" { tool.AuthType = model.ToolAuthTypeNone } status, body, err := executeCustomToolHTTP(ctx, s.httpClient, tool, req.Params) if err != nil { return nil, err } if len(body) > 500 { body = body[:500] } return &TestToolResult{Status: status, Body: body}, nil } func executeCustomToolHTTP(ctx context.Context, client HTTPDoer, tool *model.CaptainCustomTool, params map[string]interface{}) (int, string, error) { requestBody, err := buildRequestBody(tool.RequestTemplate, params) if err != nil { return 0, "", fmt.Errorf("build request body: %w", err) } method := strings.ToUpper(tool.HTTPMethod) var httpReq *http.Request if method == "GET" || method == "DELETE" { httpReq, err = http.NewRequestWithContext(ctx, method, tool.EndpointURL, nil) } else { httpReq, err = http.NewRequestWithContext(ctx, method, tool.EndpointURL, bytes.NewReader(requestBody)) } if err != nil { return 0, "", fmt.Errorf("create HTTP request: %w", err) } if method != "GET" && method != "DELETE" { httpReq.Header.Set("Content-Type", "application/json") } if err := applyAuth(httpReq, tool); err != nil { return 0, "", fmt.Errorf("apply auth: %w", err) } resp, err := client.Do(httpReq) if err != nil { return 0, "", err } defer resp.Body.Close() respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return 0, "", fmt.Errorf("read response: %w", err) } return resp.StatusCode, string(respBody), nil }