feat(conversations): align transcript delivery
This commit is contained in:
@@ -525,10 +525,24 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID
|
||||
if len(recipients) == 0 {
|
||||
return ActionDeliveryResult{DeliveryType: "email_transcript"}, fmt.Errorf("send_email_transcript action requires 'email' param")
|
||||
}
|
||||
account, err := s.loadTranscriptAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err
|
||||
}
|
||||
if !account.EmailTranscriptEnabled() {
|
||||
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ","), ResponseBody: "email_transcript_disabled"}, nil
|
||||
}
|
||||
|
||||
if s.worker != nil {
|
||||
for _, recipient := range recipients {
|
||||
_, err := s.worker.Enqueue(ctx, TaskTypeAutomationTranscriptDelivery, automationTranscriptDeliveryJob{
|
||||
allowed, err := s.consumeTranscriptEmailQuota(ctx, account, time.Now())
|
||||
if err != nil {
|
||||
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err
|
||||
}
|
||||
if !allowed {
|
||||
break
|
||||
}
|
||||
_, err = s.worker.Enqueue(ctx, TaskTypeAutomationTranscriptDelivery, automationTranscriptDeliveryJob{
|
||||
AccountID: accountID,
|
||||
ConversationID: conversationID,
|
||||
Recipient: recipient,
|
||||
@@ -547,6 +561,13 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID
|
||||
|
||||
aggregate := ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}
|
||||
for _, recipient := range recipients {
|
||||
allowed, err := s.consumeTranscriptEmailQuota(ctx, account, time.Now())
|
||||
if err != nil {
|
||||
return aggregate, err
|
||||
}
|
||||
if !allowed {
|
||||
break
|
||||
}
|
||||
result, err := s.transcriptDeliverer.DeliverTranscript(ctx, AutomationTranscriptRequest{
|
||||
AccountID: accountID,
|
||||
ConversationID: conversationID,
|
||||
@@ -847,6 +868,30 @@ func extractTranscriptRecipients(params map[string]interface{}) []string {
|
||||
return recipients
|
||||
}
|
||||
|
||||
func (s *ActionService) loadTranscriptAccount(ctx context.Context, accountID uint) (*model.Account, error) {
|
||||
var account model.Account
|
||||
if err := s.db.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *ActionService) consumeTranscriptEmailQuota(ctx context.Context, account *model.Account, now time.Time) (bool, error) {
|
||||
if account == nil {
|
||||
return false, fmt.Errorf("account is required")
|
||||
}
|
||||
if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit {
|
||||
return false, nil
|
||||
}
|
||||
if err := account.IncrementEmailSentCount(now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := s.db.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ActionService) buildAutomationWebhookPayload(ctx context.Context, accountID, conversationID uint, eventName string) (map[string]interface{}, error) {
|
||||
var conversation model.Conversation
|
||||
if err := s.db.DB().WithContext(ctx).
|
||||
@@ -937,7 +982,7 @@ func (s *ActionService) buildTranscriptEmail(ctx context.Context, accountID, con
|
||||
|
||||
var messages []model.Message
|
||||
if err := s.db.DB().WithContext(ctx).
|
||||
Where("conversation_id = ? AND account_id = ? AND private = ?", conversationID, accountID, false).
|
||||
Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversationID, accountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}).
|
||||
Order("id ASC").
|
||||
Find(&messages).Error; err != nil {
|
||||
return "", "", err
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -123,6 +124,9 @@ func TestActionService_SendEmailTranscript_DeliversSplitRecipients(t *testing.T)
|
||||
if err := db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "transcript body", ContentType: "text", MessageType: "incoming"}).Error; err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
_ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "private body", ContentType: "text", MessageType: "incoming", Private: true}).Error
|
||||
_ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "activity body", ContentType: "text", MessageType: "activity"}).Error
|
||||
_ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "template body", ContentType: "text", MessageType: "template"}).Error
|
||||
|
||||
result, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
|
||||
ActionName: "send_email_transcript",
|
||||
@@ -140,11 +144,70 @@ func TestActionService_SendEmailTranscript_DeliversSplitRecipients(t *testing.T)
|
||||
if !strings.Contains(transcript.requests[0].Subject, "Conversation Transcript") || !strings.Contains(transcript.requests[0].Body, "transcript body") {
|
||||
t.Fatalf("expected transcript subject/body to be populated: %#v", transcript.requests[0])
|
||||
}
|
||||
if strings.Contains(transcript.requests[0].Body, "private body") || strings.Contains(transcript.requests[0].Body, "activity body") || strings.Contains(transcript.requests[0].Body, "template body") {
|
||||
t.Fatalf("transcript body should include only public incoming/outgoing chat messages: %q", transcript.requests[0].Body)
|
||||
}
|
||||
var account model.Account
|
||||
if err := db.First(&account, accountID).Error; err != nil || account.EmailsSentToday(time.Now()) != 2 {
|
||||
t.Fatalf("expected two transcript sends to increment account counter, account=%#v err=%v", account, err)
|
||||
}
|
||||
if result.DeliveryType != "email_transcript" || result.Target != "first@example.com,second@example.com" || result.Attempts != 2 {
|
||||
t.Fatalf("unexpected transcript action result metadata: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionService_SendEmailTranscript_DisabledNoops(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, _ := seedTestAccount(db, t)
|
||||
if err := db.Model(&model.Account{}).Where("id = ?", accountID).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error; err != nil {
|
||||
t.Fatalf("disable transcripts: %v", err)
|
||||
}
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
|
||||
transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}}
|
||||
restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript)
|
||||
defer restore()
|
||||
|
||||
result, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
|
||||
ActionName: "send_email_transcript",
|
||||
ActionParams: map[string]interface{}{"email": "first@example.com"},
|
||||
}, ActionSourceAutomation, 99)
|
||||
if err != nil {
|
||||
t.Fatalf("disabled transcript action should no-op: %v", err)
|
||||
}
|
||||
if len(transcript.requests) != 0 || result.ResponseBody != "email_transcript_disabled" {
|
||||
t.Fatalf("expected disabled transcript no-op, result=%#v requests=%#v", result, transcript.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionService_SendEmailTranscript_StopsAtRateLimit(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, _ := seedTestAccount(db, t)
|
||||
if err := db.Model(&model.Account{}).Where("id = ?", accountID).Updates(map[string]any{"limits": datatypes.JSON(`{"emails":1}`)}).Error; err != nil {
|
||||
t.Fatalf("set email limit: %v", err)
|
||||
}
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
|
||||
transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}}
|
||||
restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript)
|
||||
defer restore()
|
||||
|
||||
_, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
|
||||
ActionName: "send_email_transcript",
|
||||
ActionParams: map[string]interface{}{"email": "first@example.com,second@example.com"},
|
||||
}, ActionSourceAutomation, 99)
|
||||
if err != nil {
|
||||
t.Fatalf("rate-limited transcript action should stop without error: %v", err)
|
||||
}
|
||||
if len(transcript.requests) != 1 || transcript.requests[0].Recipient != "first@example.com" {
|
||||
t.Fatalf("expected only first recipient before rate limit, got %#v", transcript.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionService_SendWebhookEvent_QueuesDurableDelivery(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
|
||||
@@ -721,6 +721,14 @@ func (h *ConversationHandler) Transcript(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if svcErr := h.conversationSvc.SendTranscript(c.Request.Context(), accountID, conversation.ID, req.Email); svcErr != nil {
|
||||
if errors.Is(svcErr, service.ErrEmailTranscriptDisabled) {
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"error": "Email transcript is not available on your plan"})
|
||||
return
|
||||
}
|
||||
if errors.Is(svcErr, service.ErrEmailRateLimited) {
|
||||
c.Status(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -395,6 +396,46 @@ func (s *ConversationHandlerTestSuite) TestTranscript_InvalidEmail() {
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
}
|
||||
|
||||
func (s *ConversationHandlerTestSuite) TestTranscript_PaymentRequiredWhenDisabled() {
|
||||
s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{
|
||||
"limits": datatypes.JSON(`{"email_transcript_enabled":false}`),
|
||||
"custom_attributes": datatypes.JSON(`{}`),
|
||||
}).Error)
|
||||
s.T().Cleanup(func() {
|
||||
_ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error
|
||||
})
|
||||
bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusPaymentRequired, w.Code)
|
||||
var resp map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), "Email transcript is not available on your plan", resp["error"])
|
||||
}
|
||||
|
||||
func (s *ConversationHandlerTestSuite) TestTranscript_TooManyRequestsWhenRateLimited() {
|
||||
s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{
|
||||
"limits": datatypes.JSON(`{"emails":1}`),
|
||||
"custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`),
|
||||
}).Error)
|
||||
s.T().Cleanup(func() {
|
||||
_ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error
|
||||
})
|
||||
bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusTooManyRequests, w.Code)
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
}
|
||||
|
||||
func (s *ConversationHandlerTestSuite) TestTranscript_ConversationNotFound() {
|
||||
body := map[string]string{"email": "test@example.com"}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
@@ -20,6 +20,7 @@ type Account struct {
|
||||
CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"`
|
||||
FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags
|
||||
AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days
|
||||
Limits datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"limits,omitempty"` // Chatwoot account limits, e.g. emails
|
||||
AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents]
|
||||
InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes]
|
||||
CaptainModels datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_models,omitempty"`
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
const accountOutboundEmailCountKey = "_outbound_email_count"
|
||||
|
||||
// LimitsMap returns the Chatwoot-style account limits JSON object.
|
||||
func (a *Account) LimitsMap() map[string]any {
|
||||
return jsonMap(a.Limits)
|
||||
}
|
||||
|
||||
// EmailTranscriptEnabled mirrors Account#email_transcript_enabled?. Community
|
||||
// accounts allow transcripts by default; enterprise tests may disable it via
|
||||
// limits or custom attributes.
|
||||
func (a *Account) EmailTranscriptEnabled() bool {
|
||||
if a == nil {
|
||||
return true
|
||||
}
|
||||
for _, attrs := range []map[string]any{a.LimitsMap(), a.CustomAttributesMap()} {
|
||||
if value, ok := firstPresent(attrs, "email_transcript_enabled", "email_transcript"); ok {
|
||||
return truthy(value, true)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EmailRateLimit returns the configured daily transcript/outbound email limit.
|
||||
// A zero value means unlimited, matching non-cloud Chatwoot behavior.
|
||||
func (a *Account) EmailRateLimit() int {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
for _, attrs := range []map[string]any{a.LimitsMap(), a.CustomAttributesMap()} {
|
||||
if value, ok := firstPresent(attrs, "emails", "email_rate_limit", "outbound_email_limit"); ok {
|
||||
if n := intValue(value); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (a *Account) EmailsSentToday(now time.Time) int {
|
||||
attrs := a.CustomAttributesMap()
|
||||
value, ok := attrs[accountOutboundEmailCountKey]
|
||||
if !ok {
|
||||
return intValue(attrs["emails_sent_today"])
|
||||
}
|
||||
record, ok := value.(map[string]any)
|
||||
if !ok || strings.TrimSpace(stringValue(record["date"])) != now.Format("2006-01-02") {
|
||||
return 0
|
||||
}
|
||||
return intValue(record["count"])
|
||||
}
|
||||
|
||||
func (a *Account) IncrementEmailSentCount(now time.Time) error {
|
||||
attrs := a.CustomAttributesMap()
|
||||
count := a.EmailsSentToday(now) + 1
|
||||
attrs[accountOutboundEmailCountKey] = map[string]any{
|
||||
"date": now.Format("2006-01-02"),
|
||||
"count": count,
|
||||
}
|
||||
return a.SetCustomAttributesMap(attrs)
|
||||
}
|
||||
|
||||
func jsonMap(raw datatypes.JSON) map[string]any {
|
||||
attrs := map[string]any{}
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &attrs)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func firstPresent(attrs map[string]any, keys ...string) (any, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := attrs[key]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func truthy(value any, defaultValue bool) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
s := strings.TrimSpace(strings.ToLower(v))
|
||||
if s == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return s == "true" || s == "1" || s == "yes" || s == "enabled"
|
||||
case float64:
|
||||
return v != 0
|
||||
case int:
|
||||
return v != 0
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(value any) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case json.Number:
|
||||
n, _ := v.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if s, ok := value.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/automation"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
@@ -22,20 +23,21 @@ import (
|
||||
// ConversationService implements business logic for Conversation operations.
|
||||
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb
|
||||
type ConversationService struct {
|
||||
repo *repository.ConversationRepo
|
||||
msgRepo *repository.MessageRepo
|
||||
dispatcher *channel.Dispatcher
|
||||
inboxMemberSvc *InboxMemberService
|
||||
accountUserRepo *repository.AccountUserRepo
|
||||
teamRepo *repository.TeamRepo
|
||||
teamMemberRepo *repository.TeamMemberRepo
|
||||
searchIndexer SearchIndexer
|
||||
appliedSlaSvc *AppliedSlaService
|
||||
repo *repository.ConversationRepo
|
||||
msgRepo *repository.MessageRepo
|
||||
dispatcher *channel.Dispatcher
|
||||
inboxMemberSvc *InboxMemberService
|
||||
accountUserRepo *repository.AccountUserRepo
|
||||
teamRepo *repository.TeamRepo
|
||||
teamMemberRepo *repository.TeamMemberRepo
|
||||
searchIndexer SearchIndexer
|
||||
appliedSlaSvc *AppliedSlaService
|
||||
transcriptMailer automation.AutomationTranscriptDeliverer
|
||||
}
|
||||
|
||||
// NewConversationService creates a new Conversation service.
|
||||
func NewConversationService(repo *repository.ConversationRepo, msgRepo *repository.MessageRepo, dispatcher *channel.Dispatcher, inboxMemberSvc *InboxMemberService, accountUserRepo *repository.AccountUserRepo, teamRepo *repository.TeamRepo, teamMemberRepo *repository.TeamMemberRepo) *ConversationService {
|
||||
return &ConversationService{repo: repo, msgRepo: msgRepo, dispatcher: dispatcher, inboxMemberSvc: inboxMemberSvc, accountUserRepo: accountUserRepo, teamRepo: teamRepo, teamMemberRepo: teamMemberRepo}
|
||||
return &ConversationService{repo: repo, msgRepo: msgRepo, dispatcher: dispatcher, inboxMemberSvc: inboxMemberSvc, accountUserRepo: accountUserRepo, teamRepo: teamRepo, teamMemberRepo: teamMemberRepo, transcriptMailer: automation.NewEnvAutomationTranscriptDeliverer()}
|
||||
}
|
||||
|
||||
func (s *ConversationService) SetSearchIndexer(indexer SearchIndexer) {
|
||||
@@ -46,6 +48,10 @@ func (s *ConversationService) SetAppliedSlaService(appliedSlaSvc *AppliedSlaServ
|
||||
s.appliedSlaSvc = appliedSlaSvc
|
||||
}
|
||||
|
||||
func (s *ConversationService) SetTranscriptDeliverer(deliverer automation.AutomationTranscriptDeliverer) {
|
||||
s.transcriptMailer = deliverer
|
||||
}
|
||||
|
||||
func (s *ConversationService) DB() *gorm.DB {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil
|
||||
@@ -1454,24 +1460,81 @@ func (s *ConversationService) MarkUnread(ctx context.Context, accountID, id uint
|
||||
// SendTranscript sends a conversation transcript via email.
|
||||
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#transcript
|
||||
func (s *ConversationService) SendTranscript(ctx context.Context, accountID, conversationID uint, email string) error {
|
||||
recipient := strings.TrimSpace(email)
|
||||
if recipient == "" {
|
||||
return errors.New("email address is required for transcript")
|
||||
}
|
||||
|
||||
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, conversationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// In a full implementation, this would generate the transcript and send via an email service.
|
||||
// For now, we log the transcript request for future integration.
|
||||
applogger.L().Infof("Transcript request: conversation=%d, account=%d, email=%s, contact=%d",
|
||||
conversation.ID, conversation.AccountID, email, conversation.ContactID)
|
||||
|
||||
// Validate email is not empty
|
||||
if strings.TrimSpace(email) == "" {
|
||||
return errors.New("email address is required for transcript")
|
||||
var account model.Account
|
||||
if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
if !account.EmailTranscriptEnabled() {
|
||||
return ErrEmailTranscriptDisabled
|
||||
}
|
||||
if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit {
|
||||
return ErrEmailRateLimited
|
||||
}
|
||||
|
||||
subject, body, err := s.buildTranscriptEmail(ctx, accountID, conversation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.transcriptMailer != nil {
|
||||
_, err = s.transcriptMailer.DeliverTranscript(ctx, automation.AutomationTranscriptRequest{
|
||||
AccountID: accountID,
|
||||
ConversationID: conversation.ID,
|
||||
Recipient: recipient,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := account.IncrementEmailSentCount(now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
applogger.L().Infof("Transcript request: conversation=%d, account=%d, email=%s, contact=%d",
|
||||
conversation.ID, conversation.AccountID, recipient, conversation.ContactID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ConversationService) buildTranscriptEmail(ctx context.Context, accountID uint, conversation *model.Conversation) (string, string, error) {
|
||||
var messages []model.Message
|
||||
if err := s.repo.DB().WithContext(ctx).
|
||||
Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversation.ID, accountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}).
|
||||
Order("id ASC").
|
||||
Find(&messages).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
displayID := conversation.ID
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID > 0 {
|
||||
displayID = *conversation.DisplayID
|
||||
}
|
||||
subject := fmt.Sprintf("[#%d] Conversation Transcript", displayID)
|
||||
var body strings.Builder
|
||||
body.WriteString(fmt.Sprintf("Conversation #%d transcript\n\n", displayID))
|
||||
for _, message := range messages {
|
||||
if strings.TrimSpace(message.Content) == "" {
|
||||
continue
|
||||
}
|
||||
body.WriteString(fmt.Sprintf("[%s] %s\n", message.MessageType, message.Content))
|
||||
}
|
||||
return subject, body.String(), nil
|
||||
}
|
||||
|
||||
// UpdateCustomAttributes updates the custom attributes of a conversation.
|
||||
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#custom_attributes
|
||||
func (s *ConversationService) UpdateCustomAttributes(ctx context.Context, accountID, id uint, attrs datatypes.JSON) (*model.Conversation, error) {
|
||||
@@ -1503,6 +1566,8 @@ type UnreadCountsPayload struct {
|
||||
}
|
||||
|
||||
var ErrConversationUnreadCountsFeatureNotEnabled = errors.New("Conversation unread counts feature not enabled for this account")
|
||||
var ErrEmailTranscriptDisabled = errors.New("Email transcript is not available on your plan")
|
||||
var ErrEmailRateLimited = errors.New("email transcript rate limit exceeded")
|
||||
|
||||
// GetUnreadCounts returns unread conversation counts grouped by inbox, label, and team.
|
||||
// Reference: Chatwoot app/services/conversations/unread_counts/counter.rb
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/automation"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
@@ -120,6 +121,24 @@ func (l *captureConversationEventsListener) OnEvent(ctx context.Context, event *
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingConversationTranscriptDeliverer struct {
|
||||
requests []automation.AutomationTranscriptRequest
|
||||
result automation.ActionDeliveryResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *recordingConversationTranscriptDeliverer) DeliverTranscript(ctx context.Context, req automation.AutomationTranscriptRequest) (automation.ActionDeliveryResult, error) {
|
||||
d.requests = append(d.requests, req)
|
||||
result := d.result
|
||||
if result.DeliveryType == "" {
|
||||
result.DeliveryType = "email_transcript"
|
||||
}
|
||||
if result.Target == "" {
|
||||
result.Target = req.Recipient
|
||||
}
|
||||
return result, d.err
|
||||
}
|
||||
|
||||
func createConversationServiceTestSlaPolicy(t *testing.T, db *gorm.DB, accountID uint) *model.SlaPolicy {
|
||||
t.Helper()
|
||||
policy := &model.SlaPolicy{
|
||||
@@ -659,15 +678,35 @@ func TestConversationService_UpdateLastSeen_ThrottlesRecentNoUnreadTimestamps(t
|
||||
|
||||
func TestConversationService_SendTranscript(t *testing.T) {
|
||||
svc, db := setupConversationService(t)
|
||||
deliverer := &recordingConversationTranscriptDeliverer{}
|
||||
svc.SetTranscriptDeliverer(deliverer)
|
||||
|
||||
account := createConversationServiceTestAccount(t, db)
|
||||
inbox := createConversationServiceTestInbox(t, db, account.ID)
|
||||
contact := createConversationServiceTestContact(t, db, account.ID)
|
||||
|
||||
conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
|
||||
require.NoError(t, db.Model(conv).Update("display_id", 42).Error)
|
||||
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "visible incoming", ContentType: "text", MessageType: string(model.MessageTypeIncoming)}).Error)
|
||||
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "visible outgoing", ContentType: "text", MessageType: string(model.MessageTypeOutgoing)}).Error)
|
||||
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden private", ContentType: "text", MessageType: string(model.MessageTypeIncoming), Private: true}).Error)
|
||||
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden activity", ContentType: "text", MessageType: string(model.MessageTypeActivity)}).Error)
|
||||
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden template", ContentType: "text", MessageType: string(model.MessageTypeTemplate)}).Error)
|
||||
|
||||
err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com")
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, deliverer.requests, 1)
|
||||
assert.Equal(t, "test@example.com", deliverer.requests[0].Recipient)
|
||||
assert.Equal(t, "[#42] Conversation Transcript", deliverer.requests[0].Subject)
|
||||
assert.Contains(t, deliverer.requests[0].Body, "visible incoming")
|
||||
assert.Contains(t, deliverer.requests[0].Body, "visible outgoing")
|
||||
assert.NotContains(t, deliverer.requests[0].Body, "hidden private")
|
||||
assert.NotContains(t, deliverer.requests[0].Body, "hidden activity")
|
||||
assert.NotContains(t, deliverer.requests[0].Body, "hidden template")
|
||||
|
||||
var updated model.Account
|
||||
require.NoError(t, db.First(&updated, account.ID).Error)
|
||||
assert.Equal(t, 1, updated.EmailsSentToday(time.Now()))
|
||||
}
|
||||
|
||||
func TestConversationService_SendTranscript_EmptyEmail(t *testing.T) {
|
||||
@@ -705,6 +744,41 @@ func TestConversationService_SendTranscript_ConversationNotFound(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConversationService_SendTranscript_Disabled(t *testing.T) {
|
||||
svc, db := setupConversationService(t)
|
||||
deliverer := &recordingConversationTranscriptDeliverer{}
|
||||
svc.SetTranscriptDeliverer(deliverer)
|
||||
|
||||
account := createConversationServiceTestAccount(t, db)
|
||||
require.NoError(t, db.Model(account).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error)
|
||||
inbox := createConversationServiceTestInbox(t, db, account.ID)
|
||||
contact := createConversationServiceTestContact(t, db, account.ID)
|
||||
conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
|
||||
|
||||
err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com")
|
||||
assert.ErrorIs(t, err, ErrEmailTranscriptDisabled)
|
||||
assert.Empty(t, deliverer.requests)
|
||||
}
|
||||
|
||||
func TestConversationService_SendTranscript_RateLimited(t *testing.T) {
|
||||
svc, db := setupConversationService(t)
|
||||
deliverer := &recordingConversationTranscriptDeliverer{}
|
||||
svc.SetTranscriptDeliverer(deliverer)
|
||||
|
||||
account := createConversationServiceTestAccount(t, db)
|
||||
require.NoError(t, db.Model(account).Updates(map[string]any{
|
||||
"limits": datatypes.JSON(`{"emails":1}`),
|
||||
"custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`),
|
||||
}).Error)
|
||||
inbox := createConversationServiceTestInbox(t, db, account.ID)
|
||||
contact := createConversationServiceTestContact(t, db, account.ID)
|
||||
conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
|
||||
|
||||
err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com")
|
||||
assert.ErrorIs(t, err, ErrEmailRateLimited)
|
||||
assert.Empty(t, deliverer.requests)
|
||||
}
|
||||
|
||||
// ========== UpdateCustomAttributes Tests ==========
|
||||
|
||||
func TestConversationService_UpdateCustomAttributes(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user