package service import ( "context" "encoding/json" "fmt" "sync" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" "gorm.io/gorm" ) const TaskTypeMessageSendReply = "message:send_reply" type sendReplyJob struct { MessageID uint `json:"message_id"` } var messageDeliveryRegistrations sync.Map // RegisterMessageDeliveryJobs wires Chatwoot's SendReplyJob equivalent into // the durable worker. Outgoing provider calls stay behind channel providers so // tests can fake delivery without real external APIs. func RegisterMessageDeliveryJobs(wp *worker.WorkerPool, db *gorm.DB, dispatcher *channel.Dispatcher) { if wp == nil || db == nil { return } if _, loaded := messageDeliveryRegistrations.LoadOrStore(wp, struct{}{}); loaded { return } runner := &messageDeliveryRunner{db: db, dispatcher: dispatcher} wp.Register(TaskTypeMessageSendReply, runner.performSendReply) } func EnqueueSendReply(ctx context.Context, wp *worker.WorkerPool, messageID uint) (*model.BackgroundJob, error) { if wp == nil { return nil, nil } if messageID == 0 { return nil, fmt.Errorf("invalid send reply payload: message_id=%d", messageID) } return wp.Enqueue(ctx, TaskTypeMessageSendReply, sendReplyJob{MessageID: messageID}, worker.WithQueue("high"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("message:send_reply:%d", messageID)), ) } type messageDeliveryRunner struct { db *gorm.DB dispatcher *channel.Dispatcher } func (r *messageDeliveryRunner) performSendReply(ctx context.Context, job *model.BackgroundJob) error { var payload sendReplyJob if err := json.Unmarshal(job.Payload, &payload); err != nil { return fmt.Errorf("unmarshal send reply job: %w", err) } if payload.MessageID == 0 { return fmt.Errorf("invalid send reply job payload: %#v", payload) } var message model.Message if err := r.db.WithContext(ctx).First(&message, payload.MessageID).Error; err != nil { return fmt.Errorf("load send reply message %d: %w", payload.MessageID, err) } if !deliverableReplyMessage(message) { return nil } var conversation model.Conversation if err := r.db.WithContext(ctx). Where("id = ? AND account_id = ?", message.ConversationID, message.AccountID). First(&conversation).Error; err != nil { return fmt.Errorf("load send reply conversation %d: %w", message.ConversationID, err) } var inbox model.Inbox if err := r.db.WithContext(ctx). Where("id = ? AND account_id = ?", message.InboxID, message.AccountID). First(&inbox).Error; err != nil { return fmt.Errorf("load send reply inbox %d: %w", message.InboxID, err) } var contact model.Contact if err := r.db.WithContext(ctx). Where("id = ? AND account_id = ?", conversation.ContactID, message.AccountID). First(&contact).Error; err != nil { return fmt.Errorf("load send reply contact %d: %w", conversation.ContactID, err) } result, err := channel.SendMessageToChannel(ctx, channel.ChannelType(inbox.ChannelType), &inbox, &message, &contact) if err != nil { _ = r.updateMessageDeliveryFailure(ctx, &message, err) return fmt.Errorf("send reply message %d: %w", message.ID, err) } if err := r.updateMessageDeliverySuccess(ctx, &message, result); err != nil { return err } return nil } func deliverableReplyMessage(message model.Message) bool { if message.SourceID != "" || message.Private || message.ContentType == "voice_call" { return false } switch message.MessageType { case string(model.MessageTypeOutgoing), string(model.MessageTypeTemplate): return true default: return false } } func (r *messageDeliveryRunner) updateMessageDeliverySuccess(ctx context.Context, message *model.Message, result *channel.SendResult) error { updates := map[string]any{ "status": string(model.MessageStatusSent), "content_attributes": setMessageExternalError(message.ContentAttributes, string(model.MessageStatusSent), ""), } if result != nil && result.ExternalID != "" { updates["source_id"] = result.ExternalID } if err := r.db.WithContext(ctx).Model(&model.Message{}).Where("id = ?", message.ID).Updates(updates).Error; err != nil { return fmt.Errorf("update sent message %d: %w", message.ID, err) } r.dispatchMessageStatus(ctx, message, string(model.MessageStatusSent)) return nil } func (r *messageDeliveryRunner) updateMessageDeliveryFailure(ctx context.Context, message *model.Message, sendErr error) error { updates := map[string]any{ "status": string(model.MessageStatusFailed), "content_attributes": setMessageExternalError(message.ContentAttributes, string(model.MessageStatusFailed), sendErr.Error()), } return r.db.WithContext(ctx).Model(message). Where("id = ?", message.ID). Updates(updates).Error } func (r *messageDeliveryRunner) dispatchMessageStatus(ctx context.Context, message *model.Message, status string) { if r.dispatcher == nil { return } event := channel.NewChannelEvent(channel.EventMessageStatusUpdated, channel.ChannelAPI, message.AccountID, message.InboxID) event.ConversationID = message.ConversationID if message.SenderID != nil { event.UserID = *message.SenderID } event.Data["message_id"] = message.ID event.Data["status"] = status _ = r.dispatcher.Dispatch(ctx, event) }