H-337: restore Web widget reply visibility (#64)
* H-337: restore widget reply delivery * H-337: harden widget conversation ownership --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -204,7 +204,11 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
payload := widgetMessagePayload(resp.Message, resp.ConversationID)
|
||||
conversationID := resp.ConversationID
|
||||
if conversation, lookupErr := h.widgetService.GetConversation(c.Request.Context(), widgetToken, resp.ConversationID); lookupErr == nil {
|
||||
conversationID = widgetConversationID(*conversation)
|
||||
}
|
||||
payload := widgetMessagePayload(resp.Message, conversationID)
|
||||
if len(resp.Attachments) > 0 {
|
||||
payload["attachments"] = widgetAttachmentPayloads(resp.Attachments)
|
||||
}
|
||||
@@ -271,8 +275,12 @@ func (h *WidgetHandler) GetLatestMessages(c *gin.Context) {
|
||||
}
|
||||
|
||||
payload := make([]gin.H, 0, len(messages))
|
||||
conversationID := uint(0)
|
||||
if conversation != nil {
|
||||
conversationID = widgetConversationID(*conversation)
|
||||
}
|
||||
for _, msg := range messages {
|
||||
messagePayload := widgetMessagePayload(msg, msg.ConversationID)
|
||||
messagePayload := widgetMessagePayload(msg, conversationID)
|
||||
if attachments, err := h.widgetService.GetMessageAttachments(c.Request.Context(), msg.ID); err == nil && len(attachments) > 0 {
|
||||
messagePayload["attachments"] = widgetAttachmentPayloads(attachments)
|
||||
}
|
||||
@@ -350,7 +358,7 @@ func (h *WidgetHandler) CreateConversation(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
payload := widgetConversationPayload(*conversation)
|
||||
payload["messages"] = []gin.H{widgetMessagePayload(resp.Message, resp.ConversationID)}
|
||||
payload["messages"] = []gin.H{widgetMessagePayload(resp.Message, widgetConversationID(*conversation))}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
@@ -1275,7 +1283,7 @@ func widgetMessagePayload(message model.Message, conversationID uint) gin.H {
|
||||
"content": message.Content,
|
||||
"inbox_id": message.InboxID,
|
||||
"conversation_id": conversationID,
|
||||
"message_type": message.MessageType,
|
||||
"message_type": widgetMessageType(message.MessageType),
|
||||
"content_type": message.ContentType,
|
||||
"content_attributes": webhookutil.SanitizeOutboundJSON(message.ContentAttributes),
|
||||
"created_at": message.CreatedAt.Unix(),
|
||||
@@ -1284,6 +1292,26 @@ func widgetMessagePayload(message model.Message, conversationID uint) gin.H {
|
||||
}
|
||||
}
|
||||
|
||||
func widgetMessageType(value string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "incoming":
|
||||
return 0
|
||||
case "activity":
|
||||
return 2
|
||||
case "template":
|
||||
return 3
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func widgetConversationID(conversation model.Conversation) uint {
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
||||
return *conversation.DisplayID
|
||||
}
|
||||
return conversation.ID
|
||||
}
|
||||
|
||||
func widgetAttachmentPayloads(attachments []model.Attachment) []gin.H {
|
||||
payload := make([]gin.H, 0, len(attachments))
|
||||
for _, attachment := range attachments {
|
||||
|
||||
@@ -482,7 +482,7 @@ func TestWidgetHandler_ChatwootConversationQueryTokenReusesSession(t *testing.T)
|
||||
var messageResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp))
|
||||
assert.Equal(t, "Popout session message", messageResp["content"])
|
||||
assert.Equal(t, "incoming", messageResp["message_type"])
|
||||
assert.Equal(t, float64(0), messageResp["message_type"])
|
||||
assertWidgetMessageFixtureShape(t, messageResp)
|
||||
|
||||
wLatest := httptest.NewRecorder()
|
||||
@@ -740,6 +740,22 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
|
||||
assert.Nil(t, messageResp["message"])
|
||||
assert.Equal(t, "Hello from Chatwoot widget", messageResp["content"])
|
||||
assert.NotEmpty(t, messageResp["conversation_id"])
|
||||
assert.Equal(t, float64(0), messageResp["message_type"])
|
||||
|
||||
var conversation model.Conversation
|
||||
require.NoError(t, db.First(&conversation).Error)
|
||||
displayID := uint(42)
|
||||
require.NoError(t, db.Model(&conversation).Update("display_id", displayID).Error)
|
||||
require.NoError(t, db.Create(&model.Message{
|
||||
ConversationID: conversation.ID,
|
||||
AccountID: conversation.AccountID,
|
||||
InboxID: conversation.InboxID,
|
||||
Content: "Dashboard reply visible after refresh",
|
||||
ContentType: "text",
|
||||
MessageType: string(model.MessageTypeOutgoing),
|
||||
SenderType: string(model.SenderTypeUser),
|
||||
Status: "sent",
|
||||
}).Error)
|
||||
|
||||
wIndex := httptest.NewRecorder()
|
||||
reqIndex, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil)
|
||||
@@ -750,10 +766,14 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
|
||||
var indexResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wIndex.Body.Bytes(), &indexResp))
|
||||
payload := indexResp["payload"].([]interface{})
|
||||
require.Len(t, payload, 1)
|
||||
require.Len(t, payload, 2)
|
||||
firstMessage := payload[0].(map[string]interface{})
|
||||
assertWidgetMessageFixtureShape(t, firstMessage)
|
||||
assert.Equal(t, "Hello from Chatwoot widget", firstMessage["content"])
|
||||
reply := payload[1].(map[string]interface{})
|
||||
assert.Equal(t, "Dashboard reply visible after refresh", reply["content"])
|
||||
assert.Equal(t, float64(1), reply["message_type"])
|
||||
assert.Equal(t, float64(displayID), reply["conversation_id"])
|
||||
|
||||
wContact := httptest.NewRecorder()
|
||||
reqContact, _ := http.NewRequest("GET", "/api/v1/widget/contact", nil)
|
||||
@@ -791,6 +811,57 @@ func TestWidgetHandler_ChatwootMessagesIndexFiltersInternalMessages(t *testing.T
|
||||
assert.Equal(t, float64(1), indexResp["meta"].(map[string]interface{})["total"])
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ExplicitConversationEntrypointsRejectSiblingToken(t *testing.T) {
|
||||
db, router, _ := setupWidgetHandlerTest(t)
|
||||
account, inbox := seedWidgetHandlerData(t, db)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Shared visitor"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
owner := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "owner-token"}
|
||||
sibling := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "sibling-token"}
|
||||
require.NoError(t, db.Create(owner).Error)
|
||||
require.NoError(t, db.Create(sibling).Error)
|
||||
conversation := &model.Conversation{
|
||||
AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &owner.ID,
|
||||
Status: "open", ChannelType: "web_widget", Channel: "web_widget",
|
||||
}
|
||||
require.NoError(t, db.Create(conversation).Error)
|
||||
message := &model.Message{
|
||||
AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID,
|
||||
Content: "owner only", ContentType: "integrations", MessageType: "outgoing", Status: "sent",
|
||||
ContentAttributes: datatypes.JSON(`{"data":{"meeting_id":"meeting-1"}}`),
|
||||
}
|
||||
require.NoError(t, db.Create(message).Error)
|
||||
|
||||
tests := []struct {
|
||||
name, method, path, body string
|
||||
}{
|
||||
{"messages", http.MethodGet, fmt.Sprintf("/widget/conversations/%d/messages", conversation.ID), ""},
|
||||
{"typing", http.MethodPost, fmt.Sprintf("/widget/conversations/%d/toggle_typing", conversation.ID), `{"typing":true}`},
|
||||
{"message update", http.MethodPatch, fmt.Sprintf("/api/v1/widget/messages/%d", message.ID), `{"message":{"submitted_values":[{"value":"nope"}]}}`},
|
||||
{"dyte", http.MethodPost, "/api/v1/widget/integrations/dyte/add_participant_to_meeting?website_token=handler_ws_token_123", fmt.Sprintf(`{"message_id":%d}`, message.ID)},
|
||||
{"send", http.MethodPost, "/widget/messages", fmt.Sprintf(`{"content":"nope","conversation_id":%d}`, conversation.ID)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||
req.Header.Set("X-Widget-Token", sibling.PubsubToken)
|
||||
if tt.body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, req)
|
||||
assert.GreaterOrEqual(t, response.Code, http.StatusBadRequest, response.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
var messageCount int64
|
||||
require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ?", conversation.ID).Count(&messageCount).Error)
|
||||
assert.Equal(t, int64(1), messageCount)
|
||||
var persisted model.Message
|
||||
require.NoError(t, db.First(&persisted, message.ID).Error)
|
||||
assert.JSONEq(t, `{"data":{"meeting_id":"meeting-1"}}`, string(persisted.ContentAttributes))
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootMessagesIndexReturnsLatestTwenty(t *testing.T) {
|
||||
db, router, _ := setupWidgetHandlerTest(t)
|
||||
_, inbox := seedWidgetHandlerData(t, db)
|
||||
|
||||
Reference in New Issue
Block a user