talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
344 lines
12 KiB
Go
344 lines
12 KiB
Go
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.<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"`
|
||
}
|
||
|
||
// 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.<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},
|
||
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
|
||
}
|