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

+27 -8
View File
@@ -78,9 +78,9 @@ Keys never go into config.toml.
- **`talk test`** speaks a fixed test sentence directly in this process (not
via the daemon; mute and quiet hours do not apply here, an active mute is
mentioned) and shows which tier spoke and how long synthesis and playback
took. `talk test --toast` sends a test desktop notification through the
same path `quiet_mode = toast` uses, so you can check notifications
without a quiet window or a daemon restart.
took. `talk test --toast` shows a test notification with the Cancel button
and waits for a click, so you can check the notification setup without
speaking anything.
- **`talk voices`** lists the curated voice catalog (ElevenLabs premade,
OpenAI, Edge neural voices per locale) as a table; `*` marks the voice
talk uses without `--voice` (config `voice.<provider>.<lang>` or the
@@ -95,6 +95,26 @@ Keys never go into config.toml.
very end. Without a terminal (pipe, agent) setup asks nothing and only
shows the effective configuration.
### Notifications with a Cancel button
Every announcement that plays also shows a desktop notification with its
text and, on Linux, a **Cancel** button (German: Abbrechen) that stops that
announcement without a terminal. The notification disappears when the
announcement ends. talk talks to the freedesktop notification service on the
session bus directly (any daemon with the "actions" capability: mako, dunst,
GNOME Shell, KDE, quickshell); without a session bus it falls back to
notify-send, on macOS to Notification Center and on Windows to a toast, all
without a button. Turn it off with:
```toml
[notify]
on_speak = false # env TALK_NOTIFY=false
```
`talk test --toast` shows one such notification, waits a few seconds for a
click on Cancel and reports what happened; `talk doctor` names the service
and whether it supports the button.
### Language
talk detects German and English per announcement (word lists plus umlauts;
@@ -183,11 +203,10 @@ hours are opt-in like mute). During quiet hours audio is dropped, also for
high (burst is a rate right, not a quiet right):
- `quiet_mode = "discard"` (default): drop silently.
- `quiet_mode = "toast"`: a desktop notification instead of audio, through
the system's own tools (Linux `notify-send`, macOS Notification Center,
Windows toast), best effort: if the tool is missing, only the log line
remains and `talk doctor` shows it under "Toast". Try it with
`talk test --toast`.
- `quiet_mode = "toast"`: a desktop notification instead of audio (the
notification service or the system's own tool, see "Notifications"), best
effort: if nothing is available, only the log line remains and
`talk doctor` shows it under "Toast".
- `now:true` **breaks out of quiet hours too** (a deliberate interruption).
**Length and age:** two more laws against what prompts like to do, reading
+7 -3
View File
@@ -129,6 +129,10 @@ func checkConfig() (config.Config, check) {
if _, statErr := os.Stat(path); statErr != nil {
return cfg, check{"Config", statusOK, fmt.Sprintf("no file at %s; defaults apply (talk setup creates one)", path)}
}
if len(cfg.Unknown) > 0 {
return cfg, check{"Config", statusWarn, fmt.Sprintf("%s loaded, but it has keys talk does not know: %s. A top-level key written below a [table] header belongs to that table; move it above the first [table]",
path, strings.Join(cfg.Unknown, ", "))}
}
return cfg, check{"Config", statusOK, path + " loaded"}
}
@@ -262,12 +266,12 @@ func probeTier(p provider.Provider) string {
func checkNotify(cfg config.Config) check {
if notify.Available() {
return check{"Toast", statusOK, notify.Describe() + "; used for quiet_mode = toast"}
return check{"Notify", statusOK, notify.Describe() + "; used for quiet_mode = toast"}
}
if cfg.QuietMode == queue.QuietModeToast {
return check{"Toast", statusWarn, notify.Describe() + "; quiet_mode = toast is only logged"}
return check{"Notify", statusWarn, notify.Describe() + "; quiet_mode = toast is only logged"}
}
return check{"Toast", statusOK, notify.Describe() + " (only relevant for quiet_mode = toast)"}
return check{"Notify", statusOK, notify.Describe() + " (only relevant for quiet_mode = toast)"}
}
func checkMute() check {
+21
View File
@@ -114,3 +114,24 @@ func TestDoctorBrokenConfig(t *testing.T) {
}
}
}
// TestDoctorUnknownConfigKeys: a key filed under the wrong table is ⚠ with a
// hint, not an error.
func TestDoctorUnknownConfigKeys(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "XDG_CONFIG_HOME": root})
if err := os.MkdirAll(filepath.Join(root, "talk"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "talk", "config.toml"), []byte("[voice]\nlang = \"de\"\n[voice.edge]\nde = \"de-DE-KatjaNeural\"\n"), 0o644); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
code := runCLI([]string{"doctor"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("exit %d, want 0 (unknown keys are a warning)\n%s", code, out.String())
}
if !strings.Contains(out.String(), "⚠ Config") || !strings.Contains(out.String(), "voice.lang") || !strings.Contains(out.String(), "[table]") {
t.Errorf("stdout = %q", out.String())
}
}
+10
View File
@@ -10,6 +10,15 @@ import (
"testing"
)
// TestMain cuts the test binary off from the developer's real session bus:
// the notify package would otherwise show real notifications (doctor, test
// --toast) and wait for clicks. With an unreachable address the D-Bus path
// fails fast and the tests observe the notify-send fallback.
func TestMain(m *testing.M) {
os.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/talk-test-bus")
os.Exit(m.Run())
}
// setGetenv overrides the CLI's environment seam for one test. Base kit of
// every test: TALK_EDGE_ENDPOINT points at a guaranteed dead loopback port
// (connection refused at once); the default chain contains edge, and no
@@ -25,6 +34,7 @@ func setGetenv(t *testing.T, kv map[string]string) {
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
"TALK_PORT": "1",
"TALK_AUTOSTART": "false", // in-process tests must never start the test binary as a daemon
"TALK_NOTIFY": "false", // and never pop up notifications on the developer's desktop
}
for k, v := range kv {
base[k] = v
+1
View File
@@ -404,6 +404,7 @@ func printEffective(w io.Writer, cfg config.Config, path string) {
}
fmt.Fprintf(w, " quiet hours: %s\n", orDefault(cfg.QuietHours, "none"))
fmt.Fprintf(w, " autostart: %v\n", cfg.Daemon.Autostart)
fmt.Fprintf(w, " notify: %v (desktop notification per announcement)\n", cfg.Notify.OnSpeak)
}
func audioSummary(cfg config.Config) string {
+34 -6
View File
@@ -88,23 +88,51 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
return exitOK
}
// runToastTest sends the test notification and reports the tool and the
// result. Exit 1 when no tool exists or the call fails.
// runToastTest shows the test notification the way a playing announcement
// gets one (with a Cancel button where supported), waits a few seconds for a
// click and reports what happened. Exit 1 when no tool exists or the call
// fails.
func runToastTest(lang string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "toast: %s\n", notify.Describe())
if !notify.Available() {
fmt.Fprintln(stderr, "talk test: no notification tool found; install libnotify (notify-send) on Linux, macOS and Windows have it built in")
fmt.Fprintln(stderr, "talk test: no notification path found; on Linux install libnotify (notify-send) or run a notification daemon, macOS and Windows have one built in")
return exitFailure
}
label := "Cancel"
if lang == "de" {
label = "Abbrechen"
}
clicked := make(chan struct{}, 1)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
start := time.Now()
if err := notify.Show(ctx, toastPhrase[lang]); err != nil {
closeFn, err := notify.Announce(ctx, toastPhrase[lang], label, func() {
select {
case clicked <- struct{}{}:
default:
}
})
if err != nil {
fmt.Fprintf(stderr, "talk test: %v\n", err)
fmt.Fprintln(stderr, "hint: on Linux a notification daemon (mako, dunst, GNOME Shell, KDE) must be running for notify-send to show anything.")
fmt.Fprintln(stderr, "hint: on Linux a notification daemon (mako, dunst, GNOME Shell, KDE) must be running.")
return exitFailure
}
fmt.Fprintf(stdout, "sent: %s\n", time.Since(start).Round(time.Millisecond))
fmt.Fprintln(stdout, "If no notification appeared, your notification daemon did not show it (talk only hands it over).")
if !notify.SupportsCancel() {
fmt.Fprintln(stdout, "cancel: not available on this platform (notification without button)")
fmt.Fprintln(stdout, "If no notification appeared, your notification daemon did not show it (talk only hands it over).")
return exitOK
}
fmt.Fprintf(stdout, "cancel: button %q shown; waiting up to %s for a click …\n", label, toastWait)
select {
case <-clicked:
fmt.Fprintln(stdout, "cancel: clicked, works")
case <-time.After(toastWait):
fmt.Fprintln(stdout, "cancel: not clicked (that is fine); closing the notification")
}
closeFn()
return exitOK
}
// toastWait is how long `talk test --toast` waits for a click on Cancel.
const toastWait = 8 * time.Second
+3 -1
View File
@@ -46,6 +46,8 @@ func TestTestToast(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("fake notify-send exists on Linux only")
}
// No session bus in the test binary (TestMain): the fallback through
// notify-send is what we observe here.
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeTTSBin(t, dir, "notify-send", `printf '%s\n' "$@" > "`+seen+`"`)
@@ -71,7 +73,7 @@ func TestTestToast(t *testing.T) {
t.Setenv("PATH", t.TempDir())
out.Reset()
errBuf.Reset()
if code := runCLI([]string{"test", "--toast"}, &out, &errBuf); code != exitFailure || !strings.Contains(errBuf.String(), "no notification tool") {
if code := runCLI([]string{"test", "--toast"}, &out, &errBuf); code != exitFailure || !strings.Contains(errBuf.String(), "no notification path") {
t.Errorf("without tool: exit %d, stderr %q", code, errBuf.String())
}
}
+1 -1
View File
@@ -23,7 +23,7 @@
subPackages = [ "cmd/talk" ];
# Update after changes to go.mod/go.sum: `nix build` prints the
# expected hash.
vendorHash = "sha256-CbF3juTA7V05hh1ElKKACddsCn4bIPeLZxsz2EZxs9I=";
vendorHash = "sha256-Cne8FiMlpsrFfntMJVtV4D3CzEWQ4Jnd+tIEHF9ecEc=";
env.CGO_ENABLED = 0;
ldflags = [ "-s" "-w" "-X" "main.version=${version}" "-X" "main.commit=${self.shortRev or "dirty"}" ];
# The process-boundary tests start daemons and bind ports; not in the sandbox.
+1
View File
@@ -5,6 +5,7 @@ go 1.26.8
require (
github.com/BurntSushi/toml v1.6.0
github.com/ebitengine/oto/v3 v3.5.0
github.com/godbus/dbus/v5 v5.2.4
github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/go-mp3 v0.3.4
)
+2
View File
@@ -4,6 +4,8 @@ github.com/ebitengine/oto/v3 v3.5.0 h1:8ZDhKP3Hhslyb9NL3g4HWvhRQqwI+hKOHAXAqD6GP
github.com/ebitengine/oto/v3 v3.5.0/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o=
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U=
github.com/godbus/dbus/v5 v5.2.4/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
+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.
+40
View File
@@ -629,3 +629,43 @@ func TestLoadLang(t *testing.T) {
t.Errorf("TALK_LANG=fr: error = %v, want a clear lang error", err)
}
}
// TestLoadNotify covers [notify] on_speak: default on, TOML off, env
// TALK_NOTIFY beats the file, garbage is an error.
func TestLoadNotify(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil || !cfg.Notify.OnSpeak {
t.Fatalf("default = %+v (%v), want on_speak true", cfg.Notify, err)
}
cfg, err = Load(Options{File: writeTOML(t, "[notify]\non_speak = false\n"), Getenv: env(nil)})
if err != nil || cfg.Notify.OnSpeak {
t.Errorf("TOML = %+v (%v), want on_speak false", cfg.Notify, err)
}
cfg, err = Load(Options{File: writeTOML(t, "[notify]\non_speak = false\n"), Getenv: env(map[string]string{"TALK_NOTIFY": "true"})})
if err != nil || !cfg.Notify.OnSpeak {
t.Errorf("env = %+v (%v), want on_speak true (env beats file)", cfg.Notify, err)
}
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_NOTIFY": "maybe"})}); err == nil {
t.Error("TALK_NOTIFY=maybe: no error, want a clear error")
}
}
// TestLoadReportsUnknownKeys: keys talk does not know are collected (not an
// error), including the classic trap of a top-level key written below a
// [table] header.
func TestLoadReportsUnknownKeys(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[voice]\nlang = \"de\"\n[voice.edge]\nde = \"de-DE-KatjaNeural\"\nvolume = \"loud\"\n\n[typo]\nvalue = 1\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if strings.Join(cfg.Unknown, ",") != "typo.value,voice.edge.volume,voice.lang" {
t.Errorf("Unknown = %v, want [typo.value voice.edge.volume voice.lang]", cfg.Unknown)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q; the misplaced key must not leak into the top level", cfg.Lang)
}
cfg, _ = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(nil)})
if len(cfg.Unknown) != 0 {
t.Errorf("Unknown = %v for a clean file, want none", cfg.Unknown)
}
}
+34 -9
View File
@@ -84,7 +84,34 @@ func Run(ctx context.Context, opts Options) error {
if err != nil {
return err
}
q := queue.New(queue.Laws{
// Desktop notification per announcement (notify.on_speak): shown when the
// announcement starts, with a Cancel button where the platform has one,
// closed when it ends. Best effort: the first failure is logged, later
// ones are silent, speech never waits for a notification problem.
var q *queue.Queue
var notifyFailed sync.Once
announceNotification := func(ctx context.Context, item queue.Item, lang string) func() {
if !cfg.Notify.OnSpeak {
return func() {}
}
label := "Cancel"
if lang == "de" {
label = "Abbrechen"
}
closeFn, err := notify.Announce(ctx, item.Text, label, func() {
if q.CancelItem(item.ID) {
logger.Printf("announcement %s cancelled from its notification", item.ID)
}
})
if err != nil {
notifyFailed.Do(func() {
logger.Printf("warning: notifications unavailable (%v); announcements play without one. Set [notify] on_speak = false to silence this", err)
})
}
return closeFn
}
q = queue.New(queue.Laws{
RateLimit: cfg.Queue.RateLimit,
AllowHighBurst: cfg.Queue.AllowHighBurst,
Quiet: quiet,
@@ -92,12 +119,10 @@ func Run(ctx context.Context, opts Options) error {
MaxText: cfg.Queue.MaxText,
MaxAge: cfg.Queue.MaxAge,
}, func(ctx context.Context, item queue.Item) error {
out, err := engine.Speak(ctx, speak.Request{
Text: item.Text,
Lang: item.Lang,
Provider: item.Provider,
Voice: item.Voice,
})
req := speak.Request{Text: item.Text, Lang: item.Lang, Provider: item.Provider, Voice: item.Voice}
closeNotification := announceNotification(ctx, item, engine.Lang(req))
defer closeNotification()
out, err := engine.Speak(ctx, req)
if err == nil && !out.Discarded {
logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider,
out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond))
@@ -175,8 +200,8 @@ func Run(ctx context.Context, opts Options) error {
if len(cfg.Server.AllowedOrigins) > 0 {
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
}
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)",
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(),
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, notify=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)",
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(), cfg.Notify.OnSpeak,
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
select {
+179
View File
@@ -0,0 +1,179 @@
//go:build linux
package notify
import (
"context"
"fmt"
"slices"
"sync"
"time"
"github.com/godbus/dbus/v5"
)
// The freedesktop Notifications service on the session bus. One connection
// per process, made lazily on first use and kept: the daemon notifies on
// every announcement and must see the ActionInvoked signals for the Cancel
// button, which only works on a connection that stays open.
const (
notifDest = "org.freedesktop.Notifications"
notifPath = "/org/freedesktop/Notifications"
notifIface = "org.freedesktop.Notifications"
// dbusCallTimeout bounds every call: a hanging notification service
// must never hold up an announcement.
dbusCallTimeout = 2 * time.Second
)
var bus struct {
once sync.Once
conn *dbus.Conn
err error
caps []string
server string
mu sync.Mutex
handlers map[uint32]func(action string) // by notification id
}
// connect opens the session bus once, subscribes to the service's signals
// and starts the dispatcher. A missing bus (DBUS_SESSION_BUS_ADDRESS unset:
// servers, SSH, WinRM-style sessions) is a normal, remembered failure.
func connect() (*dbus.Conn, error) {
bus.once.Do(func() {
conn, err := dbus.SessionBus()
if err != nil {
bus.err = fmt.Errorf("session bus: %w", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), dbusCallTimeout)
defer cancel()
obj := conn.Object(notifDest, notifPath)
var name, vendor, version, spec string
if err := obj.CallWithContext(ctx, notifIface+".GetServerInformation", 0).Store(&name, &vendor, &version, &spec); err != nil {
bus.err = fmt.Errorf("no notification service on the session bus: %w", err)
return
}
bus.server = name
if version != "" {
bus.server += " " + version
}
if err := obj.CallWithContext(ctx, notifIface+".GetCapabilities", 0).Store(&bus.caps); err != nil {
bus.caps = nil
}
if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(notifPath), dbus.WithMatchInterface(notifIface)); err != nil {
bus.err = fmt.Errorf("subscribing to notification signals: %w", err)
return
}
bus.handlers = map[uint32]func(string){}
ch := make(chan *dbus.Signal, 32)
conn.Signal(ch)
go dispatch(ch)
bus.conn = conn
})
return bus.conn, bus.err
}
// dispatch routes ActionInvoked to the notification's handler and drops the
// handler on NotificationClosed.
func dispatch(ch <-chan *dbus.Signal) {
for sig := range ch {
if len(sig.Body) == 0 {
continue
}
id, ok := sig.Body[0].(uint32)
if !ok {
continue
}
switch sig.Name {
case notifIface + ".ActionInvoked":
action, _ := sig.Body[1].(string)
bus.mu.Lock()
h := bus.handlers[id]
bus.mu.Unlock()
if h != nil {
h(action)
}
case notifIface + ".NotificationClosed":
bus.mu.Lock()
delete(bus.handlers, id)
bus.mu.Unlock()
}
}
}
// dbusAvailable reports whether a notification service answers on the bus.
func dbusAvailable() bool {
_, err := connect()
return err == nil
}
// dbusSupportsActions reports the "actions" capability of the service.
func dbusSupportsActions() bool {
if _, err := connect(); err != nil {
return false
}
return slices.Contains(bus.caps, "actions")
}
// dbusDescribe names the service for `talk doctor`.
func dbusDescribe() (string, bool) {
if _, err := connect(); err != nil {
return "", false
}
s := "notification service " + bus.server + " via D-Bus"
if slices.Contains(bus.caps, "actions") {
return s + " (cancel button supported)", true
}
return s + " (no actions: no cancel button)", true
}
// dbusNotify shows a notification and returns its id. actions is the flat
// freedesktop list [key, label, …]; onAction receives the key when the user
// clicks. expire_timeout 0 keeps the notification until talk closes it, so
// the Cancel button stays for the whole announcement.
func dbusNotify(ctx context.Context, text string, actions []string, onAction func(action string)) (uint32, error) {
conn, err := connect()
if err != nil {
return 0, err
}
if actions == nil {
actions = []string{}
}
expire := int32(-1) // server default for plain toasts
if len(actions) > 0 {
expire = 0
}
ctx, cancel := context.WithTimeout(ctx, dbusCallTimeout)
defer cancel()
hints := map[string]dbus.Variant{
"urgency": dbus.MakeVariant(byte(1)),
"desktop-entry": dbus.MakeVariant(Title),
}
var id uint32
err = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".Notify", 0,
Title, uint32(0), "audio-speakers", Title, text, actions, hints, expire).Store(&id)
if err != nil {
return 0, fmt.Errorf("notify: %w", err)
}
if onAction != nil {
bus.mu.Lock()
bus.handlers[id] = onAction
bus.mu.Unlock()
}
return id, nil
}
// dbusClose dismisses a notification (best effort).
func dbusClose(id uint32) {
conn, err := connect()
if err != nil {
return
}
bus.mu.Lock()
delete(bus.handlers, id)
bus.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), dbusCallTimeout)
defer cancel()
_ = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".CloseNotification", 0, id).Err
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !linux
package notify
import (
"context"
"errors"
)
// Without the freedesktop Notifications service there is no bus path; the
// OS tool does the job (no actions).
var errNoDBus = errors.New("notify: no D-Bus notification service on this platform")
func dbusAvailable() bool { return false }
func dbusSupportsActions() bool { return false }
func dbusDescribe() (string, bool) { return "", false }
func dbusClose(uint32) {}
func dbusNotify(context.Context, string, []string, func(string)) (uint32, error) {
return 0, errNoDBus
}
+68 -14
View File
@@ -1,10 +1,12 @@
// Package notify shows desktop notifications: best effort, through the
// operating system's own tools, without a library dependency and without
// CGO (the same line as provider/osbuiltin):
// Package notify shows desktop notifications: best effort, never a reason to
// abort an announcement.
//
// Linux: notify-send (libnotify; needs a notification daemon)
// macOS: osascript `display notification`
// Windows: PowerShell toast (Windows.UI.Notifications)
// Linux: the freedesktop Notifications D-Bus service (org.freedesktop.
// Notifications, pure Go via godbus): notifications with a Cancel
// action, closed again when the announcement ends. Fallback when
// no session bus is reachable: notify-send (no action).
// macOS: osascript `display notification` (no action).
// Windows: PowerShell toast (Windows.UI.Notifications; no action yet).
//
// Missing tools are a clear error for the caller (log), never an abort: a
// notification is an extra, not a contract.
@@ -29,29 +31,46 @@ const Title = "talk"
// timeout bounds the call of the OS tool.
const timeout = 10 * time.Second
// Available reports whether a notification path exists on this system
// (binary present; whether a daemon listens only Show reveals).
// Available reports whether a notification path exists on this system: a
// reachable notification service on the session bus, or the OS tool.
func Available() bool {
if dbusAvailable() {
return true
}
_, err := exec.LookPath(tool())
return err == nil
}
// Describe names the path for `talk doctor`.
// Describe names the path for `talk doctor`: the notification service with
// name and version and whether it supports a Cancel action, or the OS tool.
func Describe() string {
if info, ok := dbusDescribe(); ok {
return info
}
t := tool()
if _, err := exec.LookPath(t); err != nil {
if runtime.GOOS == "linux" {
return "no notification service on the session bus and " + t + " not found"
}
return t + " not found"
}
switch runtime.GOOS {
case "darwin":
return "osascript (Notification Center)"
return "osascript (Notification Center; no cancel button)"
case "windows":
return "powershell (Windows toast)"
return "powershell (Windows toast; no cancel button yet)"
default:
return "notify-send (libnotify)"
return "notify-send (libnotify; no session bus, so no cancel button)"
}
}
// SupportsCancel reports whether notifications on this system can carry a
// Cancel action that reaches talk (Linux with a notification service that
// has the "actions" capability).
func SupportsCancel() bool {
return dbusSupportsActions()
}
func tool() string {
switch runtime.GOOS {
case "darwin":
@@ -63,9 +82,44 @@ func tool() string {
}
}
// Show shows a notification with Title and text and waits for the OS call
// to finish (not for the notification to be closed).
// Show shows a plain notification with Title and text (no action) and waits
// for the handover to finish, not for the notification to be closed. Used
// for the quiet-hours toast.
func Show(ctx context.Context, text string) error {
if dbusAvailable() {
id, err := dbusNotify(ctx, text, nil, nil)
if err == nil {
_ = id
return nil
}
}
return showWithTool(ctx, text)
}
// Announce shows the notification for an announcement that is playing: the
// text plus a Cancel action labelled cancelLabel where the platform supports
// it. onCancel runs when the user clicks it. The returned close function
// dismisses the notification (call it when the announcement ends); it is
// never nil. Platforms without actions show a plain notification and the
// close function does nothing.
func Announce(ctx context.Context, text, cancelLabel string, onCancel func()) (close func(), err error) {
noop := func() {}
if dbusAvailable() {
id, err := dbusNotify(ctx, text, []string{"cancel", cancelLabel}, func(action string) {
if action == "cancel" && onCancel != nil {
onCancel()
}
})
if err == nil {
return func() { dbusClose(id) }, nil
}
// fall through to the tool on a bus error
}
return noop, showWithTool(ctx, text)
}
// showWithTool shows a notification through the OS tool (no action).
func showWithTool(ctx context.Context, text string) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var cmd *exec.Cmd
+7
View File
@@ -9,6 +9,13 @@ import (
"testing"
)
// TestMain cuts the tests off from the developer's real session bus so they
// observe the notify-send fallback instead of popping real notifications.
func TestMain(m *testing.M) {
os.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/talk-test-bus")
os.Exit(m.Run())
}
// TestShowCallsNotifySend: on Linux, Show goes through notify-send; here a
// fake in PATH that records its arguments (a real process, no mock
// framework). Title and text arrive unchanged.
+13
View File
@@ -259,6 +259,19 @@ func (q *Queue) Cancel() int {
return n
}
// CancelItem aborts the announcement with the given id if it is the one
// playing right now (the Cancel button of its notification). Waiting items
// and the rest of the queue are untouched. Reports whether it hit.
func (q *Queue) CancelItem(id string) bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.current == nil || q.current.ID != id || q.cancelCur == nil {
return false
}
q.cancelCur()
return true
}
// Close shuts the queue down: waiting items are dropped, the playing
// announcement is aborted, the worker loop ends. Close waits until the loop
// has ended (idempotent).
+28
View File
@@ -614,3 +614,31 @@ func waitFor(t *testing.T, cond func() bool) {
time.Sleep(5 * time.Millisecond)
}
}
// TestCancelItem: cancels only the playing item with that id; a stale id or
// a waiting item is a no-op, the queue goes on with the next item.
func TestCancelItem(t *testing.T) {
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
first, _ := q.Enqueue(Item{Text: "playing"})
g.waitUntil(t, 1)
second, _ := q.Enqueue(Item{Text: "waiting"})
if q.CancelItem(second.ID) {
t.Error("CancelItem on a waiting item reported a hit")
}
if q.CancelItem("nosuch") {
t.Error("CancelItem with an unknown id reported a hit")
}
if !q.CancelItem(first.ID) {
t.Fatal("CancelItem on the playing item reported no hit")
}
// The playing item ended via ctx; the waiting one starts next.
started := g.waitUntil(t, 2)
if len(started) != 1 || started[0].ID != second.ID {
t.Errorf("after CancelItem started = %+v, want the waiting item", started)
}
g.step <- struct{}{}
}
+9
View File
@@ -82,6 +82,15 @@ func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, erro
return out, err
}
// Lang resolves the language of a request: the pin, or detection with the
// configured fallback.
func (e *Engine) Lang(req Request) string {
if req.Lang != "" {
return req.Lang
}
return langdetect.Detect(req.Text, e.cfg.Lang)
}
// Chain returns the unpinned base chain (diagnostics: tiers and their
// availability).
func (e *Engine) Chain() *provider.Chain { return e.base }
+4 -2
View File
@@ -331,8 +331,10 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
"TALK_ELEVENLABS_ENDPOINT": fakeURL,
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // the chain must never fall back to real third-party endpoints
"TALK_PORT": strconv.Itoa(port),
"TALK_RATE_LIMIT": "0s", // law off until a test turns it on (see above)
"TALK_AUTOSTART": "false", // no test may leave a daemon behind unnoticed; autostart_test.go turns it on deliberately
"TALK_RATE_LIMIT": "0s", // law off until a test turns it on (see above)
"TALK_AUTOSTART": "false", // no test may leave a daemon behind unnoticed; autostart_test.go turns it on deliberately
"TALK_NOTIFY": "false", // no desktop notifications on the developer's machine; notify_dbus_test.go turns it on against a private bus
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/nonexistent/talk-test-bus", // never the developer's real session bus (toast tests observe notify-send)
}
for k, v := range extra {
set[k] = v
+217
View File
@@ -0,0 +1,217 @@
//go:build linux
package test
// Process-boundary test for per-announcement notifications with a Cancel
// button: a private D-Bus session bus (dbus-daemon) hosts a fake
// org.freedesktop.Notifications service written here in Go; the daemon
// connects to it via DBUS_SESSION_BUS_ADDRESS. Observed: Notify arrives with
// the announcement text and the cancel action, ActionInvoked("cancel")
// aborts the playing synthesis, and CloseNotification follows.
import (
"bufio"
"os/exec"
"strings"
"sync"
"testing"
"time"
"github.com/godbus/dbus/v5"
)
// startPrivateBus starts a dbus-daemon session bus and returns its address.
func startPrivateBus(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("dbus-daemon"); err != nil {
t.Skip("dbus-daemon not installed")
}
cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--nopidfile", "--print-address=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("dbus-daemon printed no address: %v", err)
}
return strings.TrimSpace(line)
}
// notifyCall is one Notify the fake service received.
type notifyCall struct {
id uint32
summary string
body string
actions []string
expire int32
}
// fakeNotifications is a minimal org.freedesktop.Notifications service.
type fakeNotifications struct {
conn *dbus.Conn
mu sync.Mutex
next uint32
calls []notifyCall
closed []uint32
got chan notifyCall
}
func (f *fakeNotifications) Notify(appName string, replaces uint32, icon, summary, body string, actions []string, hints map[string]dbus.Variant, expire int32) (uint32, *dbus.Error) {
f.mu.Lock()
f.next++
c := notifyCall{id: f.next, summary: summary, body: body, actions: actions, expire: expire}
f.calls = append(f.calls, c)
f.mu.Unlock()
f.got <- c
return c.id, nil
}
func (f *fakeNotifications) CloseNotification(id uint32) *dbus.Error {
f.mu.Lock()
f.closed = append(f.closed, id)
f.mu.Unlock()
_ = f.conn.Emit("/org/freedesktop/Notifications", "org.freedesktop.Notifications.NotificationClosed", id, uint32(3))
return nil
}
func (f *fakeNotifications) GetCapabilities() ([]string, *dbus.Error) {
return []string{"body", "actions"}, nil
}
func (f *fakeNotifications) GetServerInformation() (string, string, string, string, *dbus.Error) {
return "fake-notifications", "talk-tests", "1.0", "1.2", nil
}
func (f *fakeNotifications) invoke(id uint32, action string) error {
return f.conn.Emit("/org/freedesktop/Notifications", "org.freedesktop.Notifications.ActionInvoked", id, action)
}
func (f *fakeNotifications) closedIDs() []uint32 {
f.mu.Lock()
defer f.mu.Unlock()
return append([]uint32(nil), f.closed...)
}
// serveFakeNotifications registers the fake service on the bus at addr.
func serveFakeNotifications(t *testing.T, addr string) *fakeNotifications {
t.Helper()
conn, err := dbus.Connect(addr)
if err != nil {
t.Fatalf("connecting to the private bus: %v", err)
}
t.Cleanup(func() { conn.Close() })
f := &fakeNotifications{conn: conn, got: make(chan notifyCall, 8)}
if err := conn.Export(f, "/org/freedesktop/Notifications", "org.freedesktop.Notifications"); err != nil {
t.Fatal(err)
}
reply, err := conn.RequestName("org.freedesktop.Notifications", dbus.NameFlagDoNotQueue)
if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
t.Fatalf("owning org.freedesktop.Notifications: %v (%v)", err, reply)
}
return f
}
// TestNotificationWithCancelButton: a playing announcement shows a
// notification carrying the text and a Cancel action; clicking it aborts the
// synthesis (the fake provider sees the request cancelled) and the
// notification is closed. A second, uninterrupted announcement is closed
// when it finishes.
func TestNotificationWithCancelButton(t *testing.T) {
addr := startPrivateBus(t)
fakeNotif := serveFakeNotifications(t, addr)
root := t.TempDir()
fake := newFakeProvider(t, 5*time.Second)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_NOTIFY": "true",
"DBUS_SESSION_BUS_ADDRESS": addr,
})
code, m := speak(t, d, `{"text":"Der lange Vortrag beginnt jetzt","lang":"de"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("speak: %d %v", code, m)
}
var call notifyCall
select {
case call = <-fakeNotif.got:
case <-time.After(5 * time.Second):
t.Fatalf("no Notify reached the fake service; daemon log:\n%s", d.stderr.String())
}
if call.summary != "talk" || call.body != "Der lange Vortrag beginnt jetzt" {
t.Errorf("Notify = %+v, want summary talk and the announcement text", call)
}
if strings.Join(call.actions, "|") != "cancel|Abbrechen" {
t.Errorf("actions = %v, want [cancel Abbrechen] (German label for lang de)", call.actions)
}
if call.expire != 0 {
t.Errorf("expire_timeout = %d, want 0 (stays until talk closes it)", call.expire)
}
fake.waitArrived(t, 1) // synthesis is in flight (5 s fake delay)
// Click Cancel: the synthesis request is aborted, the daemon goes idle.
if err := fakeNotif.invoke(call.id, "cancel"); err != nil {
t.Fatal(err)
}
hits := fake.waitHits(t, 1)
if hits[0].Completed {
t.Errorf("synthesis completed although Cancel was clicked: %+v", hits[0])
}
waitStatusIdle(t, d, 3*time.Second)
if !strings.Contains(d.stderr.String(), "cancelled from its notification") {
t.Errorf("daemon log lacks the cancel line:\n%s", d.stderr.String())
}
deadline := time.Now().Add(2 * time.Second)
for len(fakeNotif.closedIDs()) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if ids := fakeNotif.closedIDs(); len(ids) != 1 || ids[0] != call.id {
t.Errorf("CloseNotification ids = %v, want [%d]", ids, call.id)
}
// Second announcement runs through; its notification is closed at the end
// and carries the English label for an English text.
fake.setDelay(100 * time.Millisecond)
speak(t, d, `{"text":"The second one finishes on its own"}`)
select {
case call = <-fakeNotif.got:
case <-time.After(5 * time.Second):
t.Fatal("no Notify for the second announcement")
}
if strings.Join(call.actions, "|") != "cancel|Cancel" {
t.Errorf("actions = %v, want [cancel Cancel]", call.actions)
}
fake.waitCompleted(t, 1)
waitStatusIdle(t, d, 3*time.Second)
deadline = time.Now().Add(2 * time.Second)
for len(fakeNotif.closedIDs()) < 2 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if ids := fakeNotif.closedIDs(); len(ids) != 2 || ids[1] != call.id {
t.Errorf("CloseNotification ids = %v, want the second id %d appended", ids, call.id)
}
}
// TestNotificationsOffByConfig: TALK_NOTIFY=false shows nothing even with a
// notification service around.
func TestNotificationsOffByConfig(t *testing.T) {
addr := startPrivateBus(t)
fakeNotif := serveFakeNotifications(t, addr)
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_NOTIFY": "false",
"DBUS_SESSION_BUS_ADDRESS": addr,
})
speak(t, d, `{"text":"silent on the desktop"}`)
fake.waitCompleted(t, 1)
select {
case c := <-fakeNotif.got:
t.Errorf("Notify received although notifications are off: %+v", c)
case <-time.After(300 * time.Millisecond):
}
}