Files
agent-desk-ng/internal/services/slack_outbound_service.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(),
})
}