192 lines
6.5 KiB
Go
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)
|
|
}
|