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>
206 lines
7.1 KiB
Go
206 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
|
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
|
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
)
|
|
|
|
// Time budgets for talking to the daemon. Detection (GET /status) is short:
|
|
// on a free port the connection is refused immediately; the budget only
|
|
// covers unlucky cases (filtered ports) so talk never dawdles without a
|
|
// daemon.
|
|
const (
|
|
daemonProbeTimeout = time.Second // daemon detection (GET /status)
|
|
daemonCallTimeout = 5 * time.Second // individual daemon calls
|
|
daemonStartTimeout = 5 * time.Second // autostart: how long to wait for /status
|
|
daemonStartPoll = 50 * time.Millisecond // autostart poll interval
|
|
waitPollInterval = 100 * time.Millisecond // --wait poll interval
|
|
)
|
|
|
|
// daemonAlive reports whether a daemon answers under the client (GET
|
|
// /status with 200). Every error (connection refused, timeout, a foreign
|
|
// service with another status) counts as "no daemon".
|
|
func daemonAlive(client *api.Client, timeout time.Duration) bool {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
_, err := client.Status(ctx)
|
|
return err == nil
|
|
}
|
|
|
|
// ensureDaemon returns a client to a running daemon, starting one if needed
|
|
// (daemon.autostart, default on). ok=false means: no daemon reachable and
|
|
// none startable (autostart off, start failed); the caller then speaks
|
|
// directly. Reasons go to stderr; the daemon itself logs to daemon.log in
|
|
// the config directory.
|
|
func ensureDaemon(cfg config.Config, stderr io.Writer) (client *api.Client, ok bool) {
|
|
client = api.NewClient(cfg.Server.Port)
|
|
if daemonAlive(client, daemonProbeTimeout) {
|
|
return client, true
|
|
}
|
|
if !cfg.Daemon.Autostart {
|
|
return client, false
|
|
}
|
|
return client, startDaemon(client, stderr)
|
|
}
|
|
|
|
// startDaemon starts the daemon in the background (the same binary with
|
|
// `daemon run`) and waits until it answers on /status. If the child exits
|
|
// first (typically: port taken because a parallel talk call was faster), we
|
|
// probe once more: the other call's daemon counts just as well.
|
|
func startDaemon(client *api.Client, stderr io.Writer) bool {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: cannot determine own binary; daemon start skipped (%v)\n", err)
|
|
return false
|
|
}
|
|
logPath := ""
|
|
if dir, err := config.Dir(getenv); err == nil {
|
|
logPath = filepath.Join(dir, daemon.LogFileName)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
logPath = ""
|
|
}
|
|
}
|
|
exited, err := daemon.Spawn(exe, logPath, os.Environ())
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
return false
|
|
}
|
|
logHint := ""
|
|
if logPath != "" {
|
|
logHint = " (log: " + logPath + ")"
|
|
}
|
|
deadline := time.Now().Add(daemonStartTimeout)
|
|
for {
|
|
if daemonAlive(client, daemonStartPoll*4) {
|
|
fmt.Fprintf(stderr, "talk: daemon started at %s%s\n", client.BaseURL, logHint)
|
|
return true
|
|
}
|
|
select {
|
|
case err := <-exited:
|
|
if daemonAlive(client, daemonProbeTimeout) {
|
|
return true // another call started the daemon
|
|
}
|
|
fmt.Fprintf(stderr, "talk: daemon start failed (%s)%s\n", exitReason(err), logHint)
|
|
return false
|
|
case <-time.After(daemonStartPoll):
|
|
}
|
|
if time.Now().After(deadline) {
|
|
fmt.Fprintf(stderr, "talk: daemon not answering within %s%s\n", daemonStartTimeout, logHint)
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// exitReason makes the Wait result of an exited child readable.
|
|
func exitReason(err error) string {
|
|
if err == nil {
|
|
return "process exited immediately"
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
// sayViaDaemon submits an announcement to the daemon (POST /speak). Default:
|
|
// return at once (non-blocking), info receives the item ID; --wait polls
|
|
// /status until the announcement is neither playing nor waiting. When muted
|
|
// the daemon discards and info says "muted"; law discards name the reason.
|
|
// info is the writer for these information tokens: say passes stdout, wrap
|
|
// stderr (a wrapped command's stdout must not be polluted by talk lines).
|
|
//
|
|
// handled=false means: network error (daemon died between detection and
|
|
// call), so the caller speaks directly. A daemon response with an error
|
|
// status (for example 400) is reported instead: the announcement reached
|
|
// the daemon and was rejected deliberately.
|
|
func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
|
defer cancel()
|
|
resp, err := client.Speak(ctx, api.SpeakRequest{
|
|
Text: text,
|
|
Priority: opts.priority,
|
|
Provider: opts.provider,
|
|
Voice: opts.voice,
|
|
Lang: opts.lang,
|
|
Now: opts.now,
|
|
})
|
|
if err != nil {
|
|
var httpErr *api.HTTPError
|
|
if errors.As(err, &httpErr) {
|
|
fmt.Fprintf(stderr, "talk: the daemon rejected the announcement: %v\n", err)
|
|
return exitFailure, true
|
|
}
|
|
return 0, false
|
|
}
|
|
if resp.Muted {
|
|
fmt.Fprintln(info, "muted")
|
|
fmt.Fprintln(stderr, "talk: muted; announcement discarded")
|
|
return exitOK, true
|
|
}
|
|
if resp.Discarded != "" {
|
|
// Law discard (rate limit budget / quiet hours): documented behaviour
|
|
// like mute, a plain token instead of an error; --wait has nothing to
|
|
// wait for, there is no item ID.
|
|
line := "discarded: " + resp.Discarded
|
|
if resp.Toast {
|
|
line += " (toast)"
|
|
}
|
|
fmt.Fprintln(info, line)
|
|
fmt.Fprintln(stderr, "talk: "+discardHint(resp.Discarded, resp.Toast))
|
|
return exitOK, true
|
|
}
|
|
fmt.Fprintln(info, resp.ID)
|
|
if resp.Truncated {
|
|
fmt.Fprintln(stderr, "talk: text was too long and has been truncated (queue.max_text); announcements are 1 to 2 sentences")
|
|
}
|
|
if !opts.wait {
|
|
return exitOK, true
|
|
}
|
|
return waitForItem(client, resp.ID, stderr), true
|
|
}
|
|
|
|
// discardHint translates the daemon's discard reason into a human note.
|
|
func discardHint(reason string, toast bool) string {
|
|
switch reason {
|
|
case queue.DiscardQuietHours:
|
|
if toast {
|
|
return "quiet hours active; announcement replaced by a toast (no audio)"
|
|
}
|
|
return "quiet hours active; announcement discarded (quiet_mode = discard; --now deliberately breaks out)"
|
|
case queue.DiscardRateLimit:
|
|
return "rate limit; announcement discarded (priority low over the window budget)"
|
|
case queue.DiscardExpired:
|
|
return "announcement discarded; it waited longer than queue.max_age allows (stale)"
|
|
default:
|
|
return "announcement discarded (" + reason + ")"
|
|
}
|
|
}
|
|
|
|
// waitForItem polls /status until the announcement id is neither playing
|
|
// nor waiting; it is then done (or cancelled; either way there is nothing
|
|
// left to wait for). If the daemon dies while waiting, that is an error:
|
|
// the announcement's state is unknown, and waiting silently would be the
|
|
// opposite of --wait.
|
|
func waitForItem(client *api.Client, id string, stderr io.Writer) int {
|
|
for {
|
|
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
|
st, err := client.Status(ctx)
|
|
cancel()
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: daemon no longer reachable while waiting (%v)\n", err)
|
|
return exitFailure
|
|
}
|
|
if !st.PendingOrCurrent(id) {
|
|
return exitOK
|
|
}
|
|
time.Sleep(waitPollInterval)
|
|
}
|
|
}
|