Files
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

183 lines
5.5 KiB
Go

package main
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webauthn"
)
// POST /api/ptt proxies one turn onto mavend's voice port. It carries the same
// step-up gate as typed chat, because speaking an act is not a smaller act than
// typing one (Vikunja #317). The length-prefixed voice framing is at the bottom.
// maxFrame caps a single voice frame in either direction.
const maxFrame = 64 << 20
// maxPTTAudioBytes is ten minutes of canonical 16 kHz mono int16 PCM. A PTT
// turn should be seconds, but the generous cap preserves long dictation while
// keeping both the HTTP allocation and the base64-expanded voice frame bounded.
// Meeting capture has its own streaming/blob path and does not use this route.
const maxPTTAudioBytes int64 = 10 * 60 * 16000 * 2
// pushToTalk builds the request the HTTP proxy sends. SurfacePCClient records
// that the browser is standing in for the PC client.
func pushToTalk(pcm []byte) voice.Request {
return voice.Request{
ID: uint64(time.Now().UnixNano()),
Method: voice.MethodPushToTalk,
Params: mustMarshal(voice.PushToTalkReq{
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm},
Lang: "mixed",
Surface: voice.SurfacePCClient,
}),
}
}
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
if r.Method != http.MethodPost {
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
"POST only", nil)
return
}
if !stepUpGate(w, r, session, requireStepUp) {
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPTTAudioBytes))
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeProblem(w, r, http.StatusRequestEntityTooLarge, problemPayloadTooLarge,
"audio exceeds the ten-minute PTT limit", err)
return
}
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
"read audio", fmt.Errorf("read PTT audio: %w", err))
return
}
if len(body) < 4 {
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
"audio too short", nil)
return
}
log.Printf("ptt got %d bytes from client", len(body))
var d net.Dialer
tc, err := d.DialContext(r.Context(), "tcp", voiceAddr)
if err != nil {
writeProblem(w, r, http.StatusServiceUnavailable, problemVoiceUnavailable,
"voice unavailable", fmt.Errorf("dial voice service: %w", err))
return
}
defer tc.Close()
req := pushToTalk(body)
if err := writeFrame(tc, &req); err != nil {
writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport,
"voice request failed", fmt.Errorf("write voice request: %w", err))
return
}
for {
resp, push, err := readOneFrame(tc)
if err != nil {
writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport,
"voice response failed", fmt.Errorf("read voice response: %w", err))
return
}
if push != nil {
continue
}
if resp.Error != nil {
writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse,
"voice turn failed", fmt.Errorf("voice RPC error: %s", resp.Error.Message))
return
}
var pttResp voice.PushToTalkResp
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse,
"voice response failed", fmt.Errorf("decode voice response: %w", err))
return
}
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
// PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space
// as "+", which is form encoding, and the client decodes this header
// with decodeURIComponent, which only knows "%20" — so every space in a
// spoken reply reached the on-page log as a plus sign. PathEscape is the
// flavour decodeURIComponent actually reverses, which keeps the encoding
// a property of the header rather than something the client has to know.
w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText))
w.Write(pttResp.ReplyAudio.Bytes)
return
}
}
func writeFrame(w io.Writer, v any) error {
body, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
if len(body) > maxFrame {
return fmt.Errorf("frame too large: %d", len(body))
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
_, err = w.Write(body)
return err
}
func readFrame(r io.Reader, v any) error {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return err
}
n := binary.BigEndian.Uint32(hdr[:])
if n > maxFrame {
return fmt.Errorf("frame too large: %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return err
}
return json.Unmarshal(buf, v)
}
func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) {
var raw struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"r,omitempty"`
Error *voice.RpcError `json:"e,omitempty"`
Kind voice.PushKind `json:"kind,omitempty"`
Params json.RawMessage `json:"p,omitempty"`
}
if err := readFrame(r, &raw); err != nil {
return nil, nil, err
}
if raw.Kind != "" && raw.ID == 0 {
return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil
}
return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
}
func mustMarshal(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}