feat(notify): desktop notification per announcement with a Cancel button

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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 12:10:49 +02:00
1 parent 6f3e7c9f35
commit 83a15af9c5
22 files changed
+806 -45

No files matched your search

+79 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -53,6 +54,9 @@ type Config struct {
// 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.
@@ -86,6 +90,13 @@ type Config struct {
// 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.
@@ -120,6 +131,15 @@ type Daemon struct {
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
@@ -165,6 +185,7 @@ func Defaults() Config {
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,
}
@@ -202,10 +223,29 @@ func Load(opts Options) (Config, error) {
path = filepath.Join(dir, fileName)
}
if _, err := toml.DecodeFile(path, &cfg); err != nil {
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)
}
@@ -260,6 +300,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
}
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
}
@@ -326,6 +373,37 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
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.