Files
gochat/internal/service/captain_custom_tool_service.go
T

548 lines
17 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"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
httpClient HTTPDoer
}
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
// NewCaptainCustomToolService creates a new CaptainCustomToolService.
func NewCaptainCustomToolService(toolRepo *repository.CaptainCustomToolRepo) *CaptainCustomToolService {
return &CaptainCustomToolService{toolRepo: toolRepo, httpClient: &http.Client{Timeout: 30 * time.Second}}
}
func (s *CaptainCustomToolService) SetHTTPClient(client HTTPDoer) {
if client != nil {
s.httpClient = client
}
}
// --- 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) {
// Default values
httpMethod := req.HTTPMethod
if httpMethod == "" {
httpMethod = "GET"
}
authType := req.AuthType
if authType == "" {
authType = "none"
}
slug := req.Slug
if slug == "" {
slug = customToolSlug(req.Title)
}
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 := 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 := 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 customToolSlug(title string) string {
slug := strings.ToLower(strings.TrimSpace(title))
slug = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
if slug == "" {
return "custom-tool"
}
return slug
}
// --- 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
}