222 lines
5.7 KiB
Go
222 lines
5.7 KiB
Go
package support
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
"unicode/utf8"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
"github.com/jackc/pgx/v5/pgconn"
|
||
|
|
)
|
||
|
|
|
||
|
|
// AutoReplyTimeout is the hard deadline for one LLM auto-reply attempt.
|
||
|
|
const AutoReplyTimeout = 25 * time.Second
|
||
|
|
|
||
|
|
// AutoClaim is a successful idempotency claim for posting one auto reply.
|
||
|
|
type AutoClaim struct {
|
||
|
|
TicketID uuid.UUID
|
||
|
|
CompanyID uuid.UUID
|
||
|
|
}
|
||
|
|
|
||
|
|
// ClaimAutoReplyAttempt marks the ticket as in-progress for auto-reply if no public
|
||
|
|
// auto message exists yet and auto is not disabled. Concurrent claims fail with
|
||
|
|
// ErrAutoReplyAlreadyPosted.
|
||
|
|
func (s *Service) ClaimAutoReplyAttempt(ctx context.Context, ticketID uuid.UUID) (AutoClaim, error) {
|
||
|
|
if s == nil || s.Pool == nil {
|
||
|
|
return AutoClaim{}, ErrAIAutoReplyDisabled
|
||
|
|
}
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return AutoClaim{}, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
|
|
||
|
|
var (
|
||
|
|
companyID uuid.UUID
|
||
|
|
disabled bool
|
||
|
|
status string
|
||
|
|
msgID *uuid.UUID
|
||
|
|
tStatus string
|
||
|
|
)
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
SELECT company_id,
|
||
|
|
COALESCE(auto_reply_disabled, false),
|
||
|
|
COALESCE(auto_reply_status, 'none'),
|
||
|
|
auto_reply_message_id,
|
||
|
|
status
|
||
|
|
FROM support_tickets
|
||
|
|
WHERE id = $1
|
||
|
|
FOR UPDATE`, ticketID,
|
||
|
|
).Scan(&companyID, &disabled, &status, &msgID, &tStatus)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return AutoClaim{}, ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return AutoClaim{}, err
|
||
|
|
}
|
||
|
|
if disabled {
|
||
|
|
return AutoClaim{}, ErrAutoReplyDisabled
|
||
|
|
}
|
||
|
|
if tStatus == "closed" || tStatus == "resolved" {
|
||
|
|
return AutoClaim{}, ErrTicketClosed
|
||
|
|
}
|
||
|
|
if msgID != nil || status == "matched" || status == "ai_sent" || status == "ai_draft" {
|
||
|
|
return AutoClaim{}, ErrAutoReplyAlreadyPosted
|
||
|
|
}
|
||
|
|
|
||
|
|
var existing uuid.UUID
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
SELECT id FROM support_messages
|
||
|
|
WHERE ticket_id = $1 AND company_id = $2
|
||
|
|
AND is_auto_reply = true AND is_internal_note = false
|
||
|
|
LIMIT 1`, ticketID, companyID,
|
||
|
|
).Scan(&existing)
|
||
|
|
if err == nil {
|
||
|
|
return AutoClaim{}, ErrAutoReplyAlreadyPosted
|
||
|
|
}
|
||
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return AutoClaim{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Now().UTC()
|
||
|
|
tag, err := tx.Exec(ctx, `
|
||
|
|
UPDATE support_tickets
|
||
|
|
SET auto_reply_attempted_at = $2, updated_at = $2
|
||
|
|
WHERE id = $1 AND company_id = $3
|
||
|
|
AND auto_reply_message_id IS NULL
|
||
|
|
AND COALESCE(auto_reply_disabled, false) = false`,
|
||
|
|
ticketID, now, companyID,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return AutoClaim{}, err
|
||
|
|
}
|
||
|
|
if tag.RowsAffected() == 0 {
|
||
|
|
return AutoClaim{}, ErrAutoReplyAlreadyPosted
|
||
|
|
}
|
||
|
|
if err := tx.Commit(ctx); err != nil {
|
||
|
|
return AutoClaim{}, err
|
||
|
|
}
|
||
|
|
return AutoClaim{TicketID: ticketID, CompanyID: companyID}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// InsertAutoSystemMessage posts a labeled system auto-reply scoped to claim.CompanyID.
|
||
|
|
// Unique partial index prevents double public auto posts under races.
|
||
|
|
func (s *Service) InsertAutoSystemMessage(
|
||
|
|
ctx context.Context,
|
||
|
|
claim AutoClaim,
|
||
|
|
body, source string,
|
||
|
|
confidence float64,
|
||
|
|
refType string,
|
||
|
|
refID *uuid.UUID,
|
||
|
|
internal bool,
|
||
|
|
) (uuid.UUID, error) {
|
||
|
|
if s == nil || s.Pool == nil {
|
||
|
|
return uuid.Nil, ErrAIAutoReplyDisabled
|
||
|
|
}
|
||
|
|
body = sanitizeAutoBody(body)
|
||
|
|
if body == "" {
|
||
|
|
return uuid.Nil, ErrBodyRequired
|
||
|
|
}
|
||
|
|
if source != "kb" && source != "template" && source != "ai" {
|
||
|
|
source = "ai"
|
||
|
|
}
|
||
|
|
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return uuid.Nil, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
|
|
||
|
|
var companyID uuid.UUID
|
||
|
|
var disabled bool
|
||
|
|
var msgID *uuid.UUID
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
SELECT company_id, COALESCE(auto_reply_disabled, false), auto_reply_message_id
|
||
|
|
FROM support_tickets WHERE id = $1 FOR UPDATE`, claim.TicketID,
|
||
|
|
).Scan(&companyID, &disabled, &msgID)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return uuid.Nil, ErrNotFound
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return uuid.Nil, err
|
||
|
|
}
|
||
|
|
if companyID != claim.CompanyID {
|
||
|
|
return uuid.Nil, ErrForbidden
|
||
|
|
}
|
||
|
|
if disabled {
|
||
|
|
return uuid.Nil, ErrAutoReplyDisabled
|
||
|
|
}
|
||
|
|
if msgID != nil && !internal {
|
||
|
|
return uuid.Nil, ErrAutoReplyAlreadyPosted
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Now().UTC()
|
||
|
|
var refTypeArg any
|
||
|
|
if strings.TrimSpace(refType) == "" {
|
||
|
|
refTypeArg = nil
|
||
|
|
} else {
|
||
|
|
refTypeArg = strings.TrimSpace(refType)
|
||
|
|
}
|
||
|
|
|
||
|
|
var newID uuid.UUID
|
||
|
|
err = tx.QueryRow(ctx, `
|
||
|
|
INSERT INTO support_messages (
|
||
|
|
ticket_id, company_id, author_user_id, author_role, body, is_internal_note,
|
||
|
|
created_at, is_auto_reply, auto_source, auto_confidence, auto_ref_type, auto_ref_id
|
||
|
|
) VALUES ($1,$2,NULL,'system',$3,$4,$5,true,$6,$7,$8,$9)
|
||
|
|
RETURNING id`,
|
||
|
|
claim.TicketID, claim.CompanyID, body, internal, now, source, confidence, refTypeArg, refID,
|
||
|
|
).Scan(&newID)
|
||
|
|
if err != nil {
|
||
|
|
var pgErr *pgconn.PgError
|
||
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||
|
|
return uuid.Nil, ErrAutoReplyAlreadyPosted
|
||
|
|
}
|
||
|
|
return uuid.Nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
status := "ai_draft"
|
||
|
|
if !internal {
|
||
|
|
status = "ai_sent"
|
||
|
|
if source == "kb" || source == "template" {
|
||
|
|
status = "matched"
|
||
|
|
}
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
UPDATE support_tickets
|
||
|
|
SET auto_reply_status = $2,
|
||
|
|
auto_reply_message_id = $3,
|
||
|
|
status = CASE WHEN status = 'open' THEN 'pending' ELSE status END,
|
||
|
|
last_message_at = $4,
|
||
|
|
last_agent_message_at = $4,
|
||
|
|
updated_at = $4
|
||
|
|
WHERE id = $1 AND company_id = $5`,
|
||
|
|
claim.TicketID, status, newID, now, claim.CompanyID,
|
||
|
|
)
|
||
|
|
} else {
|
||
|
|
_, err = tx.Exec(ctx, `
|
||
|
|
UPDATE support_tickets
|
||
|
|
SET auto_reply_status = $2, updated_at = $3
|
||
|
|
WHERE id = $1 AND company_id = $4`,
|
||
|
|
claim.TicketID, status, now, claim.CompanyID,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return uuid.Nil, err
|
||
|
|
}
|
||
|
|
if err := tx.Commit(ctx); err != nil {
|
||
|
|
return uuid.Nil, err
|
||
|
|
}
|
||
|
|
return newID, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func sanitizeAutoBody(body string) string {
|
||
|
|
body = strings.TrimSpace(strings.ReplaceAll(body, "\x00", ""))
|
||
|
|
if utf8.RuneCountInString(body) <= maxBodyLen {
|
||
|
|
return body
|
||
|
|
}
|
||
|
|
return string([]rune(body)[:maxBodyLen])
|
||
|
|
}
|