Files
go-sip/deploys/test/saas-mock/result_wait.go
T

227 lines
7.9 KiB
Go

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.AllowedTrunkIDs) != 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.AllowedTrunkIDs[0])
}