227 lines
7.9 KiB
Go
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])
|
|
}
|