Files
talk/internal/config/config.go
T

421 lines
14 KiB
Go
Raw Normal View History

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"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"`
// Notify configures desktop notifications.
Notify Notify `toml:"notify"`
// 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.<provider>.<lang> = 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"`
// Unknown lists keys in config.toml that talk does not know (dotted,
// for example "voice.lang"). Not an error, because an older talk must
// still read a newer file, but `talk doctor` reports them: the classic
// trap is a top-level key written below a [table] header, which TOML
// then files under that table.
Unknown []string `toml:"-"`
}
// 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"`
}
// Notify holds the desktop notification settings (TOML `[notify]`).
type Notify struct {
// OnSpeak shows a desktop notification for every announcement that is
// playing, with a Cancel button where the platform supports it (Linux
// with a freedesktop notification service), so a long announcement can
// be stopped without a terminal. Default true; env TALK_NOTIFY.
OnSpeak bool `toml:"on_speak"`
}
// 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.<name>]`). 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},
Notify: Notify{OnSpeak: 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)
}
md, err := toml.DecodeFile(path, &cfg)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return cfg, fmt.Errorf("config %s: %w", path, err)
}
} else {
// The decoder lists an unknown table and its keys; keep the keys,
// which say more than the table.
undecoded := md.Undecoded()
for i, key := range undecoded {
prefix := key.String() + "."
isTable := false
for j, other := range undecoded {
if i != j && strings.HasPrefix(other.String(), prefix) {
isTable = true
break
}
}
if !isTable {
cfg.Unknown = append(cfg.Unknown, key.String())
}
}
cfg.Unknown = append(cfg.Unknown, strayVoiceKeys(cfg.Voice)...)
}
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_NOTIFY"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return cfg, fmt.Errorf("TALK_NOTIFY: invalid value %q (true/false)", v)
}
cfg.Notify.OnSpeak = 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
}
// strayVoiceKeys finds keys under [voice.<provider>] that are not languages
// (de | en) and voice entries that are not a table of languages at all. The
// decoder accepts anything there because the mapping is open; a top-level
// key such as `lang` written below [voice] lands here by accident.
func strayVoiceKeys(voice map[string]map[string]string) []string {
var out []string
providers := make([]string, 0, len(voice))
for p := range voice {
providers = append(providers, p)
}
sort.Strings(providers)
for _, p := range providers {
langs := voice[p]
if len(langs) == 0 {
out = append(out, "voice."+p)
continue
}
keys := make([]string, 0, len(langs))
for k := range langs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if k != "de" && k != "en" {
out = append(out, "voice."+p+"."+k)
}
}
}
return out
}
// 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
}