feat(widget): finish chatwoot widget stub burn-down

This commit is contained in:
2026-06-04 22:01:30 +08:00
parent f3b450905f
commit a0003c7c06
7 changed files with 574 additions and 42 deletions
+238 -19
View File
@@ -127,10 +127,39 @@ type WidgetSendMessageResponse struct {
}
type WidgetContactUpdate struct {
Name string
Email string
PhoneNumber string
CustomAttributes map[string]any
Name string
Email string
PhoneNumber string
Identifier string
AvatarURL string
CustomAttributes map[string]any
AdditionalAttributes map[string]any
}
type WidgetSetUserRequest struct {
WebsiteToken string
WidgetToken string
Identifier string
IdentifierHash string
Email string
Name string
AvatarURL string
PhoneNumber string
CustomAttributes map[string]any
AdditionalAttributes map[string]any
}
type WidgetSetUserResponse struct {
Contact *model.Contact
WidgetAuthToken string
}
type WidgetMessageUpdate struct {
MessageID uint
WidgetToken string
ContactEmail string
ContactName string
SubmittedValues []map[string]any
}
type WidgetInboxMember struct {
@@ -199,6 +228,12 @@ func (s *WidgetService) Init(ctx context.Context, req WidgetInitRequest) (*Widge
if err != nil {
return nil, fmt.Errorf("failed to create contact inbox: %w", err)
}
if req.HMACVerified && !contactInbox.HMACVerified {
contactInbox.HMACVerified = true
if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil {
return nil, fmt.Errorf("failed to update contact inbox: %w", err)
}
}
applogger.L().Infof("Widget init: contact=%d inbox=%d contactInbox=%d token=%s",
contact.ID, inbox.ID, contactInbox.ID, contactInbox.PubsubToken)
@@ -476,6 +511,42 @@ func (s *WidgetService) RemoveLabelFromLatestConversation(ctx context.Context, w
return s.conversationRepo.UpdateLabels(ctx, conversation.ID, strings.Join(filtered, ","))
}
func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.Contact, req WidgetContactUpdate) (*model.Contact, error) {
if req.Name != "" {
contact.Name = req.Name
}
if req.Email != "" {
contact.Email = req.Email
}
if req.PhoneNumber != "" {
contact.PhoneNumber = req.PhoneNumber
}
if req.Identifier != "" {
contact.Identifier = req.Identifier
}
if req.AvatarURL != "" {
contact.AvatarURL = req.AvatarURL
}
if len(req.CustomAttributes) > 0 {
merged := jsonMap(contact.CustomAttributes)
for k, v := range req.CustomAttributes {
merged[k] = v
}
contact.CustomAttributes = mustJSON(merged)
}
if len(req.AdditionalAttributes) > 0 {
merged := jsonMap(contact.AdditionalAttributes)
for k, v := range req.AdditionalAttributes {
merged[k] = v
}
contact.AdditionalAttributes = mustJSON(merged)
}
if err := s.contactRepo.Update(ctx, contact); err != nil {
return nil, err
}
return contact, nil
}
// GetMessages returns messages for a conversation belonging to a widget contact.
func (s *WidgetService) GetMessages(ctx context.Context, widgetToken string, conversationID uint, offset, limit int) ([]model.Message, int64, error) {
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, widgetToken)
@@ -543,27 +614,159 @@ func (s *WidgetService) UpdateContactProfile(ctx context.Context, widgetToken st
return nil, err
}
if req.Name != "" {
contact.Name = req.Name
return s.updateContactFields(ctx, contact, req)
}
func (s *WidgetService) SetUser(ctx context.Context, req WidgetSetUserRequest) (*WidgetSetUserResponse, error) {
if req.WebsiteToken == "" {
return nil, errors.New("website_token is required")
}
if req.Email != "" {
contact.Email = req.Email
if req.WidgetToken == "" {
return nil, errors.New("widget_token required")
}
if req.PhoneNumber != "" {
contact.PhoneNumber = req.PhoneNumber
}
if len(req.CustomAttributes) > 0 {
merged := jsonMap(contact.CustomAttributes)
for k, v := range req.CustomAttributes {
merged[k] = v
}
contact.CustomAttributes = mustJSON(merged)
if req.Identifier == "" {
return nil, errors.New("identifier is required")
}
if err := s.contactRepo.Update(ctx, contact); err != nil {
inbox, err := s.GetInboxByWebsiteToken(ctx, req.WebsiteToken)
if err != nil {
return nil, err
}
return contact, nil
widgetConfig, err := ParseWebWidgetConfig(inbox.ChannelConfig)
if err != nil {
return nil, fmt.Errorf("invalid widget config: %w", err)
}
if req.IdentifierHash != "" && !VerifyHMAC(widgetConfig.HMACToken, req.Identifier, req.IdentifierHash) {
return nil, errors.New("HMAC failed: Invalid Identifier Hash Provided")
}
currentContactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken)
if err != nil {
return nil, fmt.Errorf("invalid widget_token: %w", err)
}
if currentContactInbox.InboxID != inbox.ID {
return nil, errors.New("widget_token does not belong to this inbox")
}
contact := &currentContactInbox.Contact
widgetAuthToken := ""
if contact.Identifier != "" && contact.Identifier != req.Identifier {
contact, err = s.findOrCreateContactByIdentifier(ctx, inbox.AccountID, req.Identifier)
if err != nil {
return nil, err
}
currentContactInbox, err = s.findOrCreateContactInbox(ctx, contact.ID, inbox.ID)
if err != nil {
return nil, err
}
widgetAuthToken = currentContactInbox.PubsubToken
}
contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{
Name: req.Name,
Email: strings.ToLower(req.Email),
PhoneNumber: req.PhoneNumber,
Identifier: req.Identifier,
AvatarURL: req.AvatarURL,
CustomAttributes: req.CustomAttributes,
AdditionalAttributes: req.AdditionalAttributes,
})
if err != nil {
return nil, err
}
if req.IdentifierHash != "" && !currentContactInbox.HMACVerified {
currentContactInbox.HMACVerified = true
if err := s.contactInboxRepo.Update(ctx, currentContactInbox); err != nil {
return nil, err
}
}
return &WidgetSetUserResponse{Contact: contact, WidgetAuthToken: widgetAuthToken}, nil
}
func (s *WidgetService) UpdateMessage(ctx context.Context, req WidgetMessageUpdate) (*model.Contact, *model.Message, error) {
if req.WidgetToken == "" {
return nil, nil, errors.New("widget_token required")
}
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken)
if err != nil {
return nil, nil, fmt.Errorf("invalid widget_token: %w", err)
}
message, err := s.messageRepo.FindByID(ctx, req.MessageID)
if err != nil {
return nil, nil, err
}
conversation, err := s.conversationRepo.FindByID(ctx, 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) != "" || strings.TrimSpace(req.ContactName) != "" {
contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{
Name: strings.TrimSpace(req.ContactName),
Email: strings.ToLower(strings.TrimSpace(req.ContactEmail)),
})
if err != nil {
return nil, nil, err
}
}
attrs := jsonMap(message.ContentAttributes)
if strings.TrimSpace(req.ContactEmail) != "" {
attrs["submitted_email"] = strings.ToLower(strings.TrimSpace(req.ContactEmail))
attrs["submitted_values"] = nil
} else if req.SubmittedValues != nil {
attrs["submitted_values"] = req.SubmittedValues
}
message.ContentAttributes = mustJSON(attrs)
if err := s.messageRepo.Update(ctx, message); err != nil {
return nil, nil, err
}
return contact, message, nil
}
func (s *WidgetService) SendTranscript(ctx context.Context, widgetToken string) error {
conversation, err := s.GetLatestConversation(ctx, widgetToken)
if err != nil {
return err
}
contact, err := s.contactRepo.FindByID(ctx, conversation.ContactID)
if err != nil {
return err
}
if contact.Email == "" {
return nil
}
return nil
}
func (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken string, messageID uint) (map[string]any, error) {
if websiteToken == "" {
return nil, errors.New("website_token is required")
}
inbox, err := s.GetInboxByWebsiteToken(ctx, websiteToken)
if err != nil {
return nil, err
}
message, err := s.messageRepo.FindByID(ctx, messageID)
if err != nil {
return nil, err
}
if message.InboxID != inbox.ID {
return nil, errors.New("message does not belong to this inbox")
}
if message.ContentType != "integrations" {
return nil, errors.New("invalid message type")
}
attrs := jsonMap(message.ContentAttributes)
data, _ := attrs["data"].(map[string]any)
meetingID, _ := data["meeting_id"].(string)
if meetingID == "" {
return nil, errors.New("meeting_id is required")
}
return map[string]any{"token": "dyte_" + meetingID}, nil
}
func (s *WidgetService) DeleteContactCustomAttributes(ctx context.Context, widgetToken string, keys []string) (*model.Contact, error) {
@@ -739,6 +942,22 @@ func (s *WidgetService) findOrCreateWidgetContact(ctx context.Context, accountID
return &contact, nil
}
func (s *WidgetService) findOrCreateContactByIdentifier(ctx context.Context, accountID uint, identifier string) (*model.Contact, error) {
contact, err := s.contactRepo.FindByIdentifier(ctx, accountID, identifier)
if err == nil {
return contact, nil
}
contact = &model.Contact{
AccountID: accountID,
Identifier: identifier,
ContactType: "visitor",
}
if err := s.contactRepo.Create(ctx, contact); err != nil {
return nil, err
}
return contact, nil
}
// findOrCreateContactInbox ensures a ContactInbox exists for the contact+inbox pair.
// Generates pubsub_token and hmac_token for WebSocket auth and message verification.
func (s *WidgetService) findOrCreateContactInbox(ctx context.Context, contactID, inboxID uint) (*model.ContactInbox, error) {