188 lines
5.3 KiB
Go
188 lines
5.3 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"agent-desk/internal/models"
|
|
"agent-desk/internal/pkg/config"
|
|
"agent-desk/internal/pkg/enums"
|
|
"agent-desk/internal/repositories"
|
|
"agent-desk/internal/services/storage"
|
|
"agent-desk/internal/slack"
|
|
|
|
"github.com/mlogclub/simple/sqls"
|
|
)
|
|
|
|
const (
|
|
slackOutboxBatchSize = 20
|
|
slackOutboxMaxRetry = 5
|
|
)
|
|
|
|
var SlackOutboundService = newSlackOutboundService()
|
|
|
|
func newSlackOutboundService() *slackOutboundService {
|
|
return &slackOutboundService{}
|
|
}
|
|
|
|
type slackOutboundService struct{}
|
|
|
|
func (s *slackOutboundService) DispatchPendingOutbox() int {
|
|
return s.doDispatchPendingOutbox(slackOutboxBatchSize)
|
|
}
|
|
|
|
func (s *slackOutboundService) doDispatchPendingOutbox(limit int) int {
|
|
if limit <= 0 {
|
|
limit = slackOutboxBatchSize
|
|
}
|
|
items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeSlack, limit)
|
|
if len(items) == 0 {
|
|
return 0
|
|
}
|
|
|
|
successCount := 0
|
|
for i := range items {
|
|
if err := s.processOutbox(items[i].ID); err != nil {
|
|
slog.Warn("process slack outbox failed",
|
|
"outbox_id", items[i].ID,
|
|
"error", err,
|
|
)
|
|
continue
|
|
}
|
|
successCount++
|
|
}
|
|
return successCount
|
|
}
|
|
|
|
func (s *slackOutboundService) processOutbox(outboxID int64) error {
|
|
outbox := ChannelMessageOutboxService.Get(outboxID)
|
|
if outbox == nil {
|
|
return nil
|
|
}
|
|
if outbox.ChannelType != enums.ChannelTypeSlack {
|
|
return nil
|
|
}
|
|
if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) {
|
|
return nil
|
|
}
|
|
if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) {
|
|
return nil
|
|
}
|
|
|
|
if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
|
|
"send_status": string(enums.ChannelMessageOutboxStatusSending),
|
|
"updated_at": time.Now(),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
message := MessageService.Get(outbox.MessageID)
|
|
if message == nil {
|
|
return s.markOutboxFailed(outbox, "message not found")
|
|
}
|
|
conversation := ConversationService.Get(outbox.ConversationID)
|
|
if conversation == nil {
|
|
return s.markOutboxFailed(outbox, "conversation not found")
|
|
}
|
|
|
|
channel := ChannelService.Get(conversation.ChannelID)
|
|
if channel == nil || channel.Status != enums.StatusOk {
|
|
return s.markOutboxFailed(outbox, "slack channel not found or disabled")
|
|
}
|
|
cfg, err := ChannelService.ParseSlackChannelConfig(channel.ConfigJSON)
|
|
if err != nil || cfg == nil {
|
|
return s.markOutboxFailed(outbox, "invalid slack channel config")
|
|
}
|
|
|
|
// The channel's own bot token wins; the deployment-wide SLACK_BOT_TOKEN is
|
|
// the fallback for a single shared Slack app.
|
|
botToken := config.ResolveSlack(cfg.BotToken, cfg.SigningSecret).BotToken
|
|
if botToken == "" {
|
|
return s.markOutboxFailed(outbox, "slack bot token not configured")
|
|
}
|
|
|
|
// Resolve target Slack Channel ID and Thread TS
|
|
var targetChannel string
|
|
var threadTS string
|
|
|
|
lastCustomerMsg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd().
|
|
Eq("conversation_id", conversation.ID).
|
|
Eq("sender_type", enums.IMSenderTypeCustomer).
|
|
Desc("id"))
|
|
if lastCustomerMsg != nil && lastCustomerMsg.Payload != "" {
|
|
var payloadMap map[string]any
|
|
if err := json.Unmarshal([]byte(lastCustomerMsg.Payload), &payloadMap); err == nil {
|
|
if ch, ok := payloadMap["slack_channel"].(string); ok && ch != "" {
|
|
targetChannel = ch
|
|
}
|
|
if ts, ok := payloadMap["slack_thread_ts"].(string); ok && ts != "" {
|
|
threadTS = ts
|
|
}
|
|
}
|
|
}
|
|
|
|
if targetChannel == "" {
|
|
targetChannel = cfg.DefaultChannel
|
|
}
|
|
if targetChannel == "" {
|
|
return s.markOutboxFailed(outbox, "unable to resolve target slack channel")
|
|
}
|
|
|
|
client := slack.NewClient(botToken)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
textToSend := message.Content
|
|
if message.MessageType == enums.IMMessageTypeImage || message.MessageType == enums.IMMessageTypeAttachment {
|
|
assetPayload, err := parseIMMessageAssetPayload(message.Payload)
|
|
if err == nil && assetPayload != nil {
|
|
assetPayload = hydrateIMMessageAssetPayload(assetPayload)
|
|
if assetPayload.Provider != "" && assetPayload.StorageKey != "" {
|
|
if provider, err := storage.NewProvider(assetPayload.Provider); err == nil {
|
|
fileURL := provider.GetSignedURL(assetPayload.StorageKey)
|
|
if fileURL != "" {
|
|
if textToSend != "" {
|
|
textToSend += "\n" + fileURL
|
|
} else {
|
|
textToSend = fileURL
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_, sendErr := client.PostMessage(ctx, targetChannel, textToSend, threadTS)
|
|
if sendErr != nil {
|
|
return s.markOutboxFailed(outbox, sendErr.Error())
|
|
}
|
|
|
|
return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
|
|
"send_status": string(enums.ChannelMessageOutboxStatusSent),
|
|
"sent_at": time.Now(),
|
|
"updated_at": time.Now(),
|
|
})
|
|
}
|
|
|
|
func (s *slackOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error {
|
|
if outbox == nil {
|
|
return nil
|
|
}
|
|
retryCount := outbox.RetryCount + 1
|
|
status := string(enums.ChannelMessageOutboxStatusFailed)
|
|
if retryCount >= slackOutboxMaxRetry {
|
|
status = string(enums.ChannelMessageOutboxStatusIgnored)
|
|
}
|
|
nextRetryAt := time.Now().Add(time.Duration(retryCount*30) * time.Second)
|
|
|
|
return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
|
|
"send_status": status,
|
|
"retry_count": retryCount,
|
|
"next_retry_at": &nextRetryAt,
|
|
"last_error": errMsg,
|
|
"updated_at": time.Now(),
|
|
})
|
|
}
|