feat(public): implement chatwoot public inbox flow

This commit is contained in:
2026-06-04 22:21:52 +08:00
parent a0003c7c06
commit 8dce39f85f
6 changed files with 880 additions and 19 deletions
+327
View File
@@ -735,6 +735,202 @@ func (h *WidgetHandler) AddDyteParticipantToMeeting(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
func (h *WidgetHandler) PublicInboxShow(c *gin.Context) {
inbox, identityValidation, err := h.widgetService.PublicGetInbox(c.Request.Context(), c.Param("inbox_id"))
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
payload := publicInboxPayload(inbox)
payload["identifier"] = c.Param("inbox_id")
payload["identity_validation_enabled"] = identityValidation
c.JSON(http.StatusOK, payload)
}
func (h *WidgetHandler) PublicCreateContact(c *gin.Context) {
req, err := bindPublicContactRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
resp, err := h.widgetService.PublicCreateContact(c.Request.Context(), c.Param("inbox_id"), req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact))
}
func (h *WidgetHandler) PublicGetContact(c *gin.Context) {
resp, err := h.widgetService.PublicGetContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"))
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact))
}
func (h *WidgetHandler) PublicUpdateContact(c *gin.Context) {
req, err := bindPublicContactRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
resp, err := h.widgetService.PublicUpdateContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicContactPayload(resp.ContactInbox, resp.Contact))
}
func (h *WidgetHandler) PublicListConversations(c *gin.Context) {
conversations, err := h.widgetService.PublicListConversations(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"))
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
payload := make([]gin.H, 0, len(conversations))
for _, conversation := range conversations {
payload = append(payload, publicConversationPayload(conversation, nil))
}
c.JSON(http.StatusOK, payload)
}
func (h *WidgetHandler) PublicCreateConversation(c *gin.Context) {
var req struct {
CustomAttributes map[string]any `json:"custom_attributes"`
}
if err := c.ShouldBindJSON(&req); err != nil && c.Request.ContentLength != 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
conversation, err := h.widgetService.PublicCreateConversation(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), service.PublicConversationRequest{CustomAttributes: req.CustomAttributes})
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicConversationPayload(*conversation, nil))
}
func (h *WidgetHandler) PublicGetConversation(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
conversation, err := h.widgetService.PublicGetConversation(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
messages, _, _, _ := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, 0, 100)
c.JSON(http.StatusOK, publicConversationPayload(*conversation, messages))
}
func (h *WidgetHandler) PublicToggleStatus(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
conversation, err := h.widgetService.PublicToggleStatus(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicConversationPayload(*conversation, nil))
}
func (h *WidgetHandler) PublicToggleTyping(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
var req struct {
TypingStatus string `json:"typing_status"`
}
_ = c.ShouldBindJSON(&req)
if req.TypingStatus != "on" && req.TypingStatus != "off" {
c.Status(http.StatusOK)
return
}
if err := h.widgetService.PublicToggleTyping(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req.TypingStatus != "off"); err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *WidgetHandler) PublicUpdateLastSeen(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
if _, err := h.widgetService.PublicUpdateLastSeen(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID); err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *WidgetHandler) PublicListMessages(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "25"))
messages, _, conversation, err := h.widgetService.PublicListMessages(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, offset, limit)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
payload := make([]gin.H, 0, len(messages))
for _, message := range messages {
payload = append(payload, publicMessagePayload(message, *conversation))
}
c.JSON(http.StatusOK, payload)
}
func (h *WidgetHandler) PublicCreateMessage(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
req, err := bindPublicMessageRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
message, conversation, err := h.widgetService.PublicCreateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicMessagePayload(*message, *conversation))
}
func (h *WidgetHandler) PublicUpdateMessage(c *gin.Context) {
conversationID, ok := publicUintParam(c, "conversation_id")
if !ok {
return
}
messageID, ok := publicUintParam(c, "message_id")
if !ok {
return
}
req, err := bindPublicMessageRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
message, conversation, err := h.widgetService.PublicUpdateMessage(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), conversationID, messageID, req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicMessagePayload(*message, *conversation))
}
// SubmitOfflineMessage handles a visitor submitting a message when agents are offline.
// POST /widget/offline_message
// Reference: Chatwoot widget SDK — when business_hours are disabled and no agents online,
@@ -863,6 +1059,137 @@ func widgetContactFullPayload(contact *model.Contact) gin.H {
}
}
func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, error) {
var body struct {
SourceID string `json:"source_id"`
Identifier string `json:"identifier"`
IdentifierHash string `json:"identifier_hash"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
PhoneNumber string `json:"phone_number"`
CustomAttributes map[string]any `json:"custom_attributes"`
}
if err := c.ShouldBindJSON(&body); err != nil && c.Request.ContentLength != 0 {
return service.PublicContactRequest{}, err
}
if body.SourceID == "" {
body.SourceID = c.Query("source_id")
}
if body.Identifier == "" {
body.Identifier = c.Query("identifier")
}
if body.IdentifierHash == "" {
body.IdentifierHash = c.Query("identifier_hash")
}
return service.PublicContactRequest{
SourceID: body.SourceID,
Identifier: body.Identifier,
IdentifierHash: body.IdentifierHash,
Email: body.Email,
Name: body.Name,
AvatarURL: body.AvatarURL,
PhoneNumber: body.PhoneNumber,
CustomAttributes: body.CustomAttributes,
}, nil
}
func bindPublicMessageRequest(c *gin.Context) (service.PublicMessageRequest, error) {
var body struct {
Content string `json:"content"`
EchoID string `json:"echo_id"`
SubmittedValues []map[string]any `json:"submitted_values"`
}
if err := c.ShouldBindJSON(&body); err != nil {
return service.PublicMessageRequest{}, err
}
return service.PublicMessageRequest{
Content: body.Content,
EchoID: body.EchoID,
SubmittedValues: body.SubmittedValues,
}, nil
}
func publicInboxPayload(inbox *model.Inbox) gin.H {
workingHours := map[string]any{}
return gin.H{
"name": inbox.Name,
"timezone": inbox.Timezone,
"working_hours": workingHours,
"working_hours_enabled": inbox.WorkingHoursEnabled,
"csat_survey_enabled": inbox.CsatSurveyEnabled,
"greeting_enabled": inbox.GreetingEnabled,
}
}
func publicContactPayload(contactInbox *model.ContactInbox, contact *model.Contact) gin.H {
return gin.H{
"source_id": contactInbox.SourceID,
"pubsub_token": contactInbox.PubsubToken,
"id": contact.ID,
"name": contact.Name,
"email": contact.Email,
"phone_number": contact.PhoneNumber,
}
}
func publicConversationPayload(conversation model.Conversation, messages []model.Message) gin.H {
payload := gin.H{
"id": publicDisplayID(conversation),
"uuid": conversation.UUID,
"inbox_id": conversation.InboxID,
"contact_last_seen_at": publicUnix(conversation.ContactLastSeenAt),
"status": conversation.Status,
"agent_last_seen_at": publicUnix(conversation.AgentLastSeenAt),
"contact": gin.H{"id": conversation.ContactID},
}
messagePayloads := make([]gin.H, 0, len(messages))
for _, message := range messages {
messagePayloads = append(messagePayloads, publicMessagePayload(message, conversation))
}
payload["messages"] = messagePayloads
return payload
}
func publicMessagePayload(message model.Message, conversation model.Conversation) gin.H {
payload := gin.H{
"id": message.ID,
"content": message.Content,
"message_type": message.MessageType,
"content_type": message.ContentType,
"content_attributes": message.ContentAttributes,
"created_at": message.CreatedAt.Unix(),
"conversation_id": publicDisplayID(conversation),
}
if message.SenderID != nil {
payload["sender"] = gin.H{"id": *message.SenderID, "type": message.SenderType}
}
return payload
}
func publicUintParam(c *gin.Context, name string) (uint, bool) {
value, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || value == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": name + " must be a positive integer"})
return 0, false
}
return uint(value), true
}
func publicDisplayID(conversation model.Conversation) uint {
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
return *conversation.DisplayID
}
return conversation.ID
}
func publicUnix(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func widgetErrorStatus(err error) int {
msg := err.Error()
if strings.Contains(msg, "invalid widget_token") || strings.Contains(msg, "HMAC failed") {
@@ -22,6 +22,7 @@ import (
"github.com/gochat/gochat/internal/campaign"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
ws "github.com/gochat/gochat/internal/ws"
@@ -62,6 +63,7 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler
&model.AccountUser{},
&model.InboxMember{},
&model.Tag{},
&channelmodel.ChannelAPI{},
&campaign.Campaign{},
))
@@ -122,6 +124,34 @@ func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler
chatwootWidgetGroup.POST("/integrations/dyte/add_participant_to_meeting", handler.AddDyteParticipantToMeeting)
}
publicInboxes := router.Group("/public/api/v1/inboxes")
{
publicInboxes.GET("/:inbox_id", handler.PublicInboxShow)
contacts := publicInboxes.Group("/:inbox_id/contacts")
{
contacts.POST("", handler.PublicCreateContact)
contacts.GET("/:contact_id", handler.PublicGetContact)
contacts.PUT("/:contact_id", handler.PublicUpdateContact)
contacts.PATCH("/:contact_id", handler.PublicUpdateContact)
conversations := contacts.Group("/:contact_id/conversations")
{
conversations.GET("", handler.PublicListConversations)
conversations.POST("", handler.PublicCreateConversation)
conversations.GET("/:conversation_id", handler.PublicGetConversation)
conversations.POST("/:conversation_id/toggle_status", handler.PublicToggleStatus)
conversations.POST("/:conversation_id/toggle_typing", handler.PublicToggleTyping)
conversations.POST("/:conversation_id/update_last_seen", handler.PublicUpdateLastSeen)
messages := conversations.Group("/:conversation_id/messages")
{
messages.GET("", handler.PublicListMessages)
messages.POST("", handler.PublicCreateMessage)
messages.PUT("/:message_id", handler.PublicUpdateMessage)
messages.PATCH("/:message_id", handler.PublicUpdateMessage)
}
}
}
}
return db, router, handler
}
@@ -152,6 +182,32 @@ func seedWidgetHandlerData(t *testing.T, db *gorm.DB) (*model.Account, *model.In
return account, inbox
}
func seedPublicAPIInbox(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox, *channelmodel.ChannelAPI) {
t.Helper()
account := &model.Account{Name: "PublicAPIOrg", Locale: "en", Status: "active"}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{
AccountID: account.ID,
Name: "Public API Inbox",
ChannelType: "api",
ChannelID: 1,
Enabled: true,
Timezone: "UTC",
GreetingEnabled: true,
CsatSurveyEnabled: true,
WorkingHoursEnabled: false,
AllowMessagesAfterResolved: true,
}
require.NoError(t, db.Create(inbox).Error)
channelAPI := &channelmodel.ChannelAPI{InboxID: inbox.ID, Identifier: "public-api-inbox", HMACToken: "public_hmac_secret"}
require.NoError(t, db.Create(channelAPI).Error)
return account, inbox, channelAPI
}
// ========== Init Handler Tests ==========
func TestWidgetHandler_Init_Success(t *testing.T) {
@@ -1053,3 +1109,105 @@ func TestWidgetHandler_Init_HMACInvalid(t *testing.T) {
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.NotEmpty(t, resp["widget_token"])
}
func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
_, inbox, _ := seedPublicAPIInbox(t, db)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var inboxResp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &inboxResp))
assert.Equal(t, "public-api-inbox", inboxResp["identifier"])
assert.Equal(t, inbox.Name, inboxResp["name"])
assert.Equal(t, true, inboxResp["identity_validation_enabled"])
contactBody := map[string]any{
"source_id": "public-source-1",
"name": "Public Visitor",
"email": "Visitor@Public.test",
"phone_number": "+15550001111",
"custom_attributes": map[string]any{
"plan": "trial",
},
}
contactJSON, _ := json.Marshal(contactBody)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts", bytes.NewReader(contactJSON))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var contactResp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &contactResp))
assert.Equal(t, "public-source-1", contactResp["source_id"])
assert.Equal(t, "Public Visitor", contactResp["name"])
assert.Equal(t, "visitor@public.test", contactResp["email"])
assert.NotEmpty(t, contactResp["pubsub_token"])
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations", bytes.NewReader([]byte(`{"custom_attributes":{"topic":"sales"}}`)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var conversationResp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conversationResp))
conversationID := strconv.FormatUint(uint64(conversationResp["id"].(float64)), 10)
assert.Equal(t, "open", conversationResp["status"])
messageBody := map[string]any{"content": "Hello from public API", "echo_id": "echo-1"}
messageJSON, _ := json.Marshal(messageBody)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages", bytes.NewReader(messageJSON))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var messageResp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messageResp))
messageID := strconv.FormatUint(uint64(messageResp["id"].(float64)), 10)
assert.Equal(t, "Hello from public API", messageResp["content"])
assert.Equal(t, "incoming", messageResp["message_type"])
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var messagesResp []map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messagesResp))
require.Len(t, messagesResp, 1)
assert.Equal(t, "Hello from public API", messagesResp[0]["content"])
updateBody := map[string]any{"submitted_values": []map[string]any{{"name": "email", "value": "visitor@public.test"}}}
updateJSON, _ := json.Marshal(updateBody)
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages/"+messageID, bytes.NewReader(updateJSON))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messageResp))
attrs := messageResp["content_attributes"].(map[string]any)
assert.NotEmpty(t, attrs["submitted_values"])
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/update_last_seen", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_typing", bytes.NewReader([]byte(`{"typing_status":"on"}`)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}