Files
go-sip/internal/configread/current.go
T

189 lines
6.5 KiB
Go

package configread
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"git.ipao.vip/rogee/go-sip/internal/contract"
)
// CurrentSnapshot carries the four independently validated configuration
// resources. Agent.Raw preserves explicit zero/false and immutable AI fields.
type CurrentSnapshot struct {
SIP CurrentSIP
Task CurrentTask
Quota CurrentQuota
Providers map[string]CurrentProvider
}
type CurrentSIP struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
Revision int64 `json:"revision"`
Trunks json.RawMessage `json:"trunks"`
}
type CurrentProvider struct {
ProviderRef string `json:"provider_ref"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint"`
Credential string `json:"credential"`
}
type currentProviderResponse struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
Providers []CurrentProvider `json:"providers"`
}
type CurrentAgent struct {
Mode string `json:"mode"`
ASR struct {
ProviderRef string `json:"provider_ref"`
} `json:"asr"`
LLM struct {
ProviderRef string `json:"provider_ref"`
} `json:"llm"`
TTS struct {
ProviderRef string `json:"provider_ref"`
} `json:"tts"`
Raw json.RawMessage `json:"-"`
}
func (a *CurrentAgent) UnmarshalJSON(raw []byte) error {
type plain CurrentAgent
var parsed plain
if err := json.Unmarshal(raw, &parsed); err != nil {
return err
}
*a = CurrentAgent(parsed)
a.Raw = append([]byte(nil), raw...)
return nil
}
type CurrentTask struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
TenantID int64 `json:"tenant_id"`
TaskID string `json:"task_id"`
TaskRevision int64 `json:"task_revision"`
Status string `json:"status"`
Name string `json:"name"`
MaxConcurrentCalls int64 `json:"max_concurrent_calls"`
RingTimeoutMS int64 `json:"ring_timeout_ms"`
MaxCallDurationMS int64 `json:"max_call_duration_ms"`
RoutePolicyID string `json:"route_policy_id"`
CallerProfileID string `json:"caller_profile_id"`
AllowedTrunkIDs []string `json:"allowed_trunk_ids"`
Schedule json.RawMessage `json:"schedule"`
Agent CurrentAgent `json:"agent"`
Raw json.RawMessage `json:"-"`
}
func (t *CurrentTask) UnmarshalJSON(raw []byte) error {
type plain CurrentTask
var parsed plain
if err := json.Unmarshal(raw, &parsed); err != nil {
return err
}
*t = CurrentTask(parsed)
t.Raw = append([]byte(nil), raw...)
return nil
}
type CurrentQuota struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
TenantID int64 `json:"tenant_id"`
QuotaRevision int64 `json:"quota_revision"`
MaxConcurrentCalls int64 `json:"max_concurrent_calls"`
}
// ReadCurrentSIP is always called at Dispatcher startup, even with no tasks.
// Revision is not a claim that Agent/Asterisk actually loaded this snapshot;
// the caller must verify applied state before opening admission.
func (c *Client) ReadCurrentSIP(ctx context.Context) (CurrentSIP, error) {
var sip CurrentSIP
if err := c.readCurrent(ctx, configReadPath+"/sip", "sip_config", &sip); err != nil {
return CurrentSIP{}, fmt.Errorf("read SIP configuration: %w", err)
}
if sip.DispatcherID != c.dispatcherID {
return CurrentSIP{}, errors.New("SIP configuration dispatcher owner mismatch")
}
return sip, nil
}
// ReadCurrentTask reads only the agreed read-only HTTP resources. It never
// substitutes stale data or the historical MQ configuration contract.
func (c *Client) ReadCurrentTask(ctx context.Context, taskID string, tenantID int64) (CurrentSnapshot, error) {
if taskID == "" || tenantID <= 0 {
return CurrentSnapshot{}, errors.New("task ID and positive tenant ID are required")
}
var result CurrentSnapshot
var err error
result.SIP, err = c.ReadCurrentSIP(ctx)
if err != nil {
return CurrentSnapshot{}, err
}
var providers currentProviderResponse
if err := c.readCurrent(ctx, configReadPath+"/ai-providers", "ai_providers", &providers); err != nil {
return CurrentSnapshot{}, fmt.Errorf("read AI providers: %w", err)
}
if err := c.readCurrent(ctx, configReadPath+"/task/"+url.PathEscape(taskID), "task_config", &result.Task); err != nil {
return CurrentSnapshot{}, fmt.Errorf("read task configuration: %w", err)
}
if err := c.readCurrent(ctx, configReadPath+"/tenant/"+strconv.FormatInt(tenantID, 10)+"/quota", "tenant_quota", &result.Quota); err != nil {
return CurrentSnapshot{}, fmt.Errorf("read tenant quota: %w", err)
}
if result.SIP.DispatcherID != c.dispatcherID || providers.DispatcherID != c.dispatcherID || result.Task.DispatcherID != c.dispatcherID || result.Quota.DispatcherID != c.dispatcherID || result.Task.TenantID != tenantID || result.Quota.TenantID != tenantID || result.Task.TaskID != taskID {
return CurrentSnapshot{}, errors.New("configuration dispatcher, task, or tenant owner mismatch")
}
result.Providers = make(map[string]CurrentProvider, len(providers.Providers))
for _, provider := range providers.Providers {
if _, exists := result.Providers[provider.ProviderRef]; exists {
return CurrentSnapshot{}, fmt.Errorf("duplicate AI provider reference %q", provider.ProviderRef)
}
result.Providers[provider.ProviderRef] = provider
}
for _, expected := range []struct{ ref, role string }{
{result.Task.Agent.ASR.ProviderRef, "asr"},
{result.Task.Agent.LLM.ProviderRef, "llm"},
{result.Task.Agent.TTS.ProviderRef, "tts"},
} {
if expected.ref == "" {
continue
}
provider, found := result.Providers[expected.ref]
if !found || !provider.Enabled || provider.Role != expected.role {
return CurrentSnapshot{}, fmt.Errorf("AI provider %q is missing, disabled, or has an incompatible role", expected.ref)
}
}
return result, nil
}
func (c *Client) readCurrent(ctx context.Context, path, resource string, dst any) error {
body, err := c.get(ctx, path)
if err != nil {
return err
}
if err := contract.ValidateCurrent("config-read", body); err != nil {
return err
}
var identity struct {
Resource string `json:"resource"`
}
if err := json.Unmarshal(body, &identity); err != nil {
return err
}
if identity.Resource != resource {
return fmt.Errorf("expected %s, got %s", resource, identity.Resource)
}
return json.Unmarshal(body, dst)
}