Every playing announcement now shows a desktop notification with its text and, on Linux, a Cancel button (Abbrechen for German) that stops exactly that announcement; the notification is closed when the announcement ends. This brings back what the old talk.sh had with notify-send --action, as a default: a long announcement can be cut short without a terminal. Implementation: the freedesktop Notifications service on the session bus via godbus (pure Go): one connection per process, Notify with the action list, ActionInvoked dispatched to the announcement, CloseNotification at the end, expire_timeout 0 so the button stays for the whole announcement. Without a session bus the notify-send fallback shows a plain notification; macOS and Windows show one without a button for now. queue.CancelItem(id) cancels only the playing item. Config [notify] on_speak (default true, env TALK_NOTIFY). `talk test --toast` now shows the real thing and waits for a click; `talk doctor` names the service and whether it supports the button. Config hygiene found on the way: a key written below a [table] header belongs to that table in TOML (lang under [voice] was silently ignored). config.Load now collects unknown keys (undecoded ones and non-language keys under [voice.<provider>]) and `talk doctor` warns with the hint. Tests: process test on a private dbus-daemon with a fake org.freedesktop.Notifications service (Notify payload, cancel click aborts the synthesis, CloseNotification), queue.CancelItem, config keys. All test binaries point DBUS_SESSION_BUS_ADDRESS at a dead socket so no test ever pops a notification on the developer's desktop. flake vendorHash updated for the new dependency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
422 lines
14 KiB
Go
422 lines
14 KiB
Go
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
|
||
}
|