feat(captain): align document response actions

This commit is contained in:
2026-06-05 12:24:16 +08:00
parent fc2cb12e63
commit afccb5f9ae
16 changed files with 877 additions and 160 deletions
@@ -2,9 +2,9 @@ package v1
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
@@ -23,8 +23,8 @@ func NewCaptainAssistantResponseHandler(svc *service.CaptainAssistantResponseSer
// ProcessResponse generates and optionally stores an assistant response.
// POST /api/v1/accounts/:id/captain/assistant_responses
func (h *CaptainAssistantResponseHandler) ProcessResponse(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
@@ -35,7 +35,7 @@ func (h *CaptainAssistantResponseHandler) ProcessResponse(c *gin.Context) {
return
}
result, err := h.svc.ProcessResponse(c.Request.Context(), uint(accountID), &req)
result, err := h.svc.ProcessResponse(c.Request.Context(), accountID, &req)
if err != nil {
applogger.L().Errorf("Process assistant response: %v", err)
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to process assistant response")
@@ -49,60 +49,64 @@ func (h *CaptainAssistantResponseHandler) ProcessResponse(c *gin.Context) {
// GET /api/v1/accounts/:id/captain/assistant_responses
// Reference: Chatwoot Captain::AssistantResponsesController#index
func (h *CaptainAssistantResponseHandler) List(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("per_page", "25"))
assistantID, _ := strconv.ParseUint(c.Query("assistant_id"), 10, 64)
documentID, _ := strconv.ParseUint(c.Query("document_id"), 10, 64)
page, _ := parseIntQueryDefault(c, "page", 1)
pageSize := 25
assistantID, _ := parseOptionalUintQueryParam(c, "assistant_id")
documentID, _ := parseOptionalUintQueryParam(c, "document_id")
status := c.Query("status")
search := c.Query("search")
responses, total, err := h.svc.List(c.Request.Context(), uint(accountID), uint(assistantID), uint(documentID), status, search, page, pageSize)
responses, total, err := h.svc.List(c.Request.Context(), accountID, assistantID, documentID, status, search, page, pageSize)
if err != nil {
applogger.L().Errorf("List assistant responses: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list responses")
return
}
response.OKWithMeta(c, responses, page, pageSize, total)
payload := make([]gin.H, 0, len(responses))
for i := range responses {
payload = append(payload, captainAssistantResponsePayload(&responses[i]))
}
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": total, "page": page}})
}
// Get returns a single assistant response.
// GET /api/v1/accounts/:id/captain/assistant_responses/:response_id
// Reference: Chatwoot Captain::AssistantResponsesController#show
func (h *CaptainAssistantResponseHandler) Get(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
responseID, err := strconv.ParseUint(c.Param("response_id"), 10, 64)
responseID, err := parseUintParam(c, "response_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid response_id")
return
}
resp, err := h.svc.Get(c.Request.Context(), uint(accountID), uint(responseID))
resp, err := h.svc.Get(c.Request.Context(), accountID, responseID)
if err != nil {
applogger.L().Errorf("Get assistant response: %v", err)
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "response not found")
return
}
response.OK(c, resp)
c.JSON(http.StatusOK, captainAssistantResponsePayload(resp))
}
// Update modifies an assistant response.
// PUT /api/v1/accounts/:id/captain/assistant_responses/:response_id
// Reference: Chatwoot Captain::AssistantResponsesController#update
func (h *CaptainAssistantResponseHandler) Update(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
responseID, err := strconv.ParseUint(c.Param("response_id"), 10, 64)
responseID, err := parseUintParam(c, "response_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid response_id")
return
@@ -112,34 +116,34 @@ func (h *CaptainAssistantResponseHandler) Update(c *gin.Context) {
Answer string `json:"answer"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
if err := bindNestedJSONPayload(c, "assistant_response", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
resp, err := h.svc.Update(c.Request.Context(), uint(accountID), uint(responseID), req.Question, req.Answer, req.Status)
resp, err := h.svc.Update(c.Request.Context(), accountID, responseID, req.Question, req.Answer, req.Status)
if err != nil {
applogger.L().Errorf("Update assistant response: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update response")
return
}
response.OK(c, resp)
c.JSON(http.StatusOK, captainAssistantResponsePayload(resp))
}
// Delete removes an assistant response.
// DELETE /api/v1/accounts/:id/captain/assistant_responses/:response_id
// Reference: Chatwoot Captain::AssistantResponsesController#destroy (head :no_content)
func (h *CaptainAssistantResponseHandler) Delete(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
responseID, err := strconv.ParseUint(c.Param("response_id"), 10, 64)
responseID, err := parseUintParam(c, "response_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid response_id")
return
}
if err := h.svc.Delete(c.Request.Context(), uint(accountID), uint(responseID)); err != nil {
if err := h.svc.Delete(c.Request.Context(), accountID, responseID); err != nil {
applogger.L().Errorf("Delete assistant response: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete response")
return
@@ -152,16 +156,12 @@ func (h *CaptainAssistantResponseHandler) Delete(c *gin.Context) {
// POST /api/v1/accounts/:id/captain/assistant_responses
// Reference: Chatwoot Captain::AssistantResponsesController#create
func (h *CaptainAssistantResponseHandler) Create(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
userID := getUserID(c)
if userID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
return
}
var req struct {
Question string `json:"question" validate:"required"`
@@ -169,16 +169,49 @@ func (h *CaptainAssistantResponseHandler) Create(c *gin.Context) {
AssistantID uint `json:"assistant_id" validate:"required"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
if err := bindNestedJSONPayload(c, "assistant_response", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
resp, err := h.svc.Create(c.Request.Context(), uint(accountID), userID, req.AssistantID, req.Question, req.Answer, req.Status)
resp, err := h.svc.Create(c.Request.Context(), accountID, userID, req.AssistantID, req.Question, req.Answer, req.Status)
if err != nil {
applogger.L().Errorf("Create assistant response: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create response")
return
}
response.OK(c, resp)
c.JSON(http.StatusOK, captainAssistantResponsePayload(resp))
}
func captainAssistantResponsePayload(resp *model.CaptainAssistantResponse) gin.H {
payload := gin.H{
"account_id": resp.AccountID,
"answer": resp.Answer,
"assistant": captainResponseAssistantPayload(resp),
"created_at": resp.CreatedAt.Unix(),
"id": resp.ID,
"question": resp.Question,
"updated_at": resp.UpdatedAt.Unix(),
"status": resp.Status,
"edited": resp.Edited,
}
if resp.DocumentableID != nil {
payload["documentable"] = captainResponseDocumentablePayload(resp)
}
return payload
}
func captainResponseAssistantPayload(resp *model.CaptainAssistantResponse) gin.H {
if resp.Assistant.ID == 0 {
return gin.H{"id": resp.AssistantID}
}
return captainAssistantPayload(&resp.Assistant)
}
func captainResponseDocumentablePayload(resp *model.CaptainAssistantResponse) gin.H {
payload := gin.H{"type": resp.DocumentableType, "id": *resp.DocumentableID}
if resp.DocumentableType == "Conversation" {
payload["display_id"] = *resp.DocumentableID
}
return payload
}
@@ -1,8 +1,8 @@
package v1
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/service"
@@ -23,14 +23,26 @@ func NewCaptainBulkActionHandler(svc *service.CaptainBulkActionService) *Captain
// Execute performs a bulk AI action on multiple conversations.
// POST /api/v1/accounts/:id/captain/bulk_actions
func (h *CaptainBulkActionHandler) Execute(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
body, err := c.GetRawData()
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
var chatwootReq service.ChatwootBulkActionRequest
if err := json.Unmarshal(body, &chatwootReq); err == nil && chatwootReq.Type != "" {
h.executeChatwoot(c, accountID, &chatwootReq)
return
}
var req service.BulkActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err := json.Unmarshal(body, &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
@@ -51,7 +63,7 @@ func (h *CaptainBulkActionHandler) Execute(c *gin.Context) {
return
}
result, err := h.svc.Execute(c.Request.Context(), uint(accountID), &req)
result, err := h.svc.Execute(c.Request.Context(), accountID, &req)
if err != nil {
applogger.L().Errorf("Bulk action: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to execute bulk action")
@@ -60,3 +72,30 @@ func (h *CaptainBulkActionHandler) Execute(c *gin.Context) {
response.OK(c, result)
}
func (h *CaptainBulkActionHandler) executeChatwoot(c *gin.Context, accountID uint, req *service.ChatwootBulkActionRequest) {
result, err := h.svc.ExecuteChatwoot(c.Request.Context(), accountID, req)
if err != nil {
applogger.L().Errorf("Chatwoot bulk action: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
return
}
if result.Empty {
c.JSON(http.StatusOK, []gin.H{})
return
}
if result.AssistantResponses != nil {
payload := make([]gin.H, 0, len(result.AssistantResponses))
for i := range result.AssistantResponses {
payload = append(payload, captainAssistantResponsePayload(&result.AssistantResponses[i]))
}
c.JSON(http.StatusOK, payload)
return
}
if result.IDs != nil {
c.JSON(http.StatusOK, gin.H{"ids": result.IDs, "count": result.Count})
return
}
c.JSON(http.StatusOK, gin.H{"count": result.Count})
}
@@ -189,7 +189,7 @@ func (h *CaptainCustomToolHandler) TestTool(c *gin.Context) {
result, err := h.svc.TestTool(c.Request.Context(), accountID, &req)
if err != nil {
applogger.L().Errorf("TestTool: %v", err)
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to test tool")
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
@@ -54,13 +54,13 @@ func (s *CaptainCustomToolTestHandlerTestSuite) SetupSuite() {
s.Require().NoError(db.Create(account).Error)
s.account = account
// 创建测试自定义工具(指向 httpbin.org 的 GET 端点,测试时替换为本地mock)
// 创建测试自定义工具
tool := &model.CaptainCustomTool{
AccountID: account.ID,
Title: "测试工具",
Slug: "test-tool",
Description: "用于测试的工具",
EndpointURL: "https://httpbin.org/get",
EndpointURL: "http://tool.test",
HTTPMethod: "GET",
AuthType: model.ToolAuthTypeNone,
Enabled: true,
@@ -71,6 +71,9 @@ func (s *CaptainCustomToolTestHandlerTestSuite) SetupSuite() {
// 创建 repo + service + handler
toolRepo := repository.NewCaptainCustomToolRepo(db)
svc := service.NewCaptainCustomToolService(toolRepo)
svc.SetHTTPClient(fakeCaptainToolHTTPDoer(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusCreated, Body: http.NoBody, Header: make(http.Header)}, nil
}))
s.handler = NewCaptainCustomToolHandler(svc)
// 设置路由
@@ -108,16 +111,11 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_成功测试工具(
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// httpbin.org 可能不可达,所以允许成功或失败
// 关键是接口返回正确格式
if w.Code == http.StatusOK {
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Contains(s.T(), resp, "success")
} else {
// 即使外部请求失败,也应该返回 InternalServerError 格式
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
}
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(s.T(), float64(http.StatusCreated), resp["status"])
assert.Contains(s.T(), resp, "body")
}
func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_无效accountID() {
@@ -162,7 +160,6 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_不存在的工具I
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// 不存在的工具ID应返回500
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
}
@@ -172,7 +169,7 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_带POST方法和参
AccountID: s.account.ID,
Title: "POST测试工具",
Slug: "post-test-tool",
EndpointURL: "https://httpbin.org/post",
EndpointURL: "http://tool.test",
HTTPMethod: "POST",
AuthType: model.ToolAuthTypeNone,
RequestTemplate: "{\"message\": \"{{.message}}\"}",
@@ -193,12 +190,10 @@ func (s *CaptainCustomToolTestHandlerTestSuite) TestTestTool_带POST方法和参
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// httpbin.org 可能不可达,关键检查返回格式
if w.Code == http.StatusOK {
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Contains(s.T(), resp, "success")
}
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(s.T(), float64(http.StatusCreated), resp["status"])
}
func TestCaptainCustomToolTestHandlerSuite(t *testing.T) {
@@ -5,9 +5,9 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/pagination"
"github.com/gochat/gochat/pkg/response"
)
@@ -25,50 +25,58 @@ func NewCaptainDocumentHandler(svc *service.CaptainDocumentService) *CaptainDocu
// Create creates a new captain document.
// POST /api/v1/accounts/:account_id/captain_assistants/:assistant_id/documents
func (h *CaptainDocumentHandler) Create(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
assistantID, err := strconv.ParseUint(c.Param("assistant_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant_id")
return
}
var req service.CreateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err := bindNestedJSONPayload(c, "document", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
doc, err := h.svc.Create(c.Request.Context(), uint(assistantID), uint(accountID), &req)
if err != nil {
applogger.L().Errorf("Create captain document: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create document")
assistantID := req.AssistantID
if assistantID == 0 {
assistantID, _ = parseUintParam(c, "assistant_id")
}
if assistantID == 0 {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "Missing Assistant")
return
}
response.Created(c, doc)
doc, err := h.svc.Create(c.Request.Context(), assistantID, accountID, &req)
if err != nil {
applogger.L().Errorf("Create captain document: %v", err)
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "failed to create document")
return
}
c.JSON(http.StatusOK, captainDocumentPayload(doc))
}
// Get retrieves a captain document by ID.
// GET /api/v1/accounts/:account_id/captain_documents/:id
func (h *CaptainDocumentHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
id, err := parseUintAnyParam(c, "document_id", "id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
doc, err := h.svc.Get(c.Request.Context(), uint(id))
doc, err := h.svc.GetByAccount(c.Request.Context(), accountID, id)
if err != nil {
applogger.L().Errorf("Get captain document: %v", err)
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "document not found")
return
}
response.OK(c, doc)
c.JSON(http.StatusOK, captainDocumentPayload(doc))
}
// Update updates an existing captain document.
@@ -99,15 +107,20 @@ func (h *CaptainDocumentHandler) Update(c *gin.Context) {
// Delete deletes a captain document.
// DELETE /api/v1/accounts/:account_id/captain_documents/:id
func (h *CaptainDocumentHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
id, err := parseUintAnyParam(c, "document_id", "id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
if err := h.svc.Delete(c.Request.Context(), uint(id)); err != nil {
if err := h.svc.DeleteByAccount(c.Request.Context(), accountID, id); err != nil {
applogger.L().Errorf("Delete captain document: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete document")
response.AbortWithStatusError(c, captainAssistantErrorStatus(err), response.ErrInternal, "failed to delete document")
return
}
@@ -117,21 +130,36 @@ func (h *CaptainDocumentHandler) Delete(c *gin.Context) {
// List retrieves documents for an assistant.
// GET /api/v1/accounts/:account_id/captain_assistants/:assistant_id/documents
func (h *CaptainDocumentHandler) List(c *gin.Context) {
assistantID, err := strconv.ParseUint(c.Param("assistant_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid assistant_id")
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
p := pagination.Parse(c)
docs, count, err := h.svc.List(c.Request.Context(), uint(assistantID), p.Offset, p.PerPage)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
assistantID, _ := parseOptionalUintQueryParam(c, "assistant_id")
if assistantID == 0 {
assistantID, _ = parseUintParam(c, "assistant_id")
}
docs, count, currentPage, err := h.svc.ListByAccount(c.Request.Context(), accountID, service.ListDocumentsRequest{
AssistantID: assistantID,
Page: page,
PerPage: 25,
Filter: c.Query("filter"),
Source: c.Query("source"),
Sort: c.Query("sort"),
SearchKey: c.Query("search_key"),
})
if err != nil {
applogger.L().Errorf("List captain documents: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list documents")
return
}
response.OKWithMeta(c, docs, p.Page, p.PerPage, count)
payload := make([]gin.H, 0, len(docs))
for i := range docs {
payload = append(payload, captainDocumentPayload(&docs[i]))
}
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": count, "page": currentPage}})
}
// ProcessDocument triggers document content extraction and embedding generation.
@@ -155,17 +183,68 @@ func (h *CaptainDocumentHandler) ProcessDocument(c *gin.Context) {
// SyncDocument triggers re-fetching content from the external URL.
// POST /api/v1/accounts/:account_id/captain_documents/:id/sync
func (h *CaptainDocumentHandler) SyncDocument(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
id, err := parseUintAnyParam(c, "document_id", "id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
if err := h.svc.SyncDocument(c.Request.Context(), uint(id)); err != nil {
if _, err := h.svc.MarkSyncing(c.Request.Context(), accountID, id); err != nil {
applogger.L().Errorf("SyncDocument: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to sync document")
response.AbortWithStatusError(c, captainAssistantErrorStatus(err), response.ErrInternal, "failed to sync document")
return
}
response.OK(c, gin.H{"synced": true})
}
c.Status(http.StatusAccepted)
}
func captainDocumentPayload(doc *model.CaptainDocument) gin.H {
status := doc.Status
if status == "" {
status = model.DocumentStatusPending
}
syncStatus := doc.SyncStatus
if syncStatus == model.DocumentSyncStatusPending {
syncStatus = "syncing"
}
payload := gin.H{
"account_id": doc.AccountID,
"assistant": captainDocumentAssistantPayload(doc),
"content": doc.Content,
"content_type": "text/html",
"created_at": doc.CreatedAt.Unix(),
"external_link": doc.ExternalLink,
"display_url": doc.ExternalLink,
"file_size": 0,
"pdf_document": false,
"id": doc.ID,
"name": doc.Name,
"status": status,
"sync_status": syncStatus,
"sync_in_progress": syncStatus == "syncing" || syncStatus == model.DocumentSyncStatusPending,
"last_synced_at": int64PointerValue(doc.LastSyncedAt),
"last_sync_attempted_at": int64PointerValue(doc.LastSyncAttemptedAt),
"last_sync_error_code": doc.LastSyncErrorCode,
"updated_at": doc.UpdatedAt.Unix(),
}
return payload
}
func captainDocumentAssistantPayload(doc *model.CaptainDocument) gin.H {
if doc.Assistant.ID == 0 {
return gin.H{"id": doc.AssistantID}
}
return captainAssistantPayload(&doc.Assistant)
}
func int64PointerValue(value *int64) any {
if value == nil {
return nil
}
return *value
}
@@ -4,9 +4,11 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -19,6 +21,12 @@ import (
"gorm.io/gorm"
)
type fakeCaptainToolHTTPDoer func(*http.Request) (*http.Response, error)
func (f fakeCaptainToolHTTPDoer) Do(req *http.Request) (*http.Response, error) {
return f(req)
}
func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.Account, *model.CaptainAssistant) {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -28,6 +36,8 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.CaptainAssistant{},
&model.CaptainDocument{},
&model.CaptainAssistantResponse{},
&model.CaptainScenario{},
&model.CaptainCustomTool{},
))
@@ -50,7 +60,19 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model
toolRepo := repository.NewCaptainCustomToolRepo(db)
toolSvc := service.NewCaptainCustomToolService(toolRepo)
toolSvc.SetHTTPClient(fakeCaptainToolHTTPDoer(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Header: make(http.Header)}, nil
}))
toolHandler := NewCaptainCustomToolHandler(toolSvc)
documentRepo := repository.NewCaptainDocumentRepo(db)
documentSvc := service.NewCaptainDocumentService(documentRepo, nil, assistantRepo)
documentHandler := NewCaptainDocumentHandler(documentSvc)
responseRepo := repository.NewCaptainAssistantResponseRepo(db)
responseSvc := service.NewCaptainAssistantResponseService(assistantRepo, responseRepo, nil, nil, nil, nil)
responseHandler := NewCaptainAssistantResponseHandler(responseSvc)
bulkSvc := service.NewCaptainBulkActionService(nil, nil, assistantRepo, nil, nil, nil, responseSvc)
bulkSvc.SetCaptainResourceRepos(responseRepo, documentRepo)
bulkHandler := NewCaptainBulkActionHandler(bulkSvc)
router := gin.New()
accountGroup := router.Group("/api/v1/accounts/:account_id/captain")
@@ -62,12 +84,30 @@ func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model
assistantScenarios.DELETE("/:scenario_id", scenarioHandler.Delete)
customTools := accountGroup.Group("/custom_tools")
customTools.POST("/test", toolHandler.TestTool)
customTools.GET("/", toolHandler.List)
customTools.POST("/", toolHandler.Create)
customTools.GET("/:tool_id", toolHandler.Get)
customTools.PUT("/:tool_id", toolHandler.Update)
customTools.DELETE("/:tool_id", toolHandler.Delete)
documents := accountGroup.Group("/documents")
documents.GET("/", documentHandler.List)
documents.POST("/", documentHandler.Create)
documents.GET("/:document_id", documentHandler.Get)
documents.DELETE("/:document_id", documentHandler.Delete)
documents.POST("/:document_id/sync", documentHandler.SyncDocument)
assistantResponses := accountGroup.Group("/assistant_responses")
assistantResponses.GET("/", responseHandler.List)
assistantResponses.POST("/", responseHandler.Create)
assistantResponses.GET("/:response_id", responseHandler.Get)
assistantResponses.PUT("/:response_id", responseHandler.Update)
assistantResponses.DELETE("/:response_id", responseHandler.Delete)
bulkActions := accountGroup.Group("/bulk_actions")
bulkActions.POST("/", bulkHandler.Execute)
return router, db, account, otherAccount, assistant
}
@@ -179,3 +219,149 @@ func TestCaptainCustomToolHandler_ChatwootToolPayloadsAndScope(t *testing.T) {
w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, toolID), nil)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestCaptainDocumentHandler_ChatwootDocumentPayloadsAndSync(t *testing.T) {
router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t)
basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/documents"
otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/documents"
body := map[string]any{"document": map[string]any{
"name": "Help center",
"external_link": "https://example.com/help",
"assistant_id": assistant.ID,
}}
w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", body)
assert.Equal(t, http.StatusOK, w.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created))
assert.NotContains(t, created, "success")
assert.Equal(t, "Help center", created["name"])
assert.Equal(t, float64(account.ID), created["account_id"])
assert.Equal(t, "Fin", created["assistant"].(map[string]any)["name"])
documentID := uint(created["id"].(float64))
w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?assistant_id="+strconv.FormatUint(uint64(assistant.ID), 10), nil)
assert.Equal(t, http.StatusOK, w.Code)
var listResp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp))
assert.Len(t, listResp["payload"], 1)
assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["total_count"])
w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, documentID), nil)
assert.Equal(t, http.StatusNotFound, w.Code)
w = captainResourceJSONRequest(t, router, http.MethodPost, fmt.Sprintf("%s/%d/sync", basePath, documentID), nil)
assert.Equal(t, http.StatusAccepted, w.Code)
w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", basePath, documentID), nil)
assert.Equal(t, http.StatusOK, w.Code)
var synced map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &synced))
assert.Equal(t, "syncing", synced["sync_status"])
assert.Equal(t, true, synced["sync_in_progress"])
w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, documentID), nil)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestCaptainAssistantResponseHandler_ChatwootResponsePayloadsAndFilters(t *testing.T) {
router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t)
basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/assistant_responses"
otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/assistant_responses"
body := map[string]any{"assistant_response": map[string]any{
"question": "Where is my order?",
"answer": "It ships today.",
"assistant_id": assistant.ID,
"status": "pending",
}}
w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", body)
assert.Equal(t, http.StatusOK, w.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created))
assert.NotContains(t, created, "success")
assert.Equal(t, "pending", created["status"])
assert.Equal(t, "Fin", created["assistant"].(map[string]any)["name"])
responseID := uint(created["id"].(float64))
w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?status=pending&search=order", nil)
assert.Equal(t, http.StatusOK, w.Code)
var listResp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp))
assert.Len(t, listResp["payload"], 1)
assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["total_count"])
w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, responseID), nil)
assert.Equal(t, http.StatusNotFound, w.Code)
updateBody := map[string]any{"assistant_response": map[string]any{"answer": "It shipped.", "status": "approved"}}
w = captainResourceJSONRequest(t, router, http.MethodPut, fmt.Sprintf("%s/%d", basePath, responseID), updateBody)
assert.Equal(t, http.StatusOK, w.Code)
var updated map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updated))
assert.Equal(t, "approved", updated["status"])
assert.Equal(t, true, updated["edited"])
w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, responseID), nil)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestCaptainBulkActionHandler_ChatwootResourceActions(t *testing.T) {
router, db, account, _, assistant := setupCaptainResourceParityTest(t)
basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/bulk_actions"
resp := &model.CaptainAssistantResponse{AccountID: account.ID, AssistantID: assistant.ID, Question: "Q", Answer: "A", Status: model.ResponseStatusPending}
require.NoError(t, db.Create(resp).Error)
w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{
"type": "AssistantResponse",
"ids": []uint{resp.ID},
"fields": map[string]any{
"status": "approve",
},
})
assert.Equal(t, http.StatusOK, w.Code)
var approved []map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &approved))
require.Len(t, approved, 1)
assert.Equal(t, "approved", approved[0]["status"])
doc := &model.CaptainDocument{AccountID: account.ID, AssistantID: assistant.ID, Name: "Doc", ExternalLink: "https://example.com", Status: model.DocumentStatusCompleted, SyncStatus: model.DocumentSyncStatusSynced}
require.NoError(t, db.Create(doc).Error)
w = captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{
"type": "AssistantDocument",
"ids": []uint{doc.ID},
"fields": map[string]any{
"status": "sync",
},
})
assert.Equal(t, http.StatusOK, w.Code)
var syncResp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &syncResp))
assert.Equal(t, float64(1), syncResp["count"])
w = captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{"type": "Unknown", "ids": []uint{1}, "fields": map[string]any{"status": "delete"}})
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
var invalid map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &invalid))
assert.Equal(t, false, invalid["success"])
}
func TestCaptainCustomToolHandler_ChatwootTestToolPayload(t *testing.T) {
router, _, account, _, _ := setupCaptainResourceParityTest(t)
basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/custom_tools/test"
w := captainResourceJSONRequest(t, router, http.MethodPost, basePath, map[string]any{"custom_tool": map[string]any{
"title": "Tester",
"endpoint_url": "http://tool.test",
"http_method": "POST",
"auth_type": "none",
"request_template": `{"value":"{{.value}}"}`,
"params": map[string]any{"value": "ping"},
}})
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, float64(http.StatusCreated), resp["status"])
assert.Equal(t, `{"ok":true}`, resp["body"])
assert.NotContains(t, resp, "success")
}
+24
View File
@@ -16,6 +16,30 @@ func parseUintParam(c *gin.Context, param string) (uint, error) {
return uint(n), nil
}
func parseOptionalUintQueryParam(c *gin.Context, param string) (uint, error) {
val := c.Query(param)
if val == "" {
return 0, nil
}
n, err := strconv.ParseUint(val, 10, 32)
if err != nil {
return 0, err
}
return uint(n), nil
}
func parseIntQueryDefault(c *gin.Context, param string, fallback int) (int, error) {
val := c.Query(param)
if val == "" {
return fallback, nil
}
n, err := strconv.Atoi(val)
if err != nil {
return fallback, err
}
return n, nil
}
func parseUintAnyParam(c *gin.Context, params ...string) (uint, error) {
var lastErr error
for _, param := range params {