Files
m3ta-chironandClaude Fable 5.1 00eb76128b feat(notify): Cancel button on Windows toasts via the talk: protocol handler
Windows toasts cannot call back into the daemon, so the Cancel button opens
a talk: link instead: `talk:cancel?id=<id>&port=<port>`. The daemon
registers the talk: URL protocol for the current user (HKCU, no admin) and
points it at `talk uri "%1"`; Windows starts that on the click, and `talk
uri` posts to the new endpoint POST /cancel-item, which cancels only the
announcement with that id (queue.CancelItem). The toast is tagged and
removed again when the announcement ends. For now the toast is shown under
Windows PowerShell's app identity, which unpackaged programs can use
without a Start Menu shortcut.

Verified on the Windows 11 VM: registry entry written by the daemon,
Start-Process "talk:cancel?id=…&port=7800" (what the button does) stops
the playing announcement within two seconds, no toast errors in the log.

Tests: `talk uri` against a real handler on a loopback port (playing vs
waiting id, dead daemon, talk:test, garbage links), POST /cancel-item
process test (only the playing item, waiting one plays next, 400 without
id).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 12:14:38 +02:00

167 lines
5.6 KiB
Go

package api
// Request/response types of the daemon HTTP contract (binding fields). The
// CLI and other consumers (MCP) use the same types: one source, no contract
// drift between daemon and client.
// SpeakRequest is the body of POST /speak.
type SpeakRequest struct {
// Text is the announcement text (required).
Text string `json:"text"`
// Priority: low | normal | high (default normal when empty).
Priority string `json:"priority,omitempty"`
// Provider pins the provider chain (--provider).
Provider string `json:"provider,omitempty"`
// Voice pins a voice ID (--voice).
Voice string `json:"voice,omitempty"`
// Lang pins the language de | en (empty = automatic detection).
Lang string `json:"lang,omitempty"`
// Now lets the announcement jump ahead of all waiting ones (--now).
Now bool `json:"now,omitempty"`
}
// SpeakResponse is the response of POST /speak: 202 {"id":"..."}; with mute
// active 202 {"id":"","muted":true} (announcement discarded; documented
// behaviour, not an error). Discarded spam is reported just as
// documented: 202 {"id":"","discarded":"rate_limit"|"quiet_hours"}, plus
// "toast":true when the quiet-hours announcement was replaced by a
// notification (quiet_mode = "toast").
type SpeakResponse struct {
ID string `json:"id"`
Muted bool `json:"muted,omitempty"`
// Discarded is the discard reason ("rate_limit" | "quiet_hours"); empty
// when the announcement was admitted.
Discarded string `json:"discarded,omitempty"`
// Toast reports the replacement by a notification.
Toast bool `json:"toast,omitempty"`
// Truncated reports that the text was cut to queue.max_text and
// admitted that way (the law against read-aloud long answers).
Truncated bool `json:"truncated,omitempty"`
}
// CurrentItem describes the running item in GET /status.
type CurrentItem struct {
ID string `json:"id"`
Text string `json:"text"`
Priority string `json:"priority"`
Now bool `json:"now"`
}
// StatusResponse is the response of GET /status: queue length, current item
// (null when idle), mute state, active provider, daemon version. QueueIDs
// lists the waiting IDs in processing order; the CLI needs them for its
// --wait polling. The law state (rate_limit, quiet_hours) and mcp/cors
// (compat fields: MCP endpoint present, configured CORS origins) are
// additive.
type StatusResponse struct {
Version string `json:"version"`
Queue int `json:"queue"`
QueueIDs []string `json:"queue_ids,omitempty"`
Current *CurrentItem `json:"current"`
Muted bool `json:"muted"`
Provider string `json:"provider"`
RateLimit *RateLimitState `json:"rate_limit,omitempty"`
QuietHours *QuietHoursState `json:"quiet_hours,omitempty"`
Limits *LimitsState `json:"limits,omitempty"`
// MCP reports that the daemon offers the MCP endpoint (always true; the
// field exists for an additive compat view of clients/browsers).
MCP bool `json:"mcp"`
// Cors is the configured CORS allow list of POST /speak (empty [] =
// door closed). Always present (even when empty), not omitempty.
Cors []string `json:"cors"`
}
// RateLimitState is the rate-limit state in GET /status.
type RateLimitState struct {
// Interval is the configured minimum spacing as a duration string
// ("30s"; "0s" = unlimited).
Interval string `json:"interval"`
// AllowHighBurst reports whether priority=high may violate it.
AllowHighBurst bool `json:"allow_high_burst"`
// NextSlot is the next free announcement slot as an RFC3339 timestamp;
// null when playback would be allowed immediately (or unlimited).
NextSlot *string `json:"next_slot"`
}
// LimitsState holds the length and age laws in GET /status.
type LimitsState struct {
// MaxText is the upper bound of the announcement length in characters
// (0 = unlimited).
MaxText int `json:"max_text"`
// MaxAge is the longest wait in the queue as a duration string ("2m0s";
// "0s" = unlimited).
MaxAge string `json:"max_age"`
}
// QuietHoursState is the quiet-hours state in GET /status.
type QuietHoursState struct {
// Window is the configured window ("18:00-08:00"; "" = disabled).
Window string `json:"window"`
// Active reports whether local time is currently inside the window.
Active bool `json:"active"`
// Mode is the configured quiet-hours mode ("discard" | "toast").
Mode string `json:"mode"`
// Until is the window end as an RFC3339 timestamp when active;
// otherwise null.
Until *string `json:"until"`
}
// PendingOrCurrent reports whether the announcement with the ID is still
// waiting or currently playing; the basis of the CLI's --wait polling. If
// it is neither waiting nor playing, it is done (or cancelled); in both
// cases the caller has nothing left to wait for.
func (s StatusResponse) PendingOrCurrent(id string) bool {
if s.Current != nil && s.Current.ID == id {
return true
}
for _, qid := range s.QueueIDs {
if qid == id {
return true
}
}
return false
}
// CancelResponse is the response of POST /cancel.
type CancelResponse struct {
Cancelled int `json:"cancelled"`
}
// MuteResponse is the response of POST /mute and POST /unmute.
type MuteResponse struct {
Muted bool `json:"muted"`
}
// ShutdownResponse is the response of POST /shutdown.
type ShutdownResponse struct {
OK bool `json:"ok"`
}
// CancelItemRequest is the body of POST /cancel-item: the id of the
// announcement to cancel, if it is the one playing.
type CancelItemRequest struct {
ID string `json:"id"`
}
// CancelItemResponse is the answer of POST /cancel-item: whether the id was
// the playing announcement and has been cancelled.
type CancelItemResponse struct {
Cancelled bool `json:"cancelled"`
}