package v1 import ( "context" "encoding/json" "os" "path/filepath" "strings" "time" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/service" "gorm.io/datatypes" "gorm.io/gorm" ) type chatwootConversationListResponse struct { Data chatwootConversationListData `json:"data"` } type chatwootConversationListData struct { Meta chatwootConversationCounts `json:"meta"` Payload []chatwootConversationPayload `json:"payload"` } type chatwootConversationCounts struct { MineCount int64 `json:"mine_count"` AssignedCount int64 `json:"assigned_count"` UnassignedCount int64 `json:"unassigned_count"` AllCount int64 `json:"all_count"` } type chatwootConversationPayload struct { Meta chatwootConversationMeta `json:"meta"` ID uint `json:"id"` Messages []chatwootMessagePayload `json:"messages"` AccountID uint `json:"account_id"` UUID string `json:"uuid"` AdditionalAttributes map[string]any `json:"additional_attributes"` AgentLastSeenAt int64 `json:"agent_last_seen_at"` AssigneeLastSeenAt int64 `json:"assignee_last_seen_at"` CanReply bool `json:"can_reply"` ContactLastSeenAt int64 `json:"contact_last_seen_at"` CustomAttributes map[string]any `json:"custom_attributes"` InboxID uint `json:"inbox_id"` Labels []string `json:"labels"` Muted bool `json:"muted"` SnoozedUntil *int64 `json:"snoozed_until"` Status string `json:"status"` CreatedAt int64 `json:"created_at"` UpdatedAt float64 `json:"updated_at"` Timestamp int64 `json:"timestamp"` FirstReplyCreatedAt int64 `json:"first_reply_created_at"` UnreadCount int64 `json:"unread_count"` LastNonActivityMessage *chatwootMessagePayload `json:"last_non_activity_message"` LastActivityAt int64 `json:"last_activity_at"` Priority string `json:"priority"` WaitingSince int64 `json:"waiting_since"` SlaPolicyID *uint `json:"sla_policy_id"` AppliedSLA map[string]any `json:"applied_sla,omitempty"` SlaEvents []map[string]any `json:"sla_events,omitempty"` } type chatwootConversationMeta struct { Sender map[string]any `json:"sender"` Channel string `json:"channel"` Assignee map[string]any `json:"assignee,omitempty"` AssigneeType string `json:"assignee_type,omitempty"` Team map[string]any `json:"team,omitempty"` HMACVerified *bool `json:"hmac_verified,omitempty"` } type chatwootConversationSearchResponse struct { Meta chatwootConversationSearchMeta `json:"meta"` Payload []chatwootConversationSearchPayload `json:"payload"` } type chatwootConversationSearchMeta struct { MineCount int64 `json:"mine_count"` UnassignedCount int64 `json:"unassigned_count"` AllCount int64 `json:"all_count"` } type chatwootConversationSearchPayload struct { ID uint `json:"id"` UUID string `json:"uuid"` CreatedAt int64 `json:"created_at"` Contact map[string]any `json:"contact"` Inbox map[string]any `json:"inbox"` Messages []chatwootConversationSearchMessage `json:"messages"` AccountID uint `json:"account_id"` } type chatwootConversationSearchMessage struct { Content string `json:"content"` ID uint `json:"id"` SenderName *string `json:"sender_name,omitempty"` MessageType int `json:"message_type"` CreatedAt int64 `json:"created_at"` } type chatwootMessageIndexResponse struct { Meta chatwootMessageIndexMeta `json:"meta"` Payload []chatwootMessagePayload `json:"payload"` } type chatwootMessageIndexMeta struct { Labels []string `json:"labels"` AdditionalAttrs map[string]any `json:"additional_attributes"` Contact map[string]any `json:"contact"` Assignee map[string]any `json:"assignee,omitempty"` AgentLastSeenAt int64 `json:"agent_last_seen_at"` AssigneeLastSeenAt int64 `json:"assignee_last_seen_at"` } type chatwootMessagePayload struct { ID uint `json:"id"` Content string `json:"content"` InboxID uint `json:"inbox_id"` AccountID uint `json:"account_id"` EchoID string `json:"echo_id,omitempty"` ConversationID uint `json:"conversation_id"` MessageType int `json:"message_type"` ContentType string `json:"content_type"` Status string `json:"status"` ContentAttributes map[string]any `json:"content_attributes"` CreatedAt int64 `json:"created_at"` Private bool `json:"private"` SourceID string `json:"source_id"` Sender map[string]any `json:"sender,omitempty"` Attachments []any `json:"attachments,omitempty"` Call map[string]any `json:"call,omitempty"` } func serializeConversationList(ctx context.Context, db *gorm.DB, conversations []model.Conversation, total int64) chatwootConversationListResponse { payload := make([]chatwootConversationPayload, 0, len(conversations)) for i := range conversations { payload = append(payload, serializeConversation(ctx, db, &conversations[i])) } return chatwootConversationListResponse{Data: chatwootConversationListData{ Meta: chatwootConversationCounts{AllCount: total}, Payload: payload, }} } func serializeConversationSearchList(ctx context.Context, db *gorm.DB, conversations []model.Conversation, counts service.FilterCountMeta) chatwootConversationSearchResponse { payload := make([]chatwootConversationSearchPayload, 0, len(conversations)) for i := range conversations { payload = append(payload, serializeConversationSearch(ctx, db, &conversations[i])) } return chatwootConversationSearchResponse{ Meta: chatwootConversationSearchMeta{ MineCount: counts.MineCount, UnassignedCount: counts.UnassignedCount, AllCount: counts.AllCount, }, Payload: payload, } } func serializeConversationSearch(ctx context.Context, db *gorm.DB, conversation *model.Conversation) chatwootConversationSearchPayload { payload := chatwootConversationSearchPayload{ ID: conversationDisplayID(conversation), UUID: conversation.UUID, CreatedAt: conversation.CreatedAt.Unix(), Contact: map[string]any{"id": conversation.ContactID}, Inbox: map[string]any{"id": conversation.InboxID, "channel_type": conversation.ChannelType}, Messages: []chatwootConversationSearchMessage{}, AccountID: conversation.AccountID, } if db == nil { return payload } var contact model.Contact if err := db.WithContext(ctx).Where("id = ?", conversation.ContactID).First(&contact).Error; err == nil { payload.Contact = map[string]any{"id": contact.ID, "name": contact.Name} } var inbox model.Inbox if err := db.WithContext(ctx).Where("id = ?", conversation.InboxID).First(&inbox).Error; err == nil { payload.Inbox = map[string]any{"id": inbox.ID, "name": inbox.Name, "channel_type": inbox.ChannelType} } var messages []model.Message if err := db.WithContext(ctx). Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID). Order("created_at ASC, id ASC"). Find(&messages).Error; err == nil { payload.Messages = make([]chatwootConversationSearchMessage, 0, len(messages)) for i := range messages { payload.Messages = append(payload.Messages, chatwootConversationSearchMessage{ Content: messages[i].Content, ID: messages[i].ID, SenderName: messageSenderName(ctx, db, &messages[i]), MessageType: messageTypeValue(messages[i].MessageType), CreatedAt: messages[i].CreatedAt.Unix(), }) } } return payload } func serializeConversationPayloads(ctx context.Context, db *gorm.DB, conversations []model.Conversation) []chatwootConversationPayload { payload := make([]chatwootConversationPayload, 0, len(conversations)) for i := range conversations { payload = append(payload, serializeConversation(ctx, db, &conversations[i])) } return payload } func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model.Conversation) chatwootConversationPayload { var lastMessage *model.Message if db != nil { var msg model.Message if err := db.WithContext(ctx).Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID).Order("created_at DESC, id DESC").First(&msg).Error; err == nil { lastMessage = &msg } } messages := []chatwootMessagePayload{} var lastNonActivity *chatwootMessagePayload if lastMessage != nil { serialized := serializeMessage(ctx, db, lastMessage, conversation) messages = append(messages, serialized) } if db != nil { var nonActivityMessage model.Message if err := db.WithContext(ctx). Where("account_id = ? AND conversation_id = ? AND message_type <> ?", conversation.AccountID, conversation.ID, "activity"). Order("created_at DESC, id DESC"). First(&nonActivityMessage).Error; err == nil { serialized := serializeMessage(ctx, db, &nonActivityMessage, conversation) lastNonActivity = &serialized } } else if lastMessage != nil && lastMessage.MessageType != "activity" { serialized := serializeMessage(ctx, db, lastMessage, conversation) lastNonActivity = &serialized } payload := chatwootConversationPayload{ Meta: serializeConversationMeta(ctx, db, conversation), ID: conversationDisplayID(conversation), Messages: messages, AccountID: conversation.AccountID, UUID: conversation.UUID, AdditionalAttributes: jsonObject(conversation.AdditionalAttributes), AgentLastSeenAt: int64Value(conversation.AgentLastSeenAt), AssigneeLastSeenAt: int64Value(conversation.AssigneeLastSeenAt), CanReply: conversationCanReply(ctx, db, conversation), ContactLastSeenAt: int64Value(conversation.ContactLastSeenAt), CustomAttributes: jsonObject(conversation.CustomAttributes), InboxID: conversation.InboxID, Labels: labelList(conversation.Labels), Muted: conversationMuted(ctx, db, conversation), SnoozedUntil: conversation.SnoozedUntil, Status: conversation.Status, CreatedAt: conversation.CreatedAt.Unix(), UpdatedAt: float64(conversation.UpdatedAt.UnixNano()) / float64(time.Second), Timestamp: int64Value(conversation.LastActivityAt), FirstReplyCreatedAt: int64Value(conversation.FirstReplyCreatedAt), UnreadCount: unreadCount(ctx, db, conversation), LastNonActivityMessage: lastNonActivity, LastActivityAt: int64Value(conversation.LastActivityAt), Priority: conversation.Priority, WaitingSince: int64Value(conversation.WaitingSince), SlaPolicyID: conversation.SlaPolicyID, } if appliedSLA := serializeAppliedSlaForConversation(ctx, db, conversation.ID); appliedSLA != nil { payload.AppliedSLA = appliedSLA } if slaEvents := serializeSlaEventsForConversation(ctx, db, conversation.ID); len(slaEvents) > 0 { payload.SlaEvents = slaEvents } return payload } func conversationMuted(ctx context.Context, db *gorm.DB, conversation *model.Conversation) bool { if db == nil || conversation.ContactID == 0 { return conversation.Muted } var contact model.Contact if err := db.WithContext(ctx). Select("id", "blocked"). Where("id = ? AND account_id = ?", conversation.ContactID, conversation.AccountID). First(&contact).Error; err == nil { return contact.Blocked } return conversation.Muted } func serializeAppliedSlaForConversation(ctx context.Context, db *gorm.DB, conversationID uint) map[string]any { if db == nil || conversationID == 0 { return nil } var applied model.AppliedSLA if err := db.WithContext(ctx). Preload("SlaPolicy"). Where("conversation_id = ?", conversationID). First(&applied).Error; err != nil { return nil } return map[string]any{ "id": applied.ID, "sla_id": applied.SlaPolicyID, "sla_status": applied.SLAStatus, "created_at": applied.CreatedAt.Unix(), "updated_at": applied.UpdatedAt.Unix(), "sla_description": applied.SlaPolicy.Description, "sla_name": applied.SlaPolicy.Name, "sla_first_response_time_threshold": applied.SlaPolicy.FirstResponseTimeThreshold, "sla_next_response_time_threshold": applied.SlaPolicy.NextResponseTimeThreshold, "sla_only_during_business_hours": applied.SlaPolicy.OnlyDuringBusinessHours, "sla_resolution_time_threshold": applied.SlaPolicy.ResolutionTimeThreshold, } } func serializeSlaEventsForConversation(ctx context.Context, db *gorm.DB, conversationID uint) []map[string]any { if db == nil || conversationID == 0 { return nil } var events []model.SlaEvent if err := db.WithContext(ctx). Where("conversation_id = ?", conversationID). Order("created_at ASC, id ASC"). Find(&events).Error; err != nil || len(events) == 0 { return nil } payload := make([]map[string]any, 0, len(events)) for i := range events { payload = append(payload, map[string]any{ "id": events[i].ID, "event_type": string(events[i].EventType), "meta": jsonObject(events[i].Meta), "updated_at": events[i].UpdatedAt.Unix(), "created_at": events[i].CreatedAt.Unix(), }) } return payload } func serializeConversationMeta(ctx context.Context, db *gorm.DB, conversation *model.Conversation) chatwootConversationMeta { meta := chatwootConversationMeta{Channel: conversation.ChannelType} if db == nil { meta.Sender = map[string]any{"id": conversation.ContactID} return meta } var contact model.Contact if err := db.WithContext(ctx).First(&contact, conversation.ContactID).Error; err == nil { meta.Sender = serializeContactWithContext(ctx, &contact) } else { meta.Sender = map[string]any{"id": conversation.ContactID} } if conversation.AssigneeAgentBotID != nil && *conversation.AssigneeAgentBotID != 0 { var bot model.AgentBot if err := db.WithContext(ctx).First(&bot, *conversation.AssigneeAgentBotID).Error; err == nil { meta.Assignee = serializeAgentBotSlim(&bot) meta.AssigneeType = "AgentBot" } } else if conversation.AssigneeID != nil && *conversation.AssigneeID != 0 { var user model.User if err := db.WithContext(ctx).First(&user, *conversation.AssigneeID).Error; err == nil { meta.Assignee = serializeUser(&user, conversation.AccountID) meta.AssigneeType = "User" } } if conversation.TeamID != nil && *conversation.TeamID != 0 { var team model.Team if err := db.WithContext(ctx).First(&team, *conversation.TeamID).Error; err == nil { meta.Team = serializeTeam(&team) } } if conversation.ContactInboxID != nil && *conversation.ContactInboxID != 0 { var contactInbox model.ContactInbox if err := db.WithContext(ctx).First(&contactInbox, *conversation.ContactInboxID).Error; err == nil { meta.HMACVerified = &contactInbox.HMACVerified } } return meta } func serializeMessageIndex(ctx context.Context, db *gorm.DB, conversation *model.Conversation, messages []model.Message) chatwootMessageIndexResponse { payload := make([]chatwootMessagePayload, 0, len(messages)) for i := range messages { payload = append(payload, serializeMessage(ctx, db, &messages[i], conversation)) } meta := chatwootMessageIndexMeta{ Labels: labelList(conversation.Labels), AdditionalAttrs: jsonObject(conversation.AdditionalAttributes), Contact: map[string]any{"id": conversation.ContactID}, AgentLastSeenAt: int64Value(conversation.AgentLastSeenAt), AssigneeLastSeenAt: int64Value(conversation.AssigneeLastSeenAt), } if db != nil { var contact model.Contact if err := db.WithContext(ctx).First(&contact, conversation.ContactID).Error; err == nil { meta.Contact = serializeContactWithContext(ctx, &contact) } if conversation.AssigneeID != nil && *conversation.AssigneeID != 0 { var user model.User if err := db.WithContext(ctx).First(&user, *conversation.AssigneeID).Error; err == nil { meta.Assignee = serializeUser(&user, conversation.AccountID) } } } return chatwootMessageIndexResponse{Meta: meta, Payload: payload} } func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message, conversation *model.Conversation) chatwootMessagePayload { conversationID := message.ConversationID if conversation != nil { conversationID = conversationDisplayID(conversation) } payload := chatwootMessagePayload{ ID: message.ID, Content: message.Content, InboxID: message.InboxID, AccountID: message.AccountID, EchoID: message.EchoID, ConversationID: conversationID, MessageType: messageTypeValue(message.MessageType), ContentType: nonEmpty(message.ContentType, "text"), Status: nonEmpty(message.Status, "sent"), ContentAttributes: jsonObject(message.ContentAttributes), CreatedAt: message.CreatedAt.Unix(), Private: message.Private, SourceID: message.SourceID, } if db != nil && message.SenderID != nil && *message.SenderID != 0 { senderType := normalizedSenderType(message.SenderType) switch senderType { case "contact": var contact model.Contact if err := db.WithContext(ctx).First(&contact, *message.SenderID).Error; err == nil { payload.Sender = serializeContactWithContext(ctx, &contact) } case "agent_bot": var bot model.AgentBot if err := db.WithContext(ctx).First(&bot, *message.SenderID).Error; err == nil { payload.Sender = serializeAgentBotSender(&bot) } default: var user model.User if err := db.WithContext(ctx).First(&user, *message.SenderID).Error; err == nil { payload.Sender = serializeUser(&user, message.AccountID) } } } if db != nil { var attachments []model.Attachment if err := db.WithContext(ctx).Where("message_id = ?", message.ID).Order("id ASC").Find(&attachments).Error; err == nil && len(attachments) > 0 { payload.Attachments = make([]any, 0, len(attachments)) for i := range attachments { payload.Attachments = append(payload.Attachments, serializeAttachmentPushEventData(&attachments[i])) } } if strings.EqualFold(message.ContentType, "voice_call") { payload.Call = serializeCallForMessage(ctx, db, message) } } return payload } func serializeCallForMessage(ctx context.Context, db *gorm.DB, message *model.Message) map[string]any { if db == nil || message == nil || message.ID == 0 { return nil } var call model.Call if err := db.WithContext(ctx). Where("account_id = ? AND message_id = ?", message.AccountID, message.ID). First(&call).Error; err != nil { return nil } return serializeCallPushEventData(ctx, db, &call) } func serializeCallPushEventData(ctx context.Context, db *gorm.DB, call *model.Call) map[string]any { if call == nil || call.ID == 0 { return nil } payload := map[string]any{ "id": call.ID, "provider_call_id": call.ProviderCallID, "provider": call.Provider, "direction": call.Direction, "status": strings.ReplaceAll(call.Status, "_", "-"), "duration_seconds": call.Duration, "end_reason": call.EndReason, "conference_sid": call.ConferenceSID, "accepted_by_agent_id": callAcceptedByAgentID(call), "accepted_by_agent_name": nil, "started_at": nil, "ended_at": callAdditionalAttribute(call, "ended_at"), "from_number": nil, "to_number": nil, "recording_url": nilIfEmpty(call.RecordingURL), "transcript": callAdditionalAttribute(call, "transcript"), } if call.StartedAt != nil { payload["started_at"] = call.StartedAt.Unix() } if db != nil { if call.AcceptedByAgentID != nil && *call.AcceptedByAgentID != 0 { var user model.User if err := db.WithContext(ctx).First(&user, *call.AcceptedByAgentID).Error; err == nil { payload["accepted_by_agent_name"] = nonEmpty(user.DisplayName, user.Name) } } fromNumber, toNumber := callPhoneNumbers(ctx, db, call) payload["from_number"] = fromNumber payload["to_number"] = toNumber } return payload } func callAcceptedByAgentID(call *model.Call) any { if call == nil || call.AcceptedByAgentID == nil || *call.AcceptedByAgentID == 0 { return nil } return *call.AcceptedByAgentID } func callPhoneNumbers(ctx context.Context, db *gorm.DB, call *model.Call) (any, any) { contactNumber := "" if call.ContactID != 0 { var contact model.Contact if err := db.WithContext(ctx).First(&contact, call.ContactID).Error; err == nil { contactNumber = contact.PhoneNumber } } channelNumber := callInboxPhoneNumber(ctx, db, call) if strings.EqualFold(call.Direction, "incoming") || strings.EqualFold(call.CallDirection, "inbound") { return nilIfEmpty(contactNumber), nilIfEmpty(channelNumber) } return nilIfEmpty(channelNumber), nilIfEmpty(contactNumber) } func callInboxPhoneNumber(ctx context.Context, db *gorm.DB, call *model.Call) string { if call.InboxID == 0 { return "" } var twilio channelmodel.ChannelTwilioSMS if err := db.WithContext(ctx).Where("account_id = ? AND inbox_id = ?", call.AccountID, call.InboxID).First(&twilio).Error; err == nil { return twilio.PhoneNumber } var whatsapp channelmodel.ChannelWhatsApp if err := db.WithContext(ctx).Where("account_id = ? AND inbox_id = ?", call.AccountID, call.InboxID).First(&whatsapp).Error; err == nil { return whatsapp.PhoneNumber } return "" } func callAdditionalAttribute(call *model.Call, key string) any { if call == nil || len(call.AdditionalAttributes) == 0 || string(call.AdditionalAttributes) == "null" { return nil } var attrs map[string]any if err := json.Unmarshal(call.AdditionalAttributes, &attrs); err != nil { return nil } return attrs[key] } func nilIfEmpty(value string) any { if value == "" { return nil } return value } func serializeAttachment(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any { payload := serializeAttachmentPushEventData(attachment) delete(payload, "account_id") message := attachment.Message if message.ID == 0 && db != nil && attachment.MessageID != 0 { _ = db.WithContext(ctx).First(&message, attachment.MessageID).Error } if !message.CreatedAt.IsZero() { payload["created_at"] = message.CreatedAt.Unix() } if sender := serializeMessageSender(ctx, db, &message); sender != nil { payload["sender"] = sender } return payload } func serializeAttachmentPushEventData(attachment *model.Attachment) map[string]any { extension := strings.TrimPrefix(filepath.Ext(attachment.FileName), ".") dataURL := nonEmpty(attachment.FileURL, attachment.ExternalURL) return map[string]any{ "id": attachment.ID, "message_id": attachment.MessageID, "file_type": attachment.FileType, "account_id": attachment.AccountID, "data_url": dataURL, "thumb_url": attachment.ThumbURL, "file_size": attachment.FileSize, "extension": extension, "width": attachment.Width, "height": attachment.Height, } } func serializeAttachmentWithConversation(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any { payload := serializeAttachment(ctx, db, attachment) message := attachment.Message if message.ID == 0 && db != nil && attachment.MessageID != 0 { _ = db.WithContext(ctx).First(&message, attachment.MessageID).Error } if db != nil && message.ConversationID != 0 { var conversation model.Conversation if err := db.WithContext(ctx).First(&conversation, message.ConversationID).Error; err == nil { payload["conversation_id"] = conversationDisplayID(&conversation) } } return payload } func serializeMessageSender(ctx context.Context, db *gorm.DB, message *model.Message) map[string]any { if db == nil || message == nil || message.SenderID == nil || *message.SenderID == 0 { return nil } senderType := normalizedSenderType(message.SenderType) if senderType == "contact" { var contact model.Contact if err := db.WithContext(ctx).First(&contact, *message.SenderID).Error; err == nil { return serializeContactWithContext(ctx, &contact) } return nil } if senderType == "agent_bot" { var bot model.AgentBot if err := db.WithContext(ctx).First(&bot, *message.SenderID).Error; err == nil { return serializeAgentBotSender(&bot) } return nil } var user model.User if err := db.WithContext(ctx).First(&user, *message.SenderID).Error; err == nil { return serializeUser(&user, message.AccountID) } return nil } func normalizedSenderType(senderType string) string { switch strings.ToLower(strings.TrimSpace(senderType)) { case "contact": return "contact" case "agentbot", "agent_bot": return "agent_bot" default: return "user" } } func messageSenderName(ctx context.Context, db *gorm.DB, message *model.Message) *string { sender := serializeMessageSender(ctx, db, message) if sender == nil { return nil } name, _ := sender["name"].(string) return &name } func serializeContact(contact *model.Contact) map[string]any { return serializeContactWithContext(context.Background(), contact) } func serializeContactWithContext(ctx context.Context, contact *model.Contact) map[string]any { return map[string]any{ "additional_attributes": jsonObject(contact.AdditionalAttributes), "availability_status": contactAvailabilityStatus(ctx, contact), "email": contact.Email, "id": contact.ID, "name": contact.Name, "phone_number": contact.PhoneNumber, "blocked": contact.Blocked, "identifier": contact.Identifier, "thumbnail": contact.AvatarURL, "custom_attributes": jsonObject(contact.CustomAttributes), "last_activity_at": int64Value(contact.LastActivityAt), "created_at": contact.CreatedAt.Unix(), } } func serializeUser(user *model.User, accountID uint) map[string]any { payload := map[string]any{ "id": user.ID, "account_id": accountID, "availability_status": availabilityStatus(user.Available), "auto_offline": false, "confirmed": user.ConfirmedAt != nil, "email": user.Email, "provider": nonEmpty(user.Provider, "email"), "available_name": nonEmpty(user.DisplayName, user.Name), "name": user.Name, "role": nonEmpty(user.Role, "agent"), "thumbnail": user.AvatarURL, } if attrs := jsonObject(user.CustomAttributes); len(attrs) > 0 { payload["custom_attributes"] = attrs } if user.CustomRoleID != nil && *user.CustomRoleID != 0 { payload["custom_role_id"] = *user.CustomRoleID } return payload } func serializeAgentBotSender(bot *model.AgentBot) map[string]any { return map[string]any{ "id": bot.ID, "name": bot.Name, "avatar_url": bot.AvatarURL, "type": "agent_bot", } } func serializeAgentBotSlim(bot *model.AgentBot) map[string]any { return map[string]any{ "id": bot.ID, "name": bot.Name, "description": bot.Description, "thumbnail": bot.AvatarURL, "outgoing_url": bot.OutgoingURL, "bot_type": bot.BotType, } } func serializeUserFromDB(ctx context.Context, db *gorm.DB, userID uint, accountID uint) any { if userID == 0 || db == nil { return nil } var user model.User if err := db.WithContext(ctx).First(&user, userID).Error; err != nil { return nil } return serializeUser(&user, accountID) } func serializeTeam(team *model.Team) map[string]any { return map[string]any{ "id": team.ID, "account_id": team.AccountID, "name": team.Name, "description": team.Description, "allow_auto_assign": team.AllowAutoAssignment, "is_member": false, } } func serializeTeamFromDB(ctx context.Context, db *gorm.DB, teamID uint, accountID uint) any { if teamID == 0 || db == nil { return nil } var team model.Team if err := db.WithContext(ctx).Where("id = ? AND account_id = ?", teamID, accountID).First(&team).Error; err != nil { return nil } return serializeTeam(&team) } func conversationDisplayID(conversation *model.Conversation) uint { if conversation.DisplayID != nil && *conversation.DisplayID != 0 { return *conversation.DisplayID } return conversation.ID } func messageTypeValue(value string) int { switch strings.ToLower(value) { case "incoming": return 0 case "outgoing", "private_note": return 1 case "activity": return 2 case "template": return 3 default: return 1 } } func labelList(labels string) []string { labels = strings.TrimSpace(labels) if labels == "" { return []string{} } if strings.HasPrefix(labels, "[") { var list []string if err := json.Unmarshal([]byte(labels), &list); err == nil { return list } } parts := strings.Split(labels, ",") result := make([]string, 0, len(parts)) for _, part := range parts { label := strings.TrimSpace(part) if label != "" { result = append(result, label) } } return result } func jsonObject(raw datatypes.JSON) map[string]any { if len(raw) == 0 || string(raw) == "null" { return map[string]any{} } var value map[string]any if err := json.Unmarshal(raw, &value); err != nil || value == nil { return map[string]any{} } return value } func int64Value(value *int64) int64 { if value == nil { return 0 } return *value } func nonEmpty(value, fallback string) string { if value == "" { return fallback } return value } func availabilityStatus(available bool) string { if available { return "online" } return "offline" } func unreadCount(ctx context.Context, db *gorm.DB, conversation *model.Conversation) int64 { if db == nil { return 0 } query := db.WithContext(ctx).Model(&model.Message{}). Where("account_id = ? AND conversation_id = ? AND message_type = ?", conversation.AccountID, conversation.ID, "incoming") if conversation.AgentLastSeenAt != nil { query = query.Where("created_at > ?", time.Unix(*conversation.AgentLastSeenAt, 0)) } var count int64 _ = query.Count(&count).Error return count } func conversationCanReply(ctx context.Context, db *gorm.DB, conversation *model.Conversation) bool { if db == nil { return true } window := messagingWindowHours(ctx, db, conversation) if window == 0 { return true } var lastIncoming model.Message err := db.WithContext(ctx). Where("account_id = ? AND conversation_id = ? AND message_type = ?", conversation.AccountID, conversation.ID, "incoming"). Order("id DESC").First(&lastIncoming).Error if err != nil { return false } return time.Now().UTC().Before(lastIncoming.CreatedAt.Add(time.Duration(window) * time.Hour)) } func messagingWindowHours(ctx context.Context, db *gorm.DB, conversation *model.Conversation) int { var inbox model.Inbox if err := db.WithContext(ctx).Select("id", "channel_type", "channel_id", "channel_config").Where("id = ?", conversation.InboxID).First(&inbox).Error; err != nil { return 0 } switch normalizedChannelType(inbox.ChannelType) { case "api", string(model.InboxChannelTypeAPI): return apiMessagingWindowHours(ctx, db, inbox.ID) case "facebook", string(model.InboxChannelTypeFacebook): return metaMessagingWindowHours("ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT") case "instagram", string(model.InboxChannelTypeInstagram): return metaMessagingWindowHours("ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT") case "whatsapp", string(model.InboxChannelTypeWhatsApp): return 24 case "tiktok", "Channel::Tiktok", string(model.InboxChannelTypeTikTok): return 48 case "twilio_sms", string(model.InboxChannelTypeTwilioSMS): if channelConfigString(inbox.ChannelConfig, "medium") == "whatsapp" { return 24 } return 0 default: return 0 } } func apiMessagingWindowHours(ctx context.Context, db *gorm.DB, inboxID uint) int { var channelAPI struct { AdditionalAttributes datatypes.JSON `gorm:"column:additional_attributes"` } if err := db.WithContext(ctx).Table("channel_api").Select("additional_attributes").Where("inbox_id = ?", inboxID).First(&channelAPI).Error; err != nil { return 0 } attrs := map[string]any{} _ = json.Unmarshal(channelAPI.AdditionalAttributes, &attrs) return positiveInt(attrs["agent_reply_time_window"]) } func metaMessagingWindowHours(configKey string) int { if truthyEnv(os.Getenv(configKey)) { return 24 * 7 } return 24 } func truthyEnv(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "t", "true", "yes", "y", "on": return true default: return false } } func normalizedChannelType(channelType string) string { channelType = strings.TrimSpace(channelType) if strings.HasPrefix(channelType, "Channel::") { return channelType } return strings.ToLower(channelType) } func channelConfigString(raw, key string) string { if strings.TrimSpace(raw) == "" { return "" } config := map[string]any{} if err := json.Unmarshal([]byte(raw), &config); err != nil { return "" } value, _ := config[key].(string) return strings.ToLower(strings.TrimSpace(value)) } func positiveInt(value any) int { switch v := value.(type) { case float64: if v > 0 { return int(v) } case int: if v > 0 { return v } case string: parsed := 0 for _, r := range strings.TrimSpace(v) { if r < '0' || r > '9' { return 0 } parsed = parsed*10 + int(r-'0') } return parsed } return 0 }