247 lines
8.2 KiB
Go
247 lines
8.2 KiB
Go
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/gochat/gochat/internal/channel"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
)
|
|
|
|
const (
|
|
TaskTypeProviderIncomingMessagePersist = "webhook:incoming_message_persist"
|
|
TaskTypeProviderMessageStatusUpdate = "webhook:message_status_update"
|
|
TaskTypeProviderContactMessagesStatusUpdate = "webhook:contact_messages_status_update"
|
|
)
|
|
|
|
type providerIncomingMessagePersistJob struct {
|
|
InboxID uint `json:"inbox_id"`
|
|
Message channel.IncomingMessage `json:"message"`
|
|
}
|
|
|
|
type providerMessageStatusUpdateJob struct {
|
|
InboxID uint `json:"inbox_id"`
|
|
SourceID string `json:"source_id"`
|
|
Status model.MessageStatus `json:"status"`
|
|
OccurredAtUnixNano int64 `json:"occurred_at_unix_nano,omitempty"`
|
|
ExternalError string `json:"external_error,omitempty"`
|
|
}
|
|
|
|
type providerContactMessagesStatusUpdateJob struct {
|
|
InboxID uint `json:"inbox_id"`
|
|
ContactSourceID string `json:"contact_source_id"`
|
|
Status model.MessageStatus `json:"status"`
|
|
OccurredAtUnixNano int64 `json:"occurred_at_unix_nano,omitempty"`
|
|
}
|
|
|
|
func (p *IncomingPersister) registerJobs(wp *worker.WorkerPool) {
|
|
if p == nil || wp == nil {
|
|
return
|
|
}
|
|
wp.Register(TaskTypeProviderIncomingMessagePersist, p.performIncomingMessagePersistJob)
|
|
wp.Register(TaskTypeProviderMessageStatusUpdate, p.performMessageStatusUpdateJob)
|
|
wp.Register(TaskTypeProviderContactMessagesStatusUpdate, p.performContactMessagesStatusUpdateJob)
|
|
}
|
|
|
|
func (p *IncomingPersister) enqueueIncomingMessagePersist(ctx context.Context, inboxID uint, msg *channel.IncomingMessage) error {
|
|
if p == nil || p.worker == nil || inboxID == 0 || msg == nil {
|
|
return nil
|
|
}
|
|
queue := incomingMessageQueue(msg.ChannelType)
|
|
_, err := p.worker.Enqueue(ctx, TaskTypeProviderIncomingMessagePersist, providerIncomingMessagePersistJob{InboxID: inboxID, Message: *msg},
|
|
worker.WithQueue(queue),
|
|
worker.WithMaxAttempts(3),
|
|
worker.WithIdempotencyKey(fmt.Sprintf("webhook:incoming_message:%d:%s", inboxID, msg.SourceID)),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (p *IncomingPersister) enqueueMessageStatusUpdate(ctx context.Context, inboxID uint, sourceID string, status model.MessageStatus, occurredAt *time.Time, externalError string) error {
|
|
if p == nil || p.worker == nil || inboxID == 0 || sourceID == "" {
|
|
return nil
|
|
}
|
|
if !validProviderMessageStatus(status) {
|
|
return nil
|
|
}
|
|
payload := providerMessageStatusUpdateJob{
|
|
InboxID: inboxID,
|
|
SourceID: sourceID,
|
|
Status: status,
|
|
OccurredAtUnixNano: timeToUnixNano(occurredAt),
|
|
ExternalError: strings.TrimSpace(externalError),
|
|
}
|
|
_, err := p.worker.Enqueue(ctx, TaskTypeProviderMessageStatusUpdate, payload,
|
|
worker.WithQueue("low"),
|
|
worker.WithMaxAttempts(3),
|
|
worker.WithIdempotencyKey(fmt.Sprintf("webhook:message_status:%d:%s:%s:%d", inboxID, sourceID, status, payload.OccurredAtUnixNano)),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (p *IncomingPersister) enqueueContactMessagesStatusUpdate(ctx context.Context, inboxID uint, contactSourceID string, status model.MessageStatus, occurredAt *time.Time) error {
|
|
if p == nil || p.worker == nil || inboxID == 0 || contactSourceID == "" {
|
|
return nil
|
|
}
|
|
if !validProviderMessageStatus(status) {
|
|
return nil
|
|
}
|
|
payload := providerContactMessagesStatusUpdateJob{
|
|
InboxID: inboxID,
|
|
ContactSourceID: contactSourceID,
|
|
Status: status,
|
|
OccurredAtUnixNano: timeToUnixNano(occurredAt),
|
|
}
|
|
_, err := p.worker.Enqueue(ctx, TaskTypeProviderContactMessagesStatusUpdate, payload,
|
|
worker.WithQueue("low"),
|
|
worker.WithMaxAttempts(3),
|
|
worker.WithIdempotencyKey(fmt.Sprintf("webhook:contact_messages_status:%d:%s:%s:%d", inboxID, contactSourceID, status, payload.OccurredAtUnixNano)),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (p *IncomingPersister) performMessageStatusUpdateJob(ctx context.Context, job *model.BackgroundJob) error {
|
|
var payload providerMessageStatusUpdateJob
|
|
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
|
return fmt.Errorf("unmarshal provider message status job: %w", err)
|
|
}
|
|
if payload.InboxID == 0 || payload.SourceID == "" || !validProviderMessageStatus(payload.Status) {
|
|
return fmt.Errorf("invalid provider message status job payload: %#v", payload)
|
|
}
|
|
inbox, err := p.loadInboxForStatusJob(ctx, payload.InboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inbox == nil {
|
|
return nil
|
|
}
|
|
return p.performMessageStatusUpdate(ctx, inbox, payload.SourceID, payload.Status, unixNanoToTime(payload.OccurredAtUnixNano), payload.ExternalError)
|
|
}
|
|
|
|
func (p *IncomingPersister) performIncomingMessagePersistJob(ctx context.Context, job *model.BackgroundJob) error {
|
|
var payload providerIncomingMessagePersistJob
|
|
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
|
return fmt.Errorf("unmarshal provider incoming message job: %w", err)
|
|
}
|
|
if payload.InboxID == 0 {
|
|
return fmt.Errorf("invalid provider incoming message job payload: %#v", payload)
|
|
}
|
|
inbox, err := p.loadInboxForStatusJob(ctx, payload.InboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inbox == nil {
|
|
return nil
|
|
}
|
|
if err := validateIncomingMessage(p, inbox, &payload.Message); err != nil {
|
|
return err
|
|
}
|
|
_, err = p.performPersistIncoming(ctx, inbox, &payload.Message)
|
|
return err
|
|
}
|
|
|
|
func (p *IncomingPersister) performContactMessagesStatusUpdateJob(ctx context.Context, job *model.BackgroundJob) error {
|
|
var payload providerContactMessagesStatusUpdateJob
|
|
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
|
return fmt.Errorf("unmarshal provider contact messages status job: %w", err)
|
|
}
|
|
if payload.InboxID == 0 || payload.ContactSourceID == "" || !validProviderMessageStatus(payload.Status) {
|
|
return fmt.Errorf("invalid provider contact messages status job payload: %#v", payload)
|
|
}
|
|
inbox, err := p.loadInboxForStatusJob(ctx, payload.InboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inbox == nil {
|
|
return nil
|
|
}
|
|
return p.performContactMessagesStatusUpdate(ctx, inbox, payload.ContactSourceID, payload.Status, unixNanoToTime(payload.OccurredAtUnixNano))
|
|
}
|
|
|
|
func (p *IncomingPersister) loadInboxForStatusJob(ctx context.Context, inboxID uint) (*model.Inbox, error) {
|
|
var inbox model.Inbox
|
|
if err := p.db.WithContext(ctx).First(&inbox, inboxID).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("load status inbox %d: %w", inboxID, err)
|
|
}
|
|
return &inbox, nil
|
|
}
|
|
|
|
func validProviderMessageStatus(status model.MessageStatus) bool {
|
|
switch status {
|
|
case model.MessageStatusSent, model.MessageStatusDelivered, model.MessageStatusRead, model.MessageStatusFailed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func incomingMessageQueue(channelType channel.ChannelType) string {
|
|
switch channelType {
|
|
case channel.ChannelWhatsApp, channel.ChannelTwilioSMS, channel.ChannelTwilioWA:
|
|
return "low"
|
|
default:
|
|
return model.DefaultBackgroundJobQueue
|
|
}
|
|
}
|
|
|
|
func validProviderMessageStatusTransition(current, next model.MessageStatus) bool {
|
|
if !validProviderMessageStatus(next) || current == next {
|
|
return validProviderMessageStatus(next)
|
|
}
|
|
if next == model.MessageStatusFailed || current == model.MessageStatusFailed {
|
|
return true
|
|
}
|
|
return providerMessageStatusRank(next) >= providerMessageStatusRank(current)
|
|
}
|
|
|
|
func providerMessageStatusRank(status model.MessageStatus) int {
|
|
switch status {
|
|
case model.MessageStatusSent:
|
|
return 1
|
|
case model.MessageStatusDelivered:
|
|
return 2
|
|
case model.MessageStatusRead:
|
|
return 3
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func setProviderMessageExternalError(attrs datatypes.JSON, status model.MessageStatus, externalError string) datatypes.JSON {
|
|
obj := map[string]any{}
|
|
if len(attrs) > 0 {
|
|
_ = json.Unmarshal(attrs, &obj)
|
|
}
|
|
if status == model.MessageStatusFailed && strings.TrimSpace(externalError) != "" {
|
|
obj["external_error"] = strings.TrimSpace(externalError)
|
|
} else {
|
|
delete(obj, "external_error")
|
|
}
|
|
bytes, _ := json.Marshal(obj)
|
|
return datatypes.JSON(bytes)
|
|
}
|
|
|
|
func timeToUnixNano(t *time.Time) int64 {
|
|
if t == nil || t.IsZero() {
|
|
return 0
|
|
}
|
|
return t.UTC().UnixNano()
|
|
}
|
|
|
|
func unixNanoToTime(value int64) *time.Time {
|
|
if value <= 0 {
|
|
return nil
|
|
}
|
|
t := time.Unix(0, value).UTC()
|
|
return &t
|
|
}
|