286 lines
11 KiB
Go
286 lines
11 KiB
Go
package callmemory
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"data-terminal-cloud/internal/audit"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"errors"
|
||
|
|
"net/url"
|
||
|
|
"regexp"
|
||
|
|
"sort"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
"github.com/volcengine/ve-tos-golang-sdk/v2/tos"
|
||
|
|
"github.com/volcengine/ve-tos-golang-sdk/v2/tos/enum"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Only metadata and locally signed URLs pass through this service. There is
|
||
|
|
// deliberately no GetObject operation or audio proxy in this interface.
|
||
|
|
type RecordingStore interface {
|
||
|
|
List(context.Context, string, string) ([]CloudRecording, error)
|
||
|
|
Sign(context.Context, string, string, int64) (string, error)
|
||
|
|
PlaybackReady() bool
|
||
|
|
}
|
||
|
|
type CloudRecording struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Speaker string `json:"speaker"`
|
||
|
|
Duration int64 `json:"duration_seconds"`
|
||
|
|
Bytes int64 `json:"bytes"`
|
||
|
|
Key string `json:"-"`
|
||
|
|
}
|
||
|
|
type TOSRecordings struct {
|
||
|
|
client *tos.ClientV2
|
||
|
|
bucket string
|
||
|
|
playbackEndpoint string
|
||
|
|
}
|
||
|
|
|
||
|
|
var recordingDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||
|
|
var recordingSuffix = regexp.MustCompile(`^[0-9]{13}_([0-9]{6})_[A-Za-z0-9]+\.(?:mp4|mp3)$`)
|
||
|
|
|
||
|
|
func NewTOSRecordings(bucket, region, ak, sk, token string) (*TOSRecordings, error) {
|
||
|
|
if !regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$`).MatchString(bucket) ||
|
||
|
|
!regexp.MustCompile(`^[a-z]{2}-[a-z]+[0-9]*$`).MatchString(region) || ak == "" || sk == "" {
|
||
|
|
return nil, errors.New("invalid TOS recording configuration")
|
||
|
|
}
|
||
|
|
credentials := tos.NewStaticCredentials(ak, sk)
|
||
|
|
if token != "" {
|
||
|
|
credentials.WithSecurityToken(token)
|
||
|
|
}
|
||
|
|
client, err := tos.NewClientV2("https://tos-"+region+".volces.com", tos.WithRegion(region), tos.WithCredentials(credentials), tos.WithMaxRetryCount(0))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &TOSRecordings{client: client, bucket: bucket}, nil
|
||
|
|
}
|
||
|
|
func (s *Service) ConfigureRecordings(store RecordingStore) { s.recordings = store }
|
||
|
|
|
||
|
|
// TOS default domains force attachment responses. Playback requires a bound
|
||
|
|
// HTTPS custom domain; signing still uses the same private bucket credentials.
|
||
|
|
func (s *TOSRecordings) ConfigurePlayback(endpoint string) error {
|
||
|
|
if endpoint == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
u, e := url.Parse(endpoint)
|
||
|
|
if e != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Port() != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
|
||
|
|
return errors.New("invalid recording playback domain")
|
||
|
|
}
|
||
|
|
s.playbackEndpoint = "https://" + u.Host
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
func (s *TOSRecordings) PlaybackReady() bool { return s.playbackEndpoint != "" }
|
||
|
|
|
||
|
|
func recordingPrefixes(device, bot string) ([]string, error) {
|
||
|
|
if !recordingDevice.MatchString(device) || !isRTCAgentUser(bot) {
|
||
|
|
return nil, errors.New("unverified recording identity")
|
||
|
|
}
|
||
|
|
// Hardware gateway identity, corroborated by the authenticated subtitle UID.
|
||
|
|
room := "aibotrtc_OPUS_" + device + "_" + strings.TrimPrefix(bot, "bot_")
|
||
|
|
return []string{device + "_" + room + "_" + device + "_C_", bot + "_" + room + "_" + bot + "_C_"}, nil
|
||
|
|
}
|
||
|
|
func parseRecording(key, prefix, speaker string, size int64) (CloudRecording, bool) {
|
||
|
|
if !strings.HasPrefix(key, prefix) {
|
||
|
|
return CloudRecording{}, false
|
||
|
|
}
|
||
|
|
parts := recordingSuffix.FindStringSubmatch(strings.TrimPrefix(key, prefix))
|
||
|
|
if len(parts) != 2 || size <= 0 {
|
||
|
|
return CloudRecording{}, false
|
||
|
|
}
|
||
|
|
duration, _ := strconv.ParseInt(parts[1], 10, 64)
|
||
|
|
return CloudRecording{ID: hash([]byte(key)), Key: key, Speaker: speaker, Duration: duration, Bytes: size}, true
|
||
|
|
}
|
||
|
|
func (s *TOSRecordings) List(ctx context.Context, device, bot string) ([]CloudRecording, error) {
|
||
|
|
prefixes, err := recordingPrefixes(device, bot)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items := []CloudRecording{}
|
||
|
|
room := "aibotrtc_OPUS_" + device + "_" + strings.TrimPrefix(bot, "bot_")
|
||
|
|
prefixes = append([]string{mixedPrefix(room)}, prefixes...)
|
||
|
|
for i, prefix := range prefixes {
|
||
|
|
cursor := ""
|
||
|
|
for page := 0; ; page++ {
|
||
|
|
if page >= 20 {
|
||
|
|
return nil, errors.New("recording listing limit exceeded")
|
||
|
|
}
|
||
|
|
out, e := s.client.ListObjectsType2(ctx, &tos.ListObjectsType2Input{Bucket: s.bucket, Prefix: prefix, MaxKeys: 100, ContinuationToken: cursor, ListOnlyOnce: true})
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
for _, object := range out.Contents {
|
||
|
|
if item, ok := parseRecording(object.Key, prefix, []string{"DIALOG", "USER", "ASSISTANT"}[i], object.Size); ok {
|
||
|
|
items = append(items, item)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !out.IsTruncated {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if out.NextContinuationToken == "" || out.NextContinuationToken == cursor {
|
||
|
|
return nil, errors.New("invalid recording pagination")
|
||
|
|
}
|
||
|
|
cursor = out.NextContinuationToken
|
||
|
|
}
|
||
|
|
if i == 0 && len(items) > 0 {
|
||
|
|
break
|
||
|
|
} // Prefer the complete conversation over separate tracks.
|
||
|
|
}
|
||
|
|
sort.Slice(items, func(i, j int) bool { return items[i].Key < items[j].Key })
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
func (s *TOSRecordings) Sign(ctx context.Context, key, action string, seconds int64) (string, error) {
|
||
|
|
if action != "PLAY" && action != "DOWNLOAD" {
|
||
|
|
return "", errors.New("invalid recording action")
|
||
|
|
}
|
||
|
|
disposition := "inline"
|
||
|
|
if action == "DOWNLOAD" {
|
||
|
|
disposition = "attachment"
|
||
|
|
}
|
||
|
|
mimeType, filename := "audio/mp4", "conversation.mp4"
|
||
|
|
if strings.HasSuffix(key, ".mp3") {
|
||
|
|
mimeType = "audio/mpeg"
|
||
|
|
filename = "conversation.mp3"
|
||
|
|
}
|
||
|
|
in := &tos.PreSignedURLInput{HTTPMethod: enum.HttpMethodGet, Bucket: s.bucket, Key: key, Expires: seconds,
|
||
|
|
Query: map[string]string{"response-content-type": mimeType, "response-content-disposition": disposition + `; filename="` + filename + `"`}}
|
||
|
|
if action == "PLAY" {
|
||
|
|
if !s.PlaybackReady() {
|
||
|
|
return "", errors.New("recording playback domain required")
|
||
|
|
}
|
||
|
|
custom := true
|
||
|
|
in.IsCustomDomain = &custom
|
||
|
|
in.AlternativeEndpoint = s.playbackEndpoint
|
||
|
|
}
|
||
|
|
out, e := s.client.PreSignedURL(in)
|
||
|
|
if e != nil {
|
||
|
|
return "", e
|
||
|
|
}
|
||
|
|
return out.SignedUrl, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type recordingInput struct {
|
||
|
|
ElderID string `json:"elder_id"`
|
||
|
|
SessionID string `json:"business_session_id"`
|
||
|
|
RecordingID string `json:"recording_id,omitempty"`
|
||
|
|
Action string `json:"action,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) callRecordings(ctx context.Context, tx pgx.Tx, p identity.Principal, op string, raw []byte) (any, error) {
|
||
|
|
var in recordingInput
|
||
|
|
if e := StrictJSON(raw, &in); e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
if !validUUID(in.SessionID) {
|
||
|
|
return nil, fault(400, "INVALID_CALL_ID")
|
||
|
|
}
|
||
|
|
if op == "calls/recording-access" && (len(in.RecordingID) != 64 || !oneOf(in.Action, "PLAY", "DOWNLOAD")) {
|
||
|
|
return nil, fault(400, "INVALID_RECORDING_REQUEST")
|
||
|
|
}
|
||
|
|
a, e := ResolveAccess(ctx, tx, p, in.ElderID, false)
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
if !a.Sensitive {
|
||
|
|
return nil, fault(403, "RECORDING_ACCESS_DENIED")
|
||
|
|
}
|
||
|
|
var device, bot, publicUser string
|
||
|
|
var consent, play, download, mixed bool
|
||
|
|
var playUntil, downloadUntil *time.Time
|
||
|
|
e = tx.QueryRow(ctx, `SELECT COALESCE(c.budget_snapshot->>'device_name',''),COALESCE(c.budget_snapshot->>'rtc_agent_user_id',''),ua.public_id::text,COALESCE(c.budget_snapshot->>'mixed_recording','false')='true',
|
||
|
|
EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=c.tenant_id AND cr.subject_id=c.subject_id AND cr.consent_type='raw_audio_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))
|
||
|
|
AND NOT EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.subject_id=c.subject_id AND pr.request_type='DELETE'),
|
||
|
|
(SELECT max(expires_at) FROM recording_access_grant WHERE tenant_id=c.tenant_id AND subject_id=c.subject_id AND grantee_user_id=ua.id AND permission='PLAY' AND revoked_at IS NULL AND expires_at>now()),
|
||
|
|
(SELECT max(expires_at) FROM recording_access_grant WHERE tenant_id=c.tenant_id AND subject_id=c.subject_id AND grantee_user_id=ua.id AND permission='DOWNLOAD' AND revoked_at IS NULL AND expires_at>now())
|
||
|
|
FROM call_session c JOIN user_account ua ON ua.id=$4::uuid AND ua.tenant_id=c.tenant_id
|
||
|
|
WHERE c.id=$1::uuid AND c.subject_id=$2::uuid AND c.tenant_id=$3::uuid`, in.SessionID, a.Subject, a.Tenant, a.User).Scan(&device, &bot, &publicUser, &mixed, &consent, &playUntil, &downloadUntil)
|
||
|
|
if errors.Is(e, pgx.ErrNoRows) {
|
||
|
|
return nil, fault(404, "CALL_NOT_FOUND")
|
||
|
|
}
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
play = consent && playUntil != nil
|
||
|
|
download = consent && downloadUntil != nil
|
||
|
|
playbackReady := s.recordings != nil && s.recordings.PlaybackReady()
|
||
|
|
result := map[string]any{"items": []CloudRecording{}, "can_play": play, "can_download": download, "can_manage": a.Manage, "requester_id": publicUser, "consent": consent, "state": "READY", "playback_ready": playbackReady}
|
||
|
|
result["mixed"] = mixed
|
||
|
|
if op == "calls/recording-access" && in.Action == "PLAY" && !playbackReady {
|
||
|
|
return nil, fault(409, "RECORDING_PLAYBACK_NOT_CONFIGURED")
|
||
|
|
}
|
||
|
|
if op == "calls/recording-access" && ((!play && in.Action == "PLAY") || (!download && in.Action == "DOWNLOAD")) {
|
||
|
|
return nil, fault(403, "RECORDING_ACCESS_DENIED")
|
||
|
|
}
|
||
|
|
if !consent {
|
||
|
|
result["state"] = "CONSENT_REQUIRED"
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
if s.recordings == nil {
|
||
|
|
result["state"] = "NOT_CONFIGURED"
|
||
|
|
if op == "calls/recording-access" {
|
||
|
|
return nil, fault(503, "RECORDING_NOT_CONFIGURED")
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
if _, e = recordingPrefixes(device, bot); e != nil {
|
||
|
|
result["state"] = "IDENTITY_PENDING"
|
||
|
|
if op == "calls/recording-access" {
|
||
|
|
return nil, fault(404, "RECORDING_NOT_FOUND")
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
items, e := s.recordings.List(ctx, device, bot)
|
||
|
|
if e != nil {
|
||
|
|
return nil, fault(503, "RECORDING_STORAGE_UNAVAILABLE")
|
||
|
|
}
|
||
|
|
if mixed {
|
||
|
|
complete := []CloudRecording{}
|
||
|
|
for _, item := range items {
|
||
|
|
if item.Speaker == "DIALOG" {
|
||
|
|
complete = append(complete, item)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
items = complete
|
||
|
|
}
|
||
|
|
if op == "calls/recordings" {
|
||
|
|
result["items"] = items
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
for _, item := range items {
|
||
|
|
if item.ID == in.RecordingID {
|
||
|
|
until := playUntil
|
||
|
|
if in.Action == "DOWNLOAD" {
|
||
|
|
until = downloadUntil
|
||
|
|
}
|
||
|
|
seconds := int64(300)
|
||
|
|
if remaining := int64(until.Sub(s.now()).Seconds()); remaining < seconds {
|
||
|
|
seconds = remaining
|
||
|
|
}
|
||
|
|
// Never issue an address that outlives its consent or recording grant.
|
||
|
|
var consentUntil *time.Time
|
||
|
|
e = tx.QueryRow(ctx, `SELECT CASE WHEN bool_or(expires_at IS NULL) THEN NULL ELSE max(expires_at) END FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='raw_audio_storage' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now())`, a.Tenant, a.Subject).Scan(&consentUntil)
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
if consentUntil != nil {
|
||
|
|
if remaining := int64(consentUntil.Sub(s.now()).Seconds()); remaining < seconds {
|
||
|
|
seconds = remaining
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if seconds < 1 {
|
||
|
|
return nil, fault(403, "RECORDING_ACCESS_DENIED")
|
||
|
|
}
|
||
|
|
url, e := s.recordings.Sign(ctx, item.Key, in.Action, seconds)
|
||
|
|
if e != nil {
|
||
|
|
return nil, fault(503, "RECORDING_STORAGE_UNAVAILABLE")
|
||
|
|
}
|
||
|
|
e = audit.NewRepository().Record(ctx, tx, audit.Event{TenantID: &a.Tenant, ActorType: "user", ActorPublicID: &publicUser, Action: "call.recording." + strings.ToLower(in.Action), ResourceType: "call_session", ResourceID: &in.SessionID, Result: "success", Purpose: "customer conversation review", Detail: map[string]any{"recording_id": item.ID, "expires_seconds": seconds}})
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
return map[string]any{"access_url": url, "access_until": s.now().Add(time.Duration(seconds) * time.Second)}, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil, fault(404, "RECORDING_NOT_FOUND")
|
||
|
|
}
|