Files
gochat/internal/service/message_delivery_worker.go
T

192 lines
6.5 KiB
Go

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/datatypes"
"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) {
registerMessageDeliveryJobs(wp, db, dispatcher, nil)
}
func RegisterMessageDeliverySearchIndexer(wp *worker.WorkerPool, db *gorm.DB, dispatcher *channel.Dispatcher, indexer SearchIndexer) {
registerMessageDeliveryJobs(wp, db, dispatcher, indexer)
}
func registerMessageDeliveryJobs(wp *worker.WorkerPool, db *gorm.DB, dispatcher *channel.Dispatcher, indexer SearchIndexer) {
if wp == nil || db == nil {
return
}
runner := &messageDeliveryRunner{db: db, dispatcher: dispatcher, searchIndexer: indexer}
actual, loaded := messageDeliveryRegistrations.LoadOrStore(wp, runner)
if loaded {
registered := actual.(*messageDeliveryRunner)
if dispatcher != nil {
registered.dispatcher = dispatcher
}
if indexer != nil {
registered.searchIndexer = indexer
}
return
}
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
searchIndexer SearchIndexer
}
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)
}
message.Status = string(model.MessageStatusSent)
message.ContentAttributes = updates["content_attributes"].(datatypes.JSON)
if result != nil && result.ExternalID != "" {
message.SourceID = result.ExternalID
}
r.dispatchMessageStatus(ctx, message, string(model.MessageStatusSent))
r.indexMessage(ctx, message)
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()),
}
if err := r.db.WithContext(ctx).Model(message).
Where("id = ?", message.ID).
Updates(updates).Error; err != nil {
return err
}
message.Status = string(model.MessageStatusFailed)
message.ContentAttributes = updates["content_attributes"].(datatypes.JSON)
r.indexMessage(ctx, message)
return nil
}
func (r *messageDeliveryRunner) indexMessage(ctx context.Context, message *model.Message) {
if r.searchIndexer == nil || message == nil {
return
}
logSearchIndexError("message", message.ID, r.searchIndexer.IndexMessage(ctx, message))
}
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)
}