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) }