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:
Rogee
2026-08-20 22:22:27 +08:00
committed by GitHub
co-authored by rogee
parent abb1bed424
commit b31b1b9562
22 changed files with 847 additions and 83 deletions
+40 -33
View File
@@ -29,6 +29,7 @@ var (
ErrWidgetConversationNotFound = errors.New("conversation not found")
ErrWidgetEndConversationDisabled = errors.New("end conversation is not permitted")
ErrWidgetMessageContentTooLong = errors.New("Content is too long (maximum is 150000 characters)")
errWidgetConversationOwnership = errors.New("conversation does not belong to this contact")
)
const widgetMessageContentLimit = 150000
@@ -368,16 +369,12 @@ func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRe
var conversation *model.Conversation
conversationCreated := false
if req.ConversationID != nil {
conversation, err = s.conversationRepo.FindByID(ctx, *req.ConversationID)
conversation, err = s.resolveWidgetConversation(ctx, contactInbox, *req.ConversationID)
if err != nil {
return nil, fmt.Errorf("conversation not found: %w", err)
}
// Verify conversation belongs to this contact
if conversation.ContactID != contactInbox.ContactID {
return nil, errors.New("conversation does not belong to this contact")
}
} else {
conversations, _, findErr := s.conversationRepo.FindByContact(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 1)
conversations, _, findErr := s.findWidgetConversations(ctx, contactInbox, 0, 1)
if findErr != nil {
return nil, findErr
}
@@ -523,8 +520,7 @@ func (s *WidgetService) GetConversations(ctx context.Context, widgetToken string
return nil, fmt.Errorf("invalid widget_token: %w", err)
}
conversations, _, err := s.conversationRepo.FindByContact(
ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 50)
conversations, _, err := s.findWidgetConversations(ctx, contactInbox, 0, 50)
if err != nil {
return nil, err
}
@@ -536,8 +532,7 @@ func (s *WidgetService) GetLatestConversation(ctx context.Context, widgetToken s
if err != nil {
return nil, fmt.Errorf("invalid widget_token: %w", err)
}
conversations, _, err := s.conversationRepo.FindByContact(
ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 1)
conversations, _, err := s.findWidgetConversations(ctx, contactInbox, 0, 1)
if err != nil {
return nil, err
}
@@ -552,12 +547,38 @@ func (s *WidgetService) GetConversation(ctx context.Context, widgetToken string,
if err != nil {
return nil, fmt.Errorf("invalid widget_token: %w", err)
}
return s.resolveWidgetConversation(ctx, contactInbox, conversationID)
}
func (s *WidgetService) findWidgetConversations(ctx context.Context, contactInbox *model.ContactInbox, offset, limit int) ([]model.Conversation, int64, error) {
count, err := s.contactInboxRepo.CountByContactAndInbox(ctx, contactInbox.ContactID, contactInbox.InboxID)
if err != nil {
return nil, 0, err
}
return s.conversationRepo.FindByContactInbox(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID,
contactInbox.InboxID, contactInbox.ID, count == 1, offset, limit)
}
func (s *WidgetService) resolveWidgetConversation(ctx context.Context, contactInbox *model.ContactInbox, conversationID uint) (*model.Conversation, error) {
conversation, err := s.conversationRepo.FindByID(ctx, conversationID)
if err != nil {
return nil, err
}
if conversation.ContactID != contactInbox.ContactID {
return nil, errors.New("conversation does not belong to this contact")
if conversation.ContactID != contactInbox.ContactID || conversation.InboxID != contactInbox.InboxID {
return nil, errWidgetConversationOwnership
}
if conversation.ContactInboxID != nil {
if *conversation.ContactInboxID == contactInbox.ID {
return conversation, nil
}
return nil, errWidgetConversationOwnership
}
count, err := s.contactInboxRepo.CountByContactAndInbox(ctx, contactInbox.ContactID, contactInbox.InboxID)
if err != nil {
return nil, err
}
if count != 1 {
return nil, errWidgetConversationOwnership
}
return conversation, nil
}
@@ -1129,14 +1150,9 @@ func (s *WidgetService) GetMessages(ctx context.Context, widgetToken string, con
return nil, 0, fmt.Errorf("invalid widget_token: %w", err)
}
// Verify conversation belongs to this contact
conversation, err := s.conversationRepo.FindByID(ctx, conversationID)
if err != nil {
if _, err := s.resolveWidgetConversation(ctx, contactInbox, conversationID); err != nil {
return nil, 0, err
}
if conversation.ContactID != contactInbox.ContactID {
return nil, 0, errors.New("conversation does not belong to this contact")
}
return s.messageRepo.FindByConversation(ctx, conversationID, offset, limit)
}
@@ -1285,13 +1301,10 @@ func (s *WidgetService) UpdateMessage(ctx context.Context, req WidgetMessageUpda
if err != nil {
return nil, nil, err
}
conversation, err := s.conversationRepo.FindByID(ctx, message.ConversationID)
conversation, err := s.resolveWidgetConversation(ctx, contactInbox, message.ConversationID)
if err != nil {
return nil, nil, err
}
if conversation.ContactID != contactInbox.ContactID || conversation.InboxID != contactInbox.InboxID {
return nil, nil, errors.New("message does not belong to this contact")
}
contact := &contactInbox.Contact
if strings.TrimSpace(req.ContactEmail) != "" && message.ContentType == string(model.MessageContentTypeInputEmail) {
@@ -1445,13 +1458,9 @@ func (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken, wi
if message.InboxID != inbox.ID {
return nil, errors.New("message does not belong to this inbox")
}
conversation, err := s.conversationRepo.FindByID(ctx, message.ConversationID)
if err != nil {
if _, err := s.resolveWidgetConversation(ctx, contactInbox, message.ConversationID); err != nil {
return nil, err
}
if conversation.ContactID != contactInbox.ContactID {
return nil, errors.New("message does not belong to this contact")
}
if message.ContentType != "integrations" {
return nil, errors.New("Invalid message type. Action not permitted")
}
@@ -1488,16 +1497,14 @@ func (s *WidgetService) ToggleTyping(ctx context.Context, widgetToken string, co
return fmt.Errorf("invalid widget_token: %w", err)
}
conversation, err := s.conversationRepo.FindByID(ctx, conversationID)
conversation, err := s.resolveWidgetConversation(ctx, contactInbox, conversationID)
if err != nil {
if errors.Is(err, errWidgetConversationOwnership) {
return err
}
return fmt.Errorf("conversation not found: %w", err)
}
// Verify conversation belongs to this contact
if conversation.ContactID != contactInbox.ContactID {
return errors.New("conversation does not belong to this contact")
}
performer := &ws.Performer{
ID: contactInbox.ContactID,
Name: contactInbox.Contact.Name,
@@ -787,6 +787,49 @@ func TestWidgetService_GetConversations_InvalidToken(t *testing.T) {
assert.Error(t, err)
}
func TestWidgetService_GetLatestMessagesScopesByContactInbox(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
owned, err := svc.SendMessage(ctx, WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "owned message"})
require.NoError(t, err)
otherContactInbox := model.ContactInbox{
ContactID: initResp.ContactID,
InboxID: initResp.InboxID,
SourceID: "other-source",
PubsubToken: "other-widget-token",
}
require.NoError(t, db.Create(&otherContactInbox).Error)
otherConversation := model.Conversation{
AccountID: initResp.AccountID,
InboxID: initResp.InboxID,
ContactID: initResp.ContactID,
ContactInboxID: &otherContactInbox.ID,
Status: string(model.ConversationStatusOpen),
ChannelType: string(channel.ChannelWebWidget),
Channel: "web_widget",
}
require.NoError(t, db.Create(&otherConversation).Error)
require.NoError(t, db.Create(&model.Message{
ConversationID: otherConversation.ID,
AccountID: initResp.AccountID,
InboxID: initResp.InboxID,
Content: "other identity message",
ContentType: "text",
MessageType: string(model.MessageTypeOutgoing),
}).Error)
messages, _, conversation, err := svc.GetLatestConversationMessages(ctx, initResp.WidgetToken, 0, 0)
require.NoError(t, err)
require.NotNil(t, conversation)
assert.Equal(t, owned.ConversationID, conversation.ID)
require.Len(t, messages, 1)
assert.Equal(t, "owned message", messages[0].Content)
}
func TestWidgetService_UpdateLastSeenQueuesMessageStatusJob(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
@@ -892,6 +935,96 @@ func TestWidgetService_GetMessages_WrongConversationOwner(t *testing.T) {
assert.Contains(t, err.Error(), "does not belong to this contact")
}
func TestWidgetService_LegacyConversationRequiresUniqueContactInbox(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
account, inbox := seedWidgetInbox(t, db)
contact := &model.Contact{AccountID: account.ID, Name: "Legacy visitor"}
require.NoError(t, db.Create(contact).Error)
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "legacy-token"}
require.NoError(t, db.Create(contactInbox).Error)
legacy := &model.Conversation{
AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID,
Status: "open", ChannelType: "web_widget", Channel: "web_widget",
}
require.NoError(t, db.Create(legacy).Error)
require.NoError(t, db.Create(&model.Message{
AccountID: account.ID, InboxID: inbox.ID, ConversationID: legacy.ID,
Content: "legacy reply", ContentType: "text", MessageType: "outgoing", Status: "sent",
}).Error)
conversations, err := svc.GetConversations(ctx, contactInbox.PubsubToken)
require.NoError(t, err)
require.Len(t, conversations, 1)
assert.Equal(t, legacy.ID, conversations[0].ID)
_, err = svc.GetConversation(ctx, contactInbox.PubsubToken, legacy.ID)
require.NoError(t, err)
messages, _, err := svc.GetMessages(ctx, contactInbox.PubsubToken, legacy.ID, 0, 10)
require.NoError(t, err)
require.Len(t, messages, 1)
conversationID := legacy.ID
_, err = svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: contactInbox.PubsubToken, ConversationID: &conversationID, Content: "legacy visitor reply",
})
require.NoError(t, err)
require.NoError(t, db.Create(&model.ContactInbox{
ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "second-legacy-token",
}).Error)
conversations, err = svc.GetConversations(ctx, contactInbox.PubsubToken)
require.NoError(t, err)
assert.Empty(t, conversations)
_, err = svc.GetConversation(ctx, contactInbox.PubsubToken, legacy.ID)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, _, err = svc.GetMessages(ctx, contactInbox.PubsubToken, legacy.ID, 0, 10)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, err = svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: contactInbox.PubsubToken, ConversationID: &conversationID, Content: "must fail closed",
})
assert.ErrorIs(t, err, errWidgetConversationOwnership)
}
func TestWidgetService_ExplicitConversationEntrypointsRejectSiblingToken(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
account, inbox := seedWidgetInbox(t, db)
contact := &model.Contact{AccountID: account.ID, Name: "Shared contact"}
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: mustJSON(map[string]any{"data": map[string]any{"meeting_id": "meeting-1"}}),
}
require.NoError(t, db.Create(message).Error)
_, err := svc.GetConversation(ctx, sibling.PubsubToken, conversation.ID)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, _, err = svc.GetMessages(ctx, sibling.PubsubToken, conversation.ID, 0, 10)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
err = svc.ToggleTyping(ctx, sibling.PubsubToken, conversation.ID, true)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, _, err = svc.UpdateMessage(ctx, WidgetMessageUpdate{
WidgetToken: sibling.PubsubToken, MessageID: message.ID, SubmittedValues: []map[string]any{{"value": "nope"}},
})
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, err = svc.AddDyteParticipant(ctx, "test_ws_token_123", sibling.PubsubToken, message.ID)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
conversationID := conversation.ID
_, err = svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: sibling.PubsubToken, ConversationID: &conversationID, Content: "nope",
})
assert.ErrorIs(t, err, errWidgetConversationOwnership)
}
// ========== GetCableToken Tests ==========
func TestWidgetService_GetCableToken(t *testing.T) {