package service import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "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 } // NewCaptainCustomToolService creates a new CaptainCustomToolService. func NewCaptainCustomToolService(toolRepo *repository.CaptainCustomToolRepo) *CaptainCustomToolService { return &CaptainCustomToolService{toolRepo: toolRepo} } // --- Request DTOs --- // CreateCustomToolRequest is the DTO for creating a custom tool. type CreateCustomToolRequest struct { Title string `json:"title" validate:"required"` Slug string `json:"slug" validate:"required"` 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) { // Default values httpMethod := req.HTTPMethod if httpMethod == "" { httpMethod = "GET" } authType := req.AuthType if authType == "" { authType = "none" } tool := &model.CaptainCustomTool{ AccountID: accountID, Title: req.Title, Slug: req.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 := 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 } // 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 } // 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 } // 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 } // --- 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"` } // 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 client := &http.Client{Timeout: 30 * time.Second} resp, err := client.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" validate:"required"` 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) (*ExecuteToolResult, error) { tool, err := s.toolRepo.GetByID(ctx, req.ToolID) if err != nil { return nil, fmt.Errorf("custom tool not found: %w", err) } // Verify the tool belongs to the specified account if tool.AccountID != accountID { return nil, fmt.Errorf("custom tool does not belong to this account") } // TestTool can execute even disabled tools (for validation before enabling) result, err := s.ExecuteTool(ctx, req.ToolID, req.Params) if err != nil { return &ExecuteToolResult{ Success: false, Error: fmt.Sprintf("test execution failed: %v", err), }, nil } return result, nil }