package services import ( "agent-desk/internal/models" "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" "encoding/json" "log/slog" "strings" "time" "agent-desk/internal/pkg/httpx/params" "github.com/mlogclub/simple/sqls" ) var ChannelMessageOutboxService = newChannelMessageOutboxService() func newChannelMessageOutboxService() *channelMessageOutboxService { return &channelMessageOutboxService{} } type channelMessageOutboxService struct { } func (s *channelMessageOutboxService) Get(id int64) *models.ChannelMessageOutbox { return repositories.ChannelMessageOutboxRepository.Get(sqls.DB(), id) } func (s *channelMessageOutboxService) Take(where ...interface{}) *models.ChannelMessageOutbox { return repositories.ChannelMessageOutboxRepository.Take(sqls.DB(), where...) } func (s *channelMessageOutboxService) Find(cnd *sqls.Cnd) []models.ChannelMessageOutbox { return repositories.ChannelMessageOutboxRepository.Find(sqls.DB(), cnd) } func (s *channelMessageOutboxService) FindOne(cnd *sqls.Cnd) *models.ChannelMessageOutbox { return repositories.ChannelMessageOutboxRepository.FindOne(sqls.DB(), cnd) } func (s *channelMessageOutboxService) FindPageByParams(params *params.QueryParams) (list []models.ChannelMessageOutbox, paging *sqls.Paging) { return repositories.ChannelMessageOutboxRepository.FindPageByParams(sqls.DB(), params) } func (s *channelMessageOutboxService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ChannelMessageOutbox, paging *sqls.Paging) { return repositories.ChannelMessageOutboxRepository.FindPageByCnd(sqls.DB(), cnd) } func (s *channelMessageOutboxService) Count(cnd *sqls.Cnd) int64 { return repositories.ChannelMessageOutboxRepository.Count(sqls.DB(), cnd) } func (s *channelMessageOutboxService) Create(t *models.ChannelMessageOutbox) error { return repositories.ChannelMessageOutboxRepository.Create(sqls.DB(), t) } func (s *channelMessageOutboxService) Update(t *models.ChannelMessageOutbox) error { return repositories.ChannelMessageOutboxRepository.Update(sqls.DB(), t) } func (s *channelMessageOutboxService) Updates(id int64, columns map[string]interface{}) error { return repositories.ChannelMessageOutboxRepository.Updates(sqls.DB(), id, columns) } func (s *channelMessageOutboxService) UpdateColumn(id int64, name string, value interface{}) error { return repositories.ChannelMessageOutboxRepository.UpdateColumn(sqls.DB(), id, name, value) } func (s *channelMessageOutboxService) Delete(id int64) { repositories.ChannelMessageOutboxRepository.Delete(sqls.DB(), id) } // GetByMessageID retrieves the outbox entry by message ID and channel type. func (s *channelMessageOutboxService) GetByMessageID(channelType string, messageID int64) *models.ChannelMessageOutbox { return repositories.ChannelMessageOutboxRepository.Take(sqls.DB(), "channel_type = ? AND message_id = ?", channelType, messageID) } func (s *channelMessageOutboxService) EnqueueTelegramMessage(conversation *models.Conversation, message *models.Message) error { if conversation == nil || message == nil { return nil } channel := ChannelService.Get(conversation.ChannelID) if channel == nil || channel.ChannelType != enums.ChannelTypeTelegram { return nil } if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { return nil } if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { return nil } if existing := s.GetByMessageID(enums.ChannelTypeTelegram, message.ID); existing != nil { return nil } payload, err := json.Marshal(map[string]any{ "conversationId": conversation.ID, "messageId": message.ID, "messageType": message.MessageType, "content": strings.TrimSpace(message.Content), "payload": strings.TrimSpace(message.Payload), "senderId": message.SenderID, }) if err != nil { return err } now := time.Now() err = s.Create(&models.ChannelMessageOutbox{ ChannelType: enums.ChannelTypeTelegram, ConversationID: conversation.ID, MessageID: message.ID, Payload: string(payload), SendStatus: string(enums.ChannelMessageOutboxStatusPending), AuditFields: models.AuditFields{ CreatedAt: now, CreateUserID: message.UpdateUserID, CreateUserName: message.UpdateUserName, UpdatedAt: now, UpdateUserID: message.UpdateUserID, UpdateUserName: message.UpdateUserName, }, }) if err != nil { return err } go func() { defer func() { if r := recover(); r != nil { slog.Error("recovered from panic in telegram outbound dispatch", "error", r) } }() TelegramOutboundService.DispatchPendingOutbox() }() return nil } func (s *channelMessageOutboxService) EnqueueZaloOAMessage(conversation *models.Conversation, message *models.Message) error { if conversation == nil || message == nil { return nil } channel := ChannelService.Get(conversation.ChannelID) if channel == nil || channel.ChannelType != enums.ChannelTypeZaloOA { return nil } if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { return nil } if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { return nil } if existing := s.GetByMessageID(enums.ChannelTypeZaloOA, message.ID); existing != nil { return nil } payload, err := json.Marshal(map[string]any{ "conversationId": conversation.ID, "messageId": message.ID, "messageType": message.MessageType, "content": strings.TrimSpace(message.Content), "payload": strings.TrimSpace(message.Payload), "senderId": message.SenderID, }) if err != nil { return err } now := time.Now() err = s.Create(&models.ChannelMessageOutbox{ ChannelType: enums.ChannelTypeZaloOA, ConversationID: conversation.ID, MessageID: message.ID, Payload: string(payload), SendStatus: string(enums.ChannelMessageOutboxStatusPending), AuditFields: models.AuditFields{ CreatedAt: now, CreateUserID: message.UpdateUserID, CreateUserName: message.UpdateUserName, UpdatedAt: now, UpdateUserID: message.UpdateUserID, UpdateUserName: message.UpdateUserName, }, }) if err != nil { return err } go func() { defer func() { if r := recover(); r != nil { slog.Error("recovered from panic in zalo oa outbound dispatch", "error", r) } }() ZaloOAOutboundService.DispatchPendingOutbox() }() return nil } func (s *channelMessageOutboxService) EnqueueSlackMessage(conversation *models.Conversation, message *models.Message) error { if conversation == nil || message == nil { return nil } channel := ChannelService.Get(conversation.ChannelID) if channel == nil || channel.ChannelType != enums.ChannelTypeSlack { return nil } if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { return nil } if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { return nil } if existing := s.GetByMessageID(enums.ChannelTypeSlack, message.ID); existing != nil { return nil } payload, err := json.Marshal(map[string]any{ "conversationId": conversation.ID, "messageId": message.ID, "messageType": message.MessageType, "content": strings.TrimSpace(message.Content), "payload": strings.TrimSpace(message.Payload), "senderId": message.SenderID, }) if err != nil { return err } now := time.Now() err = s.Create(&models.ChannelMessageOutbox{ ChannelType: enums.ChannelTypeSlack, ConversationID: conversation.ID, MessageID: message.ID, Payload: string(payload), SendStatus: string(enums.ChannelMessageOutboxStatusPending), AuditFields: models.AuditFields{ CreatedAt: now, CreateUserID: message.UpdateUserID, CreateUserName: message.UpdateUserName, UpdatedAt: now, UpdateUserID: message.UpdateUserID, UpdateUserName: message.UpdateUserName, }, }) if err != nil { return err } go func() { defer func() { if r := recover(); r != nil { slog.Error("recovered from panic in slack outbound dispatch", "error", r) } }() SlackOutboundService.DispatchPendingOutbox() }() return nil } func (s *channelMessageOutboxService) EnqueueEmailMessage(conversation *models.Conversation, message *models.Message) error { if conversation == nil { return nil } return s.EnqueueEmailMessageVia(ChannelService.Get(conversation.ChannelID), conversation, message) } // EnqueueEmailMessageVia queues a message for email delivery through an explicit // channel. // // The channel is a parameter because the delivery medium and the conversation's // channel are not always the same thing. Telling a visitor that a request they // sent by email landed in a chat they left has to go out over email even though // that conversation belongs to the web channel; deriving the channel from the // conversation, as the original code did, silently dropped exactly those notices. func (s *channelMessageOutboxService) EnqueueEmailMessageVia(channel *models.Channel, conversation *models.Conversation, message *models.Message) error { if conversation == nil || message == nil { return nil } if channel == nil || channel.ChannelType != enums.ChannelTypeEmail { return nil } if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { return nil } if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML { return nil } if existing := s.GetByMessageID(enums.ChannelTypeEmail, message.ID); existing != nil { return nil } payload, err := json.Marshal(map[string]any{ "conversationId": conversation.ID, "messageId": message.ID, "messageType": message.MessageType, "content": strings.TrimSpace(message.Content), "payload": strings.TrimSpace(message.Payload), "senderId": message.SenderID, }) if err != nil { return err } now := time.Now() err = s.Create(&models.ChannelMessageOutbox{ ChannelType: enums.ChannelTypeEmail, ConversationID: conversation.ID, MessageID: message.ID, Payload: string(payload), SendStatus: string(enums.ChannelMessageOutboxStatusPending), AuditFields: models.AuditFields{ CreatedAt: now, CreateUserID: message.UpdateUserID, CreateUserName: message.UpdateUserName, UpdatedAt: now, UpdateUserID: message.UpdateUserID, UpdateUserName: message.UpdateUserName, }, }) if err != nil { return err } // Trigger async dispatch immediately go func() { defer func() { if r := recover(); r != nil { slog.Error("recovered from panic in email outbound dispatch", "error", r) } }() EmailOutboundService.DispatchPendingOutbox() }() return nil } func (s *channelMessageOutboxService) EnqueueDiscordMessage(conversation *models.Conversation, message *models.Message) error { if conversation == nil || message == nil { return nil } channel := ChannelService.Get(conversation.ChannelID) if channel == nil || channel.ChannelType != enums.ChannelTypeDiscord { return nil } if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { return nil } if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML && message.MessageType != enums.IMMessageTypeImage && message.MessageType != enums.IMMessageTypeAttachment { return nil } if existing := s.GetByMessageID(enums.ChannelTypeDiscord, message.ID); existing != nil { return nil } payload, err := json.Marshal(map[string]any{ "conversationId": conversation.ID, "messageId": message.ID, "messageType": message.MessageType, "content": strings.TrimSpace(message.Content), "payload": strings.TrimSpace(message.Payload), "senderId": message.SenderID, }) if err != nil { return err } now := time.Now() err = s.Create(&models.ChannelMessageOutbox{ ChannelType: enums.ChannelTypeDiscord, ConversationID: conversation.ID, MessageID: message.ID, Payload: string(payload), SendStatus: string(enums.ChannelMessageOutboxStatusPending), AuditFields: models.AuditFields{ CreatedAt: now, CreateUserID: message.UpdateUserID, CreateUserName: message.UpdateUserName, UpdatedAt: now, UpdateUserID: message.UpdateUserID, UpdateUserName: message.UpdateUserName, }, }) if err != nil { return err } go func() { defer func() { if r := recover(); r != nil { slog.Error("recovered from panic in discord outbound dispatch", "error", r) } }() DiscordOutboundService.DispatchPendingOutbox() }() return nil } func (s *channelMessageOutboxService) ListPending(channelType string, limit int) []models.ChannelMessageOutbox { if limit <= 0 { limit = 20 } now := time.Now() cnd := sqls.NewCnd(). Eq("channel_type", strings.TrimSpace(channelType)). In("send_status", []string{ string(enums.ChannelMessageOutboxStatusPending), string(enums.ChannelMessageOutboxStatusFailed), }). Asc("next_retry_at"). Asc("id"). Limit(limit) // A row with no next_retry_at has never been scheduled, so it is due // immediately. Comparing it directly would not work: NULL <= ? is unknown in // SQL, never true, so a plain Lte silently drops every freshly queued message // for every channel. Only rows whose backoff has elapsed are held back, and // ordering by next_retry_at keeps a backlog of not-yet-due retries from // starving newer pending sends. cnd.Where("next_retry_at IS NULL OR next_retry_at <= ?", now) return s.Find(cnd) }