Files
gochat/backend/internal/service/message_service.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

939 lines
30 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/search"
"github.com/gochat/gochat/internal/worker"
applogger "github.com/gochat/gochat/pkg/logger"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// MessageService implements business logic for Message operations.
// Reference: Chatwoot app/controllers/api/v1/messages_controller.rb
type MessageService struct {
repo *repository.MessageRepo
dispatcher *channel.Dispatcher
searchIndexer SearchIndexer
llmProvider llm.Provider
worker *worker.WorkerPool
}
// NewMessageService creates a new Message service.
func NewMessageService(repo *repository.MessageRepo, dispatcher *channel.Dispatcher, llmProvider llm.Provider) *MessageService {
return &MessageService{repo: repo, dispatcher: dispatcher, llmProvider: llmProvider}
}
func (s *MessageService) SetSearchIndexer(indexer SearchIndexer) {
s.searchIndexer = indexer
RegisterMessageDeliverySearchIndexer(s.worker, s.repo.DB(), s.dispatcher, indexer)
}
func (s *MessageService) SetWorkerPool(wp *worker.WorkerPool) {
s.worker = wp
RegisterMessageDeliverySearchIndexer(wp, s.repo.DB(), s.dispatcher, s.searchIndexer)
}
func (s *MessageService) DB() *gorm.DB {
if s == nil || s.repo == nil {
return nil
}
return s.repo.DB()
}
func (s *MessageService) indexMessage(ctx context.Context, message *model.Message) {
if s.searchIndexer != nil {
logSearchIndexError("message", message.ID, s.searchIndexer.IndexMessage(ctx, message))
}
}
func (s *MessageService) deleteMessageIndex(ctx context.Context, accountID uint, id uint) {
if s.searchIndexer != nil {
logSearchIndexError("message", id, s.searchIndexer.DeleteMessage(ctx, accountID, id))
}
}
// dispatchMessageEvent is a helper to build and dispatch a message event.
func (s *MessageService) dispatchMessageEvent(ctx context.Context, eventType channel.EventType, message *model.Message) {
event := channel.NewChannelEvent(eventType, channel.ChannelAPI, message.AccountID, message.InboxID)
event.ConversationID = message.ConversationID
if message.SenderID != nil {
event.UserID = *message.SenderID
}
event.Data["message"] = message
applogger.L().Infof("dispatching event %s for message %d", eventType, message.ID)
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
applogger.L().Errorf("failed to dispatch event %s for message %d: %v", eventType, message.ID, err)
}
}
// ListByConversation retrieves all messages for a conversation.
func (s *MessageService) ListByConversation(ctx context.Context, conversationID uint, offset, limit int) ([]model.Message, int64, error) {
return s.repo.FindByConversation(ctx, conversationID, offset, limit)
}
func (s *MessageService) ResolveConversationForRoute(ctx context.Context, accountID, routeID uint) (*model.Conversation, error) {
var conversation model.Conversation
db := s.repo.DB().WithContext(ctx)
if err := db.Where("account_id = ? AND display_id = ?", accountID, routeID).First(&conversation).Error; err == nil {
return &conversation, nil
}
if err := db.Where("account_id = ? AND id = ?", accountID, routeID).First(&conversation).Error; err != nil {
return nil, err
}
return &conversation, nil
}
// GetByID retrieves a single message.
func (s *MessageService) GetByID(ctx context.Context, id uint) (*model.Message, error) {
return s.repo.FindByID(ctx, id)
}
// GetByAccountAndID retrieves a message scoped to an account.
func (s *MessageService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Message, error) {
return s.repo.FindByAccountAndID(ctx, accountID, id)
}
// GetByConversationAndID retrieves a message scoped to a conversation.
func (s *MessageService) GetByConversationAndID(ctx context.Context, conversationID, id uint) (*model.Message, error) {
return s.repo.FindByConversationAndID(ctx, conversationID, id)
}
// GetByAccountConversationAndID retrieves a message scoped to an account and conversation.
func (s *MessageService) GetByAccountConversationAndID(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) {
return s.repo.FindByAccountConversationAndID(ctx, accountID, conversationID, id)
}
// Search searches messages by content within an account.
func (s *MessageService) Search(ctx context.Context, accountID uint, query string, offset, limit int, searchMode search.SearchMode) ([]model.Message, int64, error) {
return s.repo.Search(ctx, accountID, query, offset, limit, searchMode)
}
// CreateMessageRequest is the DTO for creating a message.
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations/messages_controller.rb #create
type CreateMessageRequest struct {
ConversationID uint `json:"conversation_id" validate:"required"`
Content string `json:"content"`
MessageType string `json:"message_type,omitempty"`
ContentType string `json:"content_type,omitempty"`
Private bool `json:"private,omitempty"`
SenderID uint `json:"sender_id,omitempty"`
SenderType string `json:"sender_type,omitempty"`
SourceID string `json:"source_id,omitempty"`
EchoID string `json:"echo_id,omitempty"`
ExternalCreatedAt string `json:"external_created_at,omitempty"`
ContentAttributes datatypes.JSON `json:"content_attributes,omitempty"`
EmailHTMLContent string `json:"email_html_content,omitempty"`
CCEmails string `json:"cc_emails,omitempty"`
BCCEmails string `json:"bcc_emails,omitempty"`
ToEmails string `json:"to_emails,omitempty"`
CampaignID any `json:"campaign_id,omitempty"`
TemplateParams datatypes.JSON `json:"template_params,omitempty"`
IsVoiceMessage bool `json:"is_voice_message,omitempty"`
Attachments []MessageAttachmentInput `json:"-"`
}
type MessageAttachmentInput struct {
FileName string
FileSize int
ContentType string
}
// Create creates a new message.
func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint, req CreateMessageRequest) (*model.Message, error) {
req.MessageType = normalizeMessageType(req.MessageType)
if req.ContentType == "" {
req.ContentType = "text"
}
if !validMessageType(req.MessageType) {
return nil, fmt.Errorf("invalid message_type")
}
if !validContentType(req.ContentType) {
return nil, fmt.Errorf("invalid content_type")
}
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
if strings.TrimSpace(req.Content) == "" && len(req.Attachments) == 0 && !contentTypeAllowsEmptyContent(req.ContentType, req.ContentAttributes) {
return nil, fmt.Errorf("content is required")
}
var conversation model.Conversation
if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, req.ConversationID).First(&conversation).Error; err != nil {
return nil, err
}
var inbox *model.Inbox
loadInbox := func() *model.Inbox {
if inbox != nil {
return inbox
}
var loaded model.Inbox
if err := s.repo.DB().WithContext(ctx).Select("id", "channel_type").Where("id = ?", conversation.InboxID).First(&loaded).Error; err == nil {
inbox = &loaded
if strings.TrimSpace(conversation.ChannelType) == "" {
conversation.ChannelType = loaded.ChannelType
}
}
return inbox
}
if req.MessageType == "incoming" && !messageCreateAllowedIncoming(conversation.ChannelType) {
if loaded := loadInbox(); loaded != nil {
conversation.ChannelType = loaded.ChannelType
}
if !messageCreateAllowedIncoming(conversation.ChannelType) {
return nil, fmt.Errorf("Incoming messages are only allowed in Api inboxes")
}
}
contentAttributes := messageContentAttributes(req.ContentAttributes)
contentAttributes = mergeMessageContentAttributes(contentAttributes, map[string]any{
"external_created_at": strings.TrimSpace(req.ExternalCreatedAt),
})
contentAttributes = s.resolveInReplyToContentAttributes(ctx, req.ConversationID, contentAttributes)
if messageCreateEmailInbox(conversation.ChannelType) || (loadInbox() != nil && messageCreateEmailInbox(inbox.ChannelType)) {
contentAttributes = mergeMessageContentAttributes(contentAttributes, map[string]any{
"cc_emails": parseEmailList(req.CCEmails),
"bcc_emails": parseEmailList(req.BCCEmails),
"to_emails": parseEmailList(req.ToEmails),
})
if !req.Private && strings.TrimSpace(req.Content) != "" {
contentAttributes = mergeEmailContentAttributes(contentAttributes, req.Content, req.EmailHTMLContent)
}
}
senderID := userID
senderType := "user"
if strings.TrimSpace(req.SenderType) == string(model.SenderTypeAgentBot) && req.SenderID != 0 {
var bot model.AgentBot
if err := s.repo.DB().WithContext(ctx).
Where("id = ? AND (account_id IS NULL OR account_id = ?)", req.SenderID, accountID).
First(&bot).Error; err == nil {
senderID = bot.ID
senderType = string(model.SenderTypeAgentBot)
}
}
message := &model.Message{
AccountID: accountID,
ConversationID: req.ConversationID,
InboxID: conversation.InboxID,
Content: req.Content,
MessageType: req.MessageType,
ContentType: req.ContentType,
SenderID: &senderID,
SenderType: senderType,
Private: req.Private,
SourceID: req.SourceID,
EchoID: req.EchoID,
Status: "sent",
ContentAttributes: contentAttributes,
AdditionalAttributes: messageAdditionalAttributes(map[string]any{
"campaign_id": req.CampaignID,
"template_params": req.TemplateParams,
}),
}
// Chatwoot: when message_type is "private_note", force Private=true and ContentType="private_note"
if req.MessageType == "private_note" {
message.Private = true
if message.ContentType == "text" {
message.ContentType = "private_note"
}
}
if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(message).Error; err != nil {
return err
}
for _, input := range req.Attachments {
fileType := attachmentFileType(input.ContentType)
attachment := &model.Attachment{
MessageID: message.ID,
AccountID: accountID,
FileType: fileType,
FileURL: attachmentDataURL(message.ID, input.FileName),
ThumbURL: attachmentThumbURL(input.ContentType, message.ID, input.FileName),
FileSize: input.FileSize,
FileName: input.FileName,
}
if req.IsVoiceMessage && fileType == "audio" {
attachment.Metadata = `{"is_voice_message":true}`
}
if err := tx.Create(attachment).Error; err != nil {
return err
}
}
return nil
}); err != nil {
applogger.L().Errorf("Failed to create message: %v", err)
return nil, err
}
// Dispatch EventMessageCreated
s.dispatchMessageEvent(ctx, channel.EventMessageCreated, message)
s.indexMessage(ctx, message)
// Dispatch additional event based on message type
if req.MessageType == "incoming" {
s.dispatchMessageEvent(ctx, channel.EventMessageIncoming, message)
if s.worker != nil {
if _, err := EnqueueCaptainConversationResponseForMessage(ctx, s.worker, s.repo.DB(), message.ID); err != nil {
return message, err
}
}
} else if req.MessageType == "outgoing" {
if s.worker != nil {
if _, err := EnqueueSendReply(ctx, s.worker, message.ID); err != nil {
return message, err
}
} else {
s.dispatchMessageEvent(ctx, channel.EventMessageOutgoing, message)
}
}
return message, nil
}
func messageCreateAllowedIncoming(channelType string) bool {
switch strings.TrimSpace(channelType) {
case "api", string(model.InboxChannelTypeAPI):
return true
default:
return false
}
}
func messageCreateEmailInbox(channelType string) bool {
switch strings.TrimSpace(channelType) {
case "email", string(model.InboxChannelTypeEmail):
return true
default:
return false
}
}
func messageContentAttributes(raw datatypes.JSON) datatypes.JSON {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
attrs := map[string]any{}
if err := json.Unmarshal(raw, &attrs); err != nil {
var encoded string
if stringErr := json.Unmarshal(raw, &encoded); stringErr != nil || strings.TrimSpace(encoded) == "" {
return nil
}
if err := json.Unmarshal([]byte(encoded), &attrs); err != nil {
return nil
}
}
if len(attrs) == 0 {
return nil
}
encoded, err := json.Marshal(attrs)
if err != nil {
return nil
}
return datatypes.JSON(encoded)
}
func mergeMessageContentAttributes(raw datatypes.JSON, values map[string]any) datatypes.JSON {
attrs := map[string]any{}
if len(raw) > 0 && string(raw) != "null" {
_ = json.Unmarshal(raw, &attrs)
}
for key, value := range values {
if list, ok := value.([]string); ok && len(list) == 0 {
continue
}
attrs[key] = value
}
if len(attrs) == 0 {
return nil
}
encoded, err := json.Marshal(attrs)
if err != nil {
return nil
}
return datatypes.JSON(encoded)
}
func mergeEmailContentAttributes(raw datatypes.JSON, content string, htmlContent string) datatypes.JSON {
attrs := map[string]any{}
if len(raw) > 0 && string(raw) != "null" {
_ = json.Unmarshal(raw, &attrs)
}
emailAttrs := map[string]any{}
if existing, ok := attrs["email"].(map[string]any); ok {
emailAttrs = existing
}
textAttrs := map[string]any{}
if existing, ok := emailAttrs["text_content"].(map[string]any); ok {
textAttrs = existing
}
textAttrs["full"] = content
textAttrs["reply"] = content
emailAttrs["text_content"] = textAttrs
renderedHTML := strings.TrimSpace(htmlContent)
if renderedHTML == "" {
renderedHTML = content
}
htmlAttrs := map[string]any{}
if existing, ok := emailAttrs["html_content"].(map[string]any); ok {
htmlAttrs = existing
}
htmlAttrs["full"] = renderedHTML
htmlAttrs["reply"] = renderedHTML
emailAttrs["html_content"] = htmlAttrs
attrs["email"] = emailAttrs
encoded, err := json.Marshal(attrs)
if err != nil {
return raw
}
return datatypes.JSON(encoded)
}
func (s *MessageService) resolveInReplyToContentAttributes(ctx context.Context, conversationID uint, raw datatypes.JSON) datatypes.JSON {
attrs := map[string]any{}
if len(raw) > 0 && string(raw) != "null" {
_ = json.Unmarshal(raw, &attrs)
}
if _, hasReplyID := attrs["in_reply_to"]; !hasReplyID {
if _, hasExternalID := attrs["in_reply_to_external_id"]; !hasExternalID {
return raw
}
}
var replyMessage model.Message
found := false
if replyID, ok := uintFromAny(attrs["in_reply_to"]); ok && replyID != 0 {
if err := s.repo.DB().WithContext(ctx).Where("conversation_id = ? AND id = ?", conversationID, replyID).First(&replyMessage).Error; err == nil {
found = true
}
}
if !found {
if externalID, ok := stringFromAny(attrs["in_reply_to_external_id"]); ok && strings.TrimSpace(externalID) != "" {
if err := s.repo.DB().WithContext(ctx).Where("conversation_id = ? AND source_id = ?", conversationID, strings.TrimSpace(externalID)).First(&replyMessage).Error; err == nil {
found = true
}
}
}
if found {
attrs["in_reply_to"] = replyMessage.ID
attrs["in_reply_to_external_id"] = replyMessage.SourceID
} else {
attrs["in_reply_to"] = nil
attrs["in_reply_to_external_id"] = nil
}
encoded, err := json.Marshal(attrs)
if err != nil {
return raw
}
return datatypes.JSON(encoded)
}
func uintFromAny(value any) (uint, bool) {
switch typed := value.(type) {
case float64:
if typed > 0 && typed == float64(uint(typed)) {
return uint(typed), true
}
case int:
if typed > 0 {
return uint(typed), true
}
case uint:
if typed > 0 {
return typed, true
}
case string:
parsed, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64)
if err == nil && parsed > 0 {
return uint(parsed), true
}
}
return 0, false
}
func stringFromAny(value any) (string, bool) {
if value == nil {
return "", false
}
if typed, ok := value.(string); ok {
return typed, true
}
return fmt.Sprint(value), true
}
func parseEmailList(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(strings.ReplaceAll(value, " ", ""), ",")
emails := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
emails = append(emails, part)
}
}
return emails
}
func messageAdditionalAttributes(values map[string]any) datatypes.JSON {
attrs := map[string]any{}
for key, value := range values {
switch v := value.(type) {
case nil:
continue
case string:
if strings.TrimSpace(v) == "" {
continue
}
attrs[key] = v
case datatypes.JSON:
if len(v) == 0 || string(v) == "null" {
continue
}
var parsed any
if err := json.Unmarshal(v, &parsed); err == nil {
attrs[key] = parsed
}
default:
attrs[key] = v
}
}
if len(attrs) == 0 {
return nil
}
encoded, err := json.Marshal(attrs)
if err != nil {
return nil
}
return datatypes.JSON(encoded)
}
func normalizeMessageType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "1", "outgoing":
return "outgoing"
case "0", "incoming":
return "incoming"
case "2", "activity":
return "activity"
case "3", "template":
return "template"
case "private_note":
return "private_note"
default:
return value
}
}
func validMessageType(value string) bool {
switch value {
case "incoming", "outgoing", "activity", "template", "private_note":
return true
default:
return false
}
}
func validContentType(value string) bool {
switch value {
case "text", "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "incoming_email", "input_csat", "integrations", "sticker", "voice_call", "input_phone", "select", "card", "private_note", "file", "image", "audio", "video":
return true
default:
return false
}
}
func contentTypeAllowsEmptyContent(contentType string, contentAttributes datatypes.JSON) bool {
switch contentType {
case "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "input_csat", "integrations", "sticker", "voice_call", "select", "card":
return len(messageContentAttributes(contentAttributes)) > 0
default:
return false
}
}
// UpdateMessageRequest is the DTO for updating a message.
type UpdateMessageRequest struct {
Status string `json:"status,omitempty"`
ExternalError string `json:"external_error,omitempty"`
}
// Update modifies an existing message.
func (s *MessageService) Update(ctx context.Context, accountID, id uint, req UpdateMessageRequest) (*model.Message, error) {
return s.UpdateInConversation(ctx, accountID, 0, id, req)
}
// UpdateInConversation modifies a message scoped to a conversation route.
func (s *MessageService) UpdateInConversation(ctx context.Context, accountID, conversationID, id uint, req UpdateMessageRequest) (*model.Message, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id)
if err != nil {
return nil, err
}
if !s.messageInboxIsAPI(ctx, message.InboxID) {
return nil, fmt.Errorf("Message status update is only allowed for API inboxes")
}
if req.Status != "" {
if !validMessageStatus(req.Status) {
return nil, fmt.Errorf("invalid status")
}
if !(message.Status == "read" && req.Status == "delivered") {
message.Status = req.Status
message.ContentAttributes = setMessageExternalError(message.ContentAttributes, req.Status, req.ExternalError)
}
}
if err := s.repo.Update(ctx, message); err != nil {
return nil, err
}
// Dispatch EventMessageUpdated
s.dispatchMessageEvent(ctx, channel.EventMessageUpdated, message)
s.indexMessage(ctx, message)
return message, nil
}
func (s *MessageService) messageInboxIsAPI(ctx context.Context, inboxID uint) bool {
var inbox model.Inbox
if err := s.repo.DB().WithContext(ctx).First(&inbox, inboxID).Error; err != nil {
return false
}
switch strings.ToLower(inbox.ChannelType) {
case "api", "channel::api":
return true
default:
return false
}
}
// Delete marks a message deleted using Chatwoot's visible tombstone payload.
func (s *MessageService) Delete(ctx context.Context, accountID, id uint) (*model.Message, error) {
return s.DeleteInConversation(ctx, accountID, 0, id)
}
// DeleteInConversation marks a message deleted using Chatwoot's conversation-scoped lookup.
func (s *MessageService) DeleteInConversation(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) {
message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id)
if err != nil {
return nil, err
}
message.Content = "This message was deleted"
message.ContentType = "text"
message.ContentAttributes = datatypes.JSON([]byte(`{"deleted":true}`))
if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Save(message).Error; err != nil {
return err
}
if err := tx.Where("message_id = ?", message.ID).Delete(&model.Attachment{}).Error; err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
// Dispatch EventMessageDeleted
s.dispatchMessageEvent(ctx, channel.EventMessageDeleted, message)
s.deleteMessageIndex(ctx, accountID, message.ID)
return message, nil
}
// UpdateStatus updates the delivery status of a message and dispatches EventMessageStatusUpdated.
func (s *MessageService) UpdateStatus(ctx context.Context, id uint, status string) (*model.Message, error) {
message, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
message.Status = status
if err := s.repo.Update(ctx, message); err != nil {
return nil, err
}
// Dispatch EventMessageStatusUpdated
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
applogger.L().Infof("dispatching event %s for message %d, status=%s", channel.EventMessageStatusUpdated, message.ID, status)
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
applogger.L().Errorf("failed to dispatch event %s for message %d: %v", channel.EventMessageStatusUpdated, message.ID, err)
}
s.indexMessage(ctx, message)
return message, nil
}
func (s *MessageService) ListByConversationFinder(ctx context.Context, conversationID uint, after, before uint, filterInternal bool) ([]model.Message, int64, error) {
return s.repo.FindByConversationFinder(ctx, conversationID, after, before, filterInternal)
}
func validMessageStatus(value string) bool {
switch value {
case "sent", "delivered", "read", "failed":
return true
default:
return false
}
}
func setMessageExternalError(attrs datatypes.JSON, status, externalError string) datatypes.JSON {
obj := map[string]any{}
if len(attrs) > 0 {
_ = json.Unmarshal(attrs, &obj)
}
if status == "failed" && strings.TrimSpace(externalError) != "" {
obj["external_error"] = externalError
} else {
delete(obj, "external_error")
}
bytes, _ := json.Marshal(obj)
return datatypes.JSON(bytes)
}
func attachmentFileType(contentType string) string {
contentType = strings.ToLower(contentType)
switch {
case strings.HasPrefix(contentType, "image/"):
return "image"
case strings.HasPrefix(contentType, "audio/"):
return "audio"
case strings.HasPrefix(contentType, "video/"):
return "video"
default:
return "file"
}
}
func attachmentDataURL(messageID uint, fileName string) string {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return ""
}
return fmt.Sprintf("/uploads/messages/%d/%s", messageID, fileName)
}
func attachmentThumbURL(contentType string, messageID uint, fileName string) string {
if strings.HasPrefix(strings.ToLower(contentType), "image/") {
return attachmentDataURL(messageID, fileName)
}
return ""
}
// Retry retries a failed message by resetting its delivery status.
// Reference: Chatwoot MessagesController#retry sets status to sent, clears
// content_attributes, and queues SendReplyJob.
func (s *MessageService) Retry(ctx context.Context, accountID, id uint) (*model.Message, error) {
return s.RetryInConversation(ctx, accountID, 0, id)
}
// RetryInConversation retries a failed message scoped to a conversation route.
func (s *MessageService) RetryInConversation(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) {
message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id)
if err != nil {
return nil, err
}
message.Status = "sent"
message.ContentAttributes = datatypes.JSON([]byte(`{}`))
if err := s.repo.Update(ctx, message); err != nil {
return nil, err
}
if s.worker != nil {
if _, err := EnqueueSendReply(ctx, s.worker, message.ID); err != nil {
return nil, err
}
}
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"] = "sent"
applogger.L().Infof("dispatching retry event for message %d", message.ID)
if err := s.dispatcher.Dispatch(ctx, event); err != nil {
applogger.L().Errorf("failed to dispatch retry event for message %d: %v", message.ID, err)
}
s.indexMessage(ctx, message)
return message, nil
}
func (s *MessageService) findMessageForConversationRoute(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) {
if conversationID == 0 {
return s.repo.FindByAccountAndID(ctx, accountID, id)
}
return s.repo.FindByAccountConversationAndID(ctx, accountID, conversationID, id)
}
// CountByConversation returns the total message count in a conversation.
func (s *MessageService) CountByConversation(ctx context.Context, conversationID uint) (int64, error) {
return s.repo.CountByConversation(ctx, conversationID)
}
// TranslateMessageRequest is the DTO for translating a message.
type TranslateMessageRequest struct {
TargetLanguage string `json:"target_language" validate:"required"`
}
// TranslateMessageResult holds the translated message content.
type TranslateMessageResult struct {
ID uint `json:"id"`
OriginalContent string `json:"original_content"`
TranslatedContent string `json:"translated_content"`
SourceLanguage string `json:"source_language,omitempty"`
TargetLanguage string `json:"target_language"`
AlreadyTranslated bool `json:"-"`
}
// Translate translates a message's content to the target language using LLM.
func (s *MessageService) Translate(ctx context.Context, accountID, id uint, req TranslateMessageRequest) (*TranslateMessageResult, error) {
return s.TranslateInConversation(ctx, accountID, 0, id, req)
}
// TranslateInConversation translates a message scoped to a conversation route and caches the result.
func (s *MessageService) TranslateInConversation(ctx context.Context, accountID, conversationID, id uint, req TranslateMessageRequest) (*TranslateMessageResult, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id)
if err != nil {
return nil, err
}
if translated, ok := messageTranslationForLanguage(message.ContentAttributes, req.TargetLanguage); ok {
return &TranslateMessageResult{
ID: message.ID,
OriginalContent: message.Content,
TranslatedContent: translated,
TargetLanguage: req.TargetLanguage,
AlreadyTranslated: true,
}, nil
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Messages: []llm.ChatMessage{
{Role: "system", Content: fmt.Sprintf("You are a translator. Translate the user's message to %s. Return only the translated text, nothing else.", req.TargetLanguage)},
{Role: "user", Content: message.Content},
},
})
if err != nil {
applogger.L().Errorf("Translate message LLM: %v", err)
return nil, fmt.Errorf("translate message: %w", err)
}
translated := ""
if len(llmResp.Choices) > 0 {
translated = llmResp.Choices[0].Message.Content
}
if strings.TrimSpace(translated) != "" {
message.ContentAttributes = setMessageTranslation(message.ContentAttributes, req.TargetLanguage, translated)
if err := s.repo.Update(ctx, message); err != nil {
return nil, err
}
s.indexMessage(ctx, message)
}
return &TranslateMessageResult{
ID: message.ID,
OriginalContent: message.Content,
TranslatedContent: translated,
TargetLanguage: req.TargetLanguage,
}, nil
}
func messageTranslationForLanguage(attrs datatypes.JSON, language string) (string, bool) {
language = strings.TrimSpace(language)
if language == "" {
return "", false
}
obj := map[string]any{}
if len(attrs) == 0 || string(attrs) == "null" {
return "", false
}
if err := json.Unmarshal(attrs, &obj); err != nil {
return "", false
}
translations, ok := obj["translations"].(map[string]any)
if !ok {
return "", false
}
value, ok := translations[language].(string)
return value, ok && strings.TrimSpace(value) != ""
}
func setMessageTranslation(attrs datatypes.JSON, language string, translated string) datatypes.JSON {
obj := map[string]any{}
if len(attrs) > 0 && string(attrs) != "null" {
_ = json.Unmarshal(attrs, &obj)
}
translations := map[string]any{}
if existing, ok := obj["translations"].(map[string]any); ok {
translations = existing
}
translations[strings.TrimSpace(language)] = translated
obj["translations"] = translations
encoded, err := json.Marshal(obj)
if err != nil {
return attrs
}
return datatypes.JSON(encoded)
}
// ListAttachments returns paginated attachments for all messages in a conversation.
// Reference: Chatwoot conversations_controller.rb #attachments (member route)
func (s *MessageService) ListAttachments(ctx context.Context, accountID, conversationID uint, offset, limit int) ([]model.Attachment, int64, error) {
var attachments []model.Attachment
var total int64
db := s.repo.DB()
scope := db.WithContext(ctx).
Where("account_id = ? AND message_id IN (SELECT id FROM messages WHERE conversation_id = ?)", accountID, conversationID)
if err := scope.Model(&model.Attachment{}).Count(&total).Error; err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 100
}
if err := scope.
Preload("Message").
Order("created_at DESC, id DESC").
Offset(offset).
Limit(limit).
Find(&attachments).Error; err != nil {
return nil, 0, err
}
return attachments, total, nil
}