package config import ( "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/BurntSushi/toml" "code.m3ta.dev/m3tam3re/talk/internal/queue" ) // Allowed values for audio.sink (public contract). const ( SinkOto = "oto" SinkNull = "null" ) // DefaultServerPort is the daemon's default HTTP port on 127.0.0.1. const DefaultServerPort = 7800 // DefaultRateLimit is the anti-spam law's default: one announcement per // 30 seconds. "0s" disables the limit. const DefaultRateLimit = 30 * time.Second // DefaultMaxText is the default cap on announcement length in characters: // two to three sentences, roughly 25 seconds of speech. 0 = unlimited. const DefaultMaxText = 400 // DefaultMaxAge is the default longest wait in the queue; whatever parks // longer is stale and gets discarded. "0s" = unlimited. const DefaultMaxAge = 2 * time.Minute // DefaultLang is the language assumed when detection cannot decide (empty, // numbers, ties). Also the language of talk's own announcements such as the // wrap result when the label gives no hint. const DefaultLang = "en" // Config is the effective configuration after all layers were applied. type Config struct { Audio Audio `toml:"audio"` // Server configures the daemon's HTTP server on 127.0.0.1. Server Server `toml:"server"` // Queue holds the daemon's anti-spam laws. Queue Queue `toml:"queue"` // Daemon configures the background process (autostart). Daemon Daemon `toml:"daemon"` // Lang is the fallback language (de | en) when detection cannot decide, // and the language of talk's own announcements. Default "en"; env // TALK_LANG. Lang string `toml:"lang"` // Provider pins the chain to exactly one provider (for example // `provider = "elevenlabs"`); empty = the full default chain // (elevenlabs → openai → edge → osbuiltin). The --provider flag beats // this value. Valid names are checked by the provider layer. Provider string `toml:"provider"` // Providers holds per-provider settings (endpoint, model): // `[providers.elevenlabs] endpoint = "…"`. The key is plural so the pin // (`provider`) and the settings table can coexist in TOML. Providers map[string]ProviderSettings `toml:"providers"` // Voice is the voice mapping `voice.. = id`, for example // `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. It beats the built-in // neutral defaults; the --voice flag beats everything. Voice map[string]map[string]string `toml:"voice"` // Wrap configures `talk wrap`. Wrap Wrap `toml:"wrap"` // QuietHours is the quiet window "HH:MM-HH:MM" in local time (may span // midnight, for example "18:00-08:00"); empty = disabled. Deliberately // no default window: a law that discards by time of day would silence // every evening cron job; quiet hours are opt-in like mute. QuietHours string `toml:"quiet_hours"` // QuietMode is the behaviour during quiet hours: "discard" (default, // drop silently) or "toast" (desktop notification instead of audio). QuietMode string `toml:"quiet_mode"` } // Audio configures playback. type Audio struct { // Sink selects playback: "oto" (embedded playback, default) or "null" // (discard; CI, servers, tests). Sink string `toml:"sink"` } // Server holds the daemon's network settings (loopback only; the daemon // binds 127.0.0.1, the config only supplies the port). type Server struct { // Port of the daemon's HTTP server on 127.0.0.1: `[server] port = 7800`, // env override TALK_PORT, default 7800. Port int `toml:"port"` // AllowedOrigins is the CORS allow-list for POST /speak: exact origins // such as "https://chat.example.com". The entry "*" allows every origin // explicitly. Deliberate default: EMPTY, meaning no CORS headers at all // and the server-side origin guard rejects browser requests; the door // stays shut until configured (env TALK_ALLOWED_ORIGINS, comma-separated; // "off" clears it). AllowedOrigins []string `toml:"allowed_origins"` } // Daemon holds the background process settings (TOML `[daemon]`). type Daemon struct { // Autostart lets talk start the daemon itself in the background on the // first speak command (default true; env TALK_AUTOSTART). false is for // setups that manage the daemon themselves (systemd, launchd, Task // Scheduler): without a daemon, say then speaks directly, with a note. Autostart bool `toml:"autostart"` } // Wrap holds the `talk wrap` settings (TOML `[wrap]`). type Wrap struct { // Label replaces the command's base name in the announcement, for // example `label = "Deployment"` to hear "Deployment succeeded" instead // of "terraform succeeded" (env override TALK_WRAP_LABEL). Label string `toml:"label"` } // Queue holds the daemon's anti-spam laws (TOML `[queue]`). type Queue struct { // RateLimit is the minimum gap between two announcement starts, as a // duration string ("30s"; "0s" = unlimited). Default 30 s. RateLimit time.Duration `toml:"rate_limit"` // AllowHighBurst lets priority=high deliberately break the rate limit // (default true). Quiet hours still apply. AllowHighBurst bool `toml:"allow_high_burst"` // MaxText caps announcement length in characters; longer texts are cut // at a word boundary (default 400; 0 = unlimited; env TALK_MAX_TEXT). MaxText int `toml:"max_text"` // MaxAge is the longest an announcement may wait in the queue; older // ones are discarded when they come due (default "2m"; "0s" = // unlimited; env TALK_MAX_AGE). MaxAge time.Duration `toml:"max_age"` } // ProviderSettings are the per-provider endpoint and model (TOML // `[providers.]`). Empty = built-in defaults; the env overrides // TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT / TALK_EDGE_ENDPOINT // (read by the provider layer) beat the file. Keys come exclusively from // TALK_ELEVENLABS_KEY / TALK_OPENAI_KEY and are never written to the file. type ProviderSettings struct { Endpoint string `toml:"endpoint"` Model string `toml:"model"` // openai only (default "tts-1") } // Defaults returns the built-in default configuration (the lowest layer). func Defaults() Config { return Config{ Audio: Audio{Sink: SinkOto}, Server: Server{Port: DefaultServerPort}, Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge}, Daemon: Daemon{Autostart: true}, Lang: DefaultLang, QuietMode: queue.QuietModeDiscard, } } // Options controls Load. Every field is injectable so unit tests run without // a real HOME, environment or files. type Options struct { // File overrides the platform-native path of config.toml (tests inject // a temp file). Empty: Dir() + "config.toml". File string // Getenv supplies environment variables (default os.Getenv): the TALK_* // overrides and the path resolution (HOME, XDG_CONFIG_HOME, APPDATA). Getenv func(string) string } // Load resolves the configuration in layers: defaults → config.toml (if // present) → env overrides (TALK_* beats the file). Env and file validation // errors are reported clearly, never ignored. If the config directory // cannot be determined (no HOME), talk runs with defaults + env: the file is // optional, a broken environment must not make talk unusable. func Load(opts Options) (Config, error) { if opts.Getenv == nil { opts.Getenv = os.Getenv } cfg := Defaults() path := opts.File if path == "" { dir, err := Dir(opts.Getenv) if err != nil { return applyEnv(cfg, opts.Getenv) } path = filepath.Join(dir, fileName) } if _, err := toml.DecodeFile(path, &cfg); err != nil { if !errors.Is(err, os.ErrNotExist) { return cfg, fmt.Errorf("config %s: %w", path, err) } } return applyEnv(cfg, opts.Getenv) } // applyEnv applies the env overrides and validates the result. func applyEnv(cfg Config, getenv func(string) string) (Config, error) { if v := getenv("TALK_AUDIO_SINK"); v != "" { cfg.Audio.Sink = v } if v := getenv("TALK_PROVIDER"); v != "" { cfg.Provider = v } if v := getenv("TALK_LANG"); v != "" { cfg.Lang = v } if v := getenv("TALK_PORT"); v != "" { port, err := strconv.Atoi(v) if err != nil || port < 1 || port > 65535 { return cfg, fmt.Errorf("TALK_PORT: invalid value %q (1–65535)", v) } cfg.Server.Port = port } if v := getenv("TALK_ALLOWED_ORIGINS"); v != "" { if v == "off" { cfg.Server.AllowedOrigins = nil } else { origins, err := splitOrigins(v) if err != nil { return cfg, fmt.Errorf("TALK_ALLOWED_ORIGINS: %w", err) } cfg.Server.AllowedOrigins = origins } } if v := getenv("TALK_MAX_TEXT"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 0 { return cfg, fmt.Errorf("TALK_MAX_TEXT: invalid value %q (characters; 0 = unlimited)", v) } cfg.Queue.MaxText = n } if v := getenv("TALK_MAX_AGE"); v != "" { d, err := time.ParseDuration(v) if err != nil || d < 0 { return cfg, fmt.Errorf("TALK_MAX_AGE: invalid value %q (duration such as \"2m\"; \"0s\" = unlimited)", v) } cfg.Queue.MaxAge = d } if v := getenv("TALK_AUTOSTART"); v != "" { b, err := strconv.ParseBool(v) if err != nil { return cfg, fmt.Errorf("TALK_AUTOSTART: invalid value %q (true/false)", v) } cfg.Daemon.Autostart = b } if v := getenv("TALK_WRAP_LABEL"); v != "" { cfg.Wrap.Label = v } if v := getenv("TALK_RATE_LIMIT"); v != "" { d, err := time.ParseDuration(v) if err != nil || d < 0 { return cfg, fmt.Errorf("TALK_RATE_LIMIT: invalid value %q (duration such as \"30s\"; \"0s\" = unlimited)", v) } cfg.Queue.RateLimit = d } if v := getenv("TALK_ALLOW_HIGH_BURST"); v != "" { b, err := strconv.ParseBool(v) if err != nil { return cfg, fmt.Errorf("TALK_ALLOW_HIGH_BURST: invalid value %q (true/false)", v) } cfg.Queue.AllowHighBurst = b } if v := getenv("TALK_QUIET_HOURS"); v != "" { if v == "off" { cfg.QuietHours = "" } else { if _, err := queue.ParseQuietHours(v); err != nil { return cfg, fmt.Errorf("TALK_QUIET_HOURS: invalid window %q (HH:MM-HH:MM, \"off\" = disabled)", v) } cfg.QuietHours = v } } if v := getenv("TALK_QUIET_MODE"); v != "" { if v != queue.QuietModeDiscard && v != queue.QuietModeToast { return cfg, fmt.Errorf("TALK_QUIET_MODE: invalid value %q (allowed: %q, %q)", v, queue.QuietModeDiscard, queue.QuietModeToast) } cfg.QuietMode = v } if cfg.Queue.MaxText < 0 || cfg.Queue.MaxAge < 0 || cfg.Queue.RateLimit < 0 { return cfg, fmt.Errorf("queue: max_text, max_age and rate_limit must not be negative (0 = unlimited)") } switch cfg.Audio.Sink { case SinkOto, SinkNull: default: return cfg, fmt.Errorf("audio.sink: invalid value %q (allowed: %q, %q)", cfg.Audio.Sink, SinkOto, SinkNull) } if cfg.Lang != "de" && cfg.Lang != "en" { return cfg, fmt.Errorf("lang: invalid value %q (allowed: de, en)", cfg.Lang) } // Validate allowed_origins from the file (env was parsed above): an empty // list is fine (door shut), but entries must have substance. for _, o := range cfg.Server.AllowedOrigins { if strings.TrimSpace(o) == "" { return cfg, fmt.Errorf("server.allowed_origins: empty entry in %v", cfg.Server.AllowedOrigins) } } // Validate quiet_hours from the file (env was checked above) and lift an // empty quiet_mode to the default. if cfg.QuietHours != "" { if _, err := queue.ParseQuietHours(cfg.QuietHours); err != nil { return cfg, err } } if cfg.QuietMode == "" { cfg.QuietMode = queue.QuietModeDiscard } return cfg, nil } // splitOrigins splits a comma-separated origin list (the env form of // server.allowed_origins, for example "https://a.example, https://b.example"). // Whitespace around entries is tolerated; empty entries are an error. func splitOrigins(v string) ([]string, error) { parts := strings.Split(v, ",") origins := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p == "" { return nil, fmt.Errorf("empty entry in %q (comma-separated origins, or \"off\")", v) } origins = append(origins, p) } return origins, nil }