package main import ( "context" "crypto/sha256" "encoding/json" "errors" "fmt" "os" "path/filepath" "time" "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/contract" amqp "github.com/rabbitmq/amqp091-go" ) const resultQueue = "agent-call.saas.events.v1" type oneShotResult struct { Outcome string RecordingStatus string Transcripts int BodySHA256 string } func expectedResultEventID(dispatcherID, sourceID string) string { identity := sha256.Sum256([]byte("call.execute.result\x00" + dispatcherID + "\x00" + sourceID)) return fmt.Sprintf("result-%x", identity[:]) } func verifyOneShotReceipt(body []byte, dispatcherID string, tenantID int64, sourceID string) (string, error) { if err := contract.ValidateCurrent("mq", body); err != nil { return "", errors.New("call dispatch receipt violates the current MQ contract") } var event struct { EventID string `json:"event_id"` EventType string `json:"event_type"` DispatcherID string `json:"dispatcher_id"` TenantID int64 `json:"tenant_id"` Payload struct { Status string `json:"status"` } `json:"payload"` } if err := json.Unmarshal(body, &event); err != nil || event.EventID != sourceID || event.EventType != "call.execute" || event.DispatcherID != dispatcherID || event.TenantID != tenantID || (event.Payload.Status != "dispatched" && event.Payload.Status != "rejected") { return "", errors.New("dispatch receipt does not belong to the selected call") } return event.Payload.Status, nil } func summarizeOneShotResult(body []byte, dispatcherID, sourceID, taskID, callee, trunkID string) (oneShotResult, error) { if err := contract.ValidateCurrent("mq", body); err != nil { return oneShotResult{}, errors.New("final MQ result violates current contract") } var event struct { EventID string `json:"event_id"` EventType string `json:"event_type"` DispatcherID string `json:"dispatcher_id"` Payload struct { TaskID string `json:"task_id"` Callee string `json:"callee"` TrunkID string `json:"trunk_id"` Outcome string `json:"outcome"` Transcript []json.RawMessage `json:"transcript"` Recording struct { Status string `json:"status"` } `json:"recording"` } `json:"payload"` } if err := json.Unmarshal(body, &event); err != nil || event.EventType != "call.execute.result" || event.EventID != expectedResultEventID(dispatcherID, sourceID) || event.DispatcherID != dispatcherID || event.Payload.TaskID != taskID || event.Payload.Callee != callee || event.Payload.TrunkID != trunkID { return oneShotResult{}, errors.New("final MQ result does not belong to the selected one-shot call") } sha := sha256.Sum256(body) return oneShotResult{Outcome: event.Payload.Outcome, RecordingStatus: event.Payload.Recording.Status, Transcripts: len(event.Payload.Transcript), BodySHA256: fmt.Sprintf("%x", sha[:])}, nil } func checkResultPath(path string) error { if !filepath.IsAbs(path) { return errors.New("final result evidence path must be absolute") } info, err := os.Stat(filepath.Dir(path)) if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { return errors.New("final result evidence directory must be private mode 0700") } if _, err := os.Lstat(path); err == nil || !errors.Is(err, os.ErrNotExist) { return errors.New("final result evidence already exists or is inaccessible") } return nil } func persistOneShotResult(path string, body []byte) error { if err := checkResultPath(path); err != nil { return err } parent := filepath.Dir(path) file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { return errors.New("final result evidence already exists or cannot be created") } if _, err := file.Write(body); err != nil { file.Close() return err } if err := file.Sync(); err != nil { file.Close() return err } if err := file.Close(); err != nil { return err } dir, err := os.Open(parent) if err != nil { return err } defer dir.Close() return dir.Sync() } type resultConsumer struct { connection *amqp.Connection channel *amqp.Channel deliveries <-chan amqp.Delivery } func openResultConsumer(brokerURL string) (_ *resultConsumer, err error) { if err := requireDedicatedVhost(brokerURL); err != nil { return nil, err } conn, err := amqp.Dial(brokerURL) if err != nil { return nil, fmt.Errorf("connect SaaS result queue: %T", err) } defer func() { if err != nil { conn.Close() } }() ch, err := conn.Channel() if err != nil { return nil, fmt.Errorf("open SaaS result channel: %T", err) } queue, err := ch.QueueDeclarePassive(resultQueue, true, false, false, false, nil) if err != nil { ch.Close() return nil, errors.New("preprovisioned SaaS result queue unavailable") } if queue.Messages != 0 || queue.Consumers != 0 { ch.Close() return nil, errors.New("SaaS result queue is not empty or has another consumer") } deliveries, err := ch.Consume(resultQueue, "", false, false, false, false, nil) if err != nil { ch.Close() return nil, errors.New("SaaS result consumer unavailable") } return &resultConsumer{connection: conn, channel: ch, deliveries: deliveries}, nil } func (c *resultConsumer) Close() { if c != nil { c.channel.Close() c.connection.Close() } } func (c *resultConsumer) wait(ctx context.Context, path, dispatcherID string, tenantID int64, sourceID, taskID, callee, trunkID string) (oneShotResult, error) { for { select { case <-ctx.Done(): return oneShotResult{}, errors.New("approved call final result not received before timeout; outcome unknown") case message, ok := <-c.deliveries: if !ok { return oneShotResult{}, errors.New("SaaS result subscription lost; outcome unknown") } summary, err := summarizeOneShotResult(message.Body, dispatcherID, sourceID, taskID, callee, trunkID) if err != nil { status, receiptErr := verifyOneShotReceipt(message.Body, dispatcherID, tenantID, sourceID) if receiptErr != nil { return oneShotResult{}, errors.Join(err, message.Nack(false, true)) } receiptPath := path + ".receipt.json" if status == "rejected" { receiptPath = path + ".rejected.json" } if err := persistOneShotResult(receiptPath, message.Body); err != nil { return oneShotResult{}, errors.Join(err, message.Nack(false, true)) } if err := message.Ack(false); err != nil { return oneShotResult{}, errors.New("dispatch receipt persisted but SaaS MQ acknowledgment unknown") } if status == "rejected" { return oneShotResult{}, errors.New("Agent explicitly rejected this call before dialing; no final call result") } continue // issuance receipt is not the unique physical call result } if err := persistOneShotResult(path, message.Body); err != nil { return oneShotResult{}, errors.Join(err, message.Nack(false, true)) } if err := message.Ack(false); err != nil { return oneShotResult{}, errors.New("final result persisted but SaaS MQ acknowledgment unknown") } return summary, nil } } } func publishAndAwait(ctx context.Context, brokerURL string, data dataset, eventID, taskID, callee, resultPath string, at time.Time) (oneShotResult, error) { if err := checkResultPath(resultPath); err != nil { return oneShotResult{}, err } var task configread.Task if err := json.Unmarshal(data.tasks[taskID], &task); err != nil || len(task.AllowedTrunks) != 1 { return oneShotResult{}, errors.New("one-shot result requires a single approved trunk") } consumer, err := openResultConsumer(brokerURL) if err != nil { return oneShotResult{}, err } defer consumer.Close() publishCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if err := publishExecuteAt(publishCtx, brokerURL, data, eventID, taskID, callee, at); err != nil { return oneShotResult{}, err } return consumer.wait(ctx, resultPath, data.dispatcherID, data.tenantID, eventID, taskID, callee, task.AllowedTrunks[0].TrunkID) }