548 lines
17 KiB
Go
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
|
|
}
|