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) } }