Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
980 lines
34 KiB
Go
980 lines
34 KiB
Go
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
|
|
}
|