Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
745 lines
23 KiB
Go
745 lines
23 KiB
Go
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
|
|
}
|