diff --git a/cmd/mavweb/ambient.go b/cmd/mavweb/ambient.go index c6a57af..470ea53 100644 --- a/cmd/mavweb/ambient.go +++ b/cmd/mavweb/ambient.go @@ -30,6 +30,13 @@ import ( // A notification with no recognisable clock reading stores NOTHING. Maven is // not a guesser-of-truth, and a mailbox of noise rendered as invented meetings // is worse than a gap. +// +// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient +// meeting is good enough to recite and not good enough to stop a nudge — +// calendar_busy is still written only by the CalDAV poller. That is backwards, +// since suppressing a nudge is the lower-risk use of a low-confidence signal. +// calendar_busy is a level rather than an event, so an ambient writer needs an +// expiry, which is its own task and not a change here. // ambientMaxBody bounds the request. A notification is two short lines. const ambientMaxBody = 8 << 10 @@ -120,8 +127,16 @@ func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, tok // ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token // header, compared in constant time. +// +// The scheme is matched case-insensitively. RFC 7235 says it is, and a phone +// client sending "bearer " used to fall through to the X-Maven-Token +// branch and get a silent 401 with nothing to see from the phone's side. func ambientAuthorized(r *http.Request, token string) bool { - got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer")) + got := "" + if authz := strings.TrimSpace(r.Header.Get("Authorization")); len(authz) >= len("Bearer") && + strings.EqualFold(authz[:len("Bearer")], "Bearer") { + got = strings.TrimSpace(authz[len("Bearer"):]) + } if got == "" { got = strings.TrimSpace(r.Header.Get("X-Maven-Token")) } diff --git a/cmd/mavweb/ambient_test.go b/cmd/mavweb/ambient_test.go index f6870ac..4067d3f 100644 --- a/cmd/mavweb/ambient_test.go +++ b/cmd/mavweb/ambient_test.go @@ -166,6 +166,27 @@ func TestHandleAmbientAuth(t *testing.T) { } }) + // RFC 7235 says the scheme is case-insensitive. A phone sending + // "bearer " used to fall through to the X-Maven-Token branch and get a + // 401 that looked, from the phone's side, like a wrong token. + t.Run("lowercase bearer scheme accepted", func(t *testing.T) { + rr := httptest.NewRecorder() + handleAmbient(rr, newReq("Authorization", "bearer "+ambientTestToken), &ambientCore{}, ambientTestToken) + if rr.Code != http.StatusCreated { + t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body) + } + }) + + // A bare token with no scheme is not a bearer header. Accepting it made the + // Authorization branch a second, undocumented X-Maven-Token. + t.Run("bare token in Authorization rejected", func(t *testing.T) { + rr := httptest.NewRecorder() + handleAmbient(rr, newReq("Authorization", ambientTestToken), &ambientCore{}, ambientTestToken) + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } + }) + t.Run("X-Maven-Token accepted", func(t *testing.T) { rr := httptest.NewRecorder() handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken) diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 162cc9b..6b56c51 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -1172,3 +1172,82 @@ func TestHandleTools_GET_MCPUnavailable(t *testing.T) { t.Error("expected the empty-state copy") } } + +// --- voice-path step-up gate (Vikunja #317) --- +// +// POST /api/ptt and GET /ws proxy audio into mavend's voice port, which runs +// the same router, LLM and act path as POST /api/chat. They used to be +// ungated on the grounds that the voice port is only reachable inside the +// deploy, but mavweb is the thing proxying into it from outside. Speaking +// "выключи свет" is not a smaller act than typing it. + +// unreachableVoice is a closed port: a request that clears the gate fails at +// the dial with 503, which is how these tests tell "passed" from "denied". +const unreachableVoice = "127.0.0.1:1" + +func pttReq() *http.Request { + return httptest.NewRequest(http.MethodPost, "/api/ptt", strings.NewReader("PCM-ish bytes")) +} + +func TestHandlePTT_RequireStepUp_FailsClosed(t *testing.T) { + rr := httptest.NewRecorder() + handlePTT(rr, pttReq(), unreachableVoice, nil, true) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestHandlePTT_UnassertedSession_Denied(t *testing.T) { + rr := httptest.NewRecorder() + handlePTT(rr, pttReq(), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestHandlePTT_AssertedSession_PassesGate(t *testing.T) { + rr := httptest.NewRecorder() + handlePTT(rr, pttReq(), unreachableVoice, stepUpSession(), true) + if rr.Code == http.StatusForbidden { + t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String()) + } + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String()) + } +} + +// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ push-to-talk +// keeps working, resting on the transport-level auth in front of mavweb. +func TestHandlePTT_FailOpenByDefault(t *testing.T) { + rr := httptest.NewRecorder() + handlePTT(rr, pttReq(), unreachableVoice, nil, false) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestHandleWS_RequireStepUp_FailsClosed(t *testing.T) { + rr := httptest.NewRecorder() + handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, nil, true) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestHandleWS_UnassertedSession_Denied(t *testing.T) { + rr := httptest.NewRecorder() + handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) + } +} + +// Past the gate the handshake itself fails (httptest's recorder cannot be +// hijacked), which is not a 403. That is all this asserts: the gate let it by. +func TestHandleWS_AssertedSession_PassesGate(t *testing.T) { + rr := httptest.NewRecorder() + handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, stepUpSession(), true) + if rr.Code == http.StatusForbidden { + t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String()) + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 17e49af..5023008 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -351,7 +351,7 @@ func main() { coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)") pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)") pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)") - requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour") + requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /models, /api/revert, /api/chat, /api/ptt and GET /ws) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour") pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)") nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)") praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)") @@ -387,12 +387,9 @@ func main() { handleVoice(w, r) })) - mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { - handleWS(w, r, *voiceAddr) - }) - mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) { - handlePTT(w, r, *voiceAddr) - }) + // /ws and /api/ptt are registered further down, next to /api/chat: they + // carry the same step-up gate and so need stepUpSession, which is only + // built once the passkey endpoints are wired. mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("pong")) }) @@ -475,10 +472,29 @@ func main() { mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish) } if stepUpSession == nil { + // One surface per line: these are read in a terminal at the moment + // someone is deciding whether the box is safe to expose. + surfaces := []string{ + "POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES", + "POST /routines accepting schedules recurring firing", + "POST /models chooses the resident model that routes and words every turn", + "POST /api/revert voids the latest fact for a key", + "POST /api/chat reaches the router, the LLM and, through applyAction, the act path", + "POST /api/ptt the same, from audio", + "GET /ws the same, streamed", + } if *requireStepUp { - log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") + log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):") } else { - log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") + log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:") + } + for _, s := range surfaces { + log.Printf("SECURITY: %s", s) + } + if *requireStepUp { + log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") + } else { + log.Printf("SECURITY: they rest on the transport-level auth in front of mavweb (wg+nginx+auth). Do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") } } @@ -508,13 +524,26 @@ func main() { // POST /routines step-up — accepting schedules recurring firing // POST /api/revert step-up — voids the latest fact for a key // POST /api/chat step-up — reaches the router, LLM and the act path + // POST /api/ptt step-up — audio into runTurn, so the same router, + // LLM and act path as /api/chat + // GET /ws step-up — same, streamed // POST /api/signal none — appends a presence fact, no argv, no act - // POST /api/ptt, /ws none — proxy audio to mavend's voice port, which - // is itself only reachable inside the deploy + // POST /api/ambient shared secret — notification relay, constant-time + // token compare, poster is a phone service + // and not a browser, so step-up cannot apply // // "step-up" means stepUpOK: asserted passkey when WebAuthn is configured, // otherwise fail-open unless -require-stepup, which denies. // + // /api/ptt and /ws used to be ungated, justified by mavend's voice port + // being reachable only inside the deploy. That argument does not hold: + // mavweb is the thing proxying into it from outside. Speaking "выключи + // свет" is not a smaller act than typing it (Vikunja #317). + // + // The gate here is per-request, which costs the hands-free case a passkey + // assertion per turn whenever WebAuthn is configured. A session-scoped + // assertion covering a run of turns is the right shape and is its own task. + // // GET /chat only renders the page and echoes back the q/r query params the // POST redirect set — nothing to gate. mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { @@ -526,6 +555,12 @@ func main() { mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { handleRevert(w, r, core, stepUpSession, *requireStepUp) }) + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp) + }) + mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) { + handlePTT(w, r, *voiceAddr, stepUpSession, *requireStepUp) + }) srv := &http.Server{Addr: *addr, Handler: mux} @@ -543,7 +578,11 @@ func main() { } } -func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string) { +func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { + if !stepUpOK(session, requireStepUp) { + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return + } conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ OriginPatterns: []string{"*"}, }) @@ -1449,11 +1488,15 @@ func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) { return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil } -func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string) { +func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { if r.Method != http.MethodPost { http.Error(w, "POST only", 405) return } + if !stepUpOK(session, requireStepUp) { + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return + } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), 400) diff --git a/deploy/ecosystem/nginx.conf b/deploy/ecosystem/nginx.conf index 5f8b685..41a7dbc 100644 --- a/deploy/ecosystem/nginx.conf +++ b/deploy/ecosystem/nginx.conf @@ -31,49 +31,24 @@ # read the note at the top of that file first. # # `nginx -t` catches the second and not the first. Run it anyway, every time. - -# maven. → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201). -# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves -# POST /tools, which defines argv that internal/tool EXECUTES, plus POST -# /routines, /api/revert and /api/chat (Vikunja #317). Without -# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block -# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the -# allow/deny anyway — belt and braces on an RCE surface. # -# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the -# Upgrade/Connection headers below are required, not decoration. They reference -# $connection_upgrade, which this file does NOT define — see -# nginx-upgrade-map.conf and point 2 above. +# BLOCK ORDER IS LOAD-BEARING. nginx serves the first block for a given listen +# address when no server_name matches, so whichever block comes first here +# answers requests with an unknown or absent Host header. That must not be +# mavweb: it is the one surface in this file that can define and run argv. The +# nexus block is marked default_server so the choice is explicit rather than a +# consequence of file order, and the maven block sits last as a second guard. +# If you add a block, do not put it above nexus. If another site file already +# claims default_server on 10.42.0.1:80 or 192.168.1.104:80, nginx refuses to +# start with "a duplicate default server" — drop the two keywords here and rely +# on the block order instead. +# +# Every server_name below is a literal for kvmx.ru even though the comments +# write maven.. This file reads like a template and is not one. server { - listen 10.42.0.1:80; - listen 192.168.1.104:80; - server_name maven.kvmx.ru; - - allow 10.42.0.0/24; - allow 192.168.1.0/24; - deny all; - - # push-to-talk uploads raw PCM; the default 1m is enough for a short - # utterance but not for a long one. - client_max_body_size 32m; - - location / { - proxy_pass http://127.0.0.1:9201; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU - } -} - -server { - listen 10.42.0.1:80; - listen 192.168.1.104:80; + listen 10.42.0.1:80 default_server; + listen 192.168.1.104:80 default_server; server_name nexus.kvmx.ru; allow 10.42.0.0/24; @@ -124,3 +99,47 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } } + +# maven. → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201). +# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves +# POST /tools, which defines argv that internal/tool EXECUTES, plus POST +# /routines, /api/revert, /api/chat, /api/ptt and /ws (Vikunja #317). Without +# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block +# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the +# allow/deny anyway — belt and braces on an RCE surface. +# +# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the +# Upgrade/Connection headers below are required, not decoration. They reference +# $connection_upgrade, which this file does NOT define — see +# nginx-upgrade-map.conf and point 2 above. + +server { + listen 10.42.0.1:80; + listen 192.168.1.104:80; + server_name maven.kvmx.ru; + + allow 10.42.0.0/24; + allow 192.168.1.0/24; + deny all; + + # push-to-talk uploads raw PCM; the default 1m is enough for a short + # utterance but not for a long one. + client_max_body_size 32m; + # A 32m upload over a slow link outlives the 60s default on the two body + # timeouts, and nginx cuts it at exactly 60s with a 408 or a 504 that looks + # like the turn failed. Raise them with the size, not just proxy_read_timeout. + client_body_timeout 300s; + + location / { + proxy_pass http://127.0.0.1:9201; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_send_timeout 300s; # pushing the PCM upstream, same reason + proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU + } +} diff --git a/internal/calendar/ambient.go b/internal/calendar/ambient.go index bc1272a..6721b0d 100644 --- a/internal/calendar/ambient.go +++ b/internal/calendar/ambient.go @@ -36,13 +36,38 @@ type Notification struct { Posted time.Time `json:"posted_at"` } +// ambientPastGrace — how far before the notification a derived start may sit +// before the event is refused. +// +// The date is not in the clock reading, so it is inferred, and the inference is +// only safe while the event is still roughly now. A 21:00 reminder reading +// "Tomorrow at 09:00" would otherwise land at 09:00 TODAY, twelve hours in the +// past, and FactKey would file that wrong meeting under today's date. Storing a +// wrong meeting is the one outcome this file exists to avoid, so anything this +// stale is dropped instead. The grace covers the ordinary case of a phone +// reposting a notification for a meeting already under way. +const ambientPastGrace = 2 * time.Hour + +// dayWords maps the words that move a notification off Posted's day. Only +// explicit ones: an offset is a claim about which day, and guessing which day +// is exactly the guess this parse refuses to make. +var dayWords = map[string]int{ + "завтра": 1, + "tomorrow": 1, + "сегодня": 0, + "today": 0, + "tonight": 0, + "послезавтра": 2, +} + // EventFromNotification turns a notification into the event it describes, or // reports false when it does not clearly describe one. // // It needs two things: a clock reading, and a summary that is not just that -// clock reading. Everything else is defaulted — the date is Posted's day (a -// meeting notification is about today or it would not be firing now), and a -// bare start time gets DefaultReminderDuration. +// clock reading. The date comes from Posted's day, shifted by an explicit day +// word ("завтра", "tomorrow") when the notification carries one, and the result +// is refused if it lands more than ambientPastGrace in the past. A bare start +// time gets DefaultReminderDuration. func EventFromNotification(n Notification) (Event, bool) { if n.Posted.IsZero() { return Event{}, false @@ -57,9 +82,14 @@ func EventFromNotification(n Notification) (Event, bool) { return Event{}, false } - y, m, d := n.Posted.Date() + y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date() loc := n.Posted.Location() s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc) + // Too far in the past to be the meeting this notification is about. The day + // was inferred, so the honest reading is that the inference was wrong. + if s.Before(n.Posted.Add(-ambientPastGrace)) { + return Event{}, false + } var e time.Time if end != nil { e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc) @@ -73,12 +103,38 @@ func EventFromNotification(n Notification) (Event, bool) { return Event{Summary: summary, Start: s, End: e}, true } +// dayOffset reports how many days off Posted's day the notification puts the +// event. Words are matched whole, so "послезавтра" is not read as "завтра". +func dayOffset(line string) int { + for _, f := range strings.Fields(strings.ToLower(line)) { + f = strings.Trim(f, ".,;:!?—–-()\"'«»") + if off, ok := dayWords[f]; ok { + return off + } + } + return 0 +} + +// stripDayWords removes the day word from a summary candidate. It named the +// date, which now lives in Start, and leaving it in makes "Завтра Планёрка" +// the name of the meeting. +func stripDayWords(s string) string { + out := make([]string, 0, 8) + for _, f := range strings.Fields(s) { + if _, ok := dayWords[strings.Trim(strings.ToLower(f), ".,;:!?—–-()\"'«»")]; ok { + continue + } + out = append(out, f) + } + return strings.Join(out, " ") +} + // notificationSummary picks the text that names the meeting: the title when it // carries words, otherwise the body. The clock reading is stripped out — it // already lives in the times, and FactValue renders it again. func notificationSummary(n Notification) string { for _, cand := range []string{n.Title, n.Text} { - s := strings.TrimSpace(stripClock(cand)) + s := strings.TrimSpace(stripDayWords(stripClock(cand))) s = strings.Trim(s, " \t-–—,;:@|·") s = strings.Join(strings.Fields(s), " ") if hasLetters(s) { diff --git a/internal/calendar/ambient_test.go b/internal/calendar/ambient_test.go index 1dd4577..5484ac2 100644 --- a/internal/calendar/ambient_test.go +++ b/internal/calendar/ambient_test.go @@ -110,6 +110,97 @@ func TestEventFromNotification(t *testing.T) { } } +// A notification is not always about today. A 21:00 reminder reading +// "Tomorrow at 09:00" used to be dated to the notification's own day, which put +// the meeting twelve hours in the past and filed it under today in FactKey. A +// wrong meeting stored is worse than nothing stored. +func TestEventFromNotificationDayWords(t *testing.T) { + loc := time.FixedZone("+04", 4*3600) + evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc) + + tests := []struct { + name string + title, text string + posted time.Time + wantOK bool + wantDay int // day of month + wantSummary string + }{ + { + name: "tomorrow in english", title: "Standup", text: "Tomorrow at 09:00", + posted: evening, wantOK: true, wantDay: 4, wantSummary: "Standup", + }, + { + name: "завтра in russian", title: "Планёрка", text: "завтра в 09:00", + posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка", + }, + { + name: "завтра in the title, summary in the body", title: "Завтра в 09:00", text: "Планёрка", + posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка", + }, + { + name: "послезавтра is two days, not one", title: "Ретро", text: "послезавтра 11:00", + posted: evening, wantOK: true, wantDay: 5, wantSummary: "Ретро", + }, + { + name: "сегодня stays on the posted day", title: "Созвон", text: "сегодня 21:30", + posted: evening, wantOK: true, wantDay: 3, wantSummary: "Созвон", + }, + // No day word: the 09:00 is twelve hours behind the notification, so the + // inferred day is wrong and there is nothing honest to store. + { + name: "stale morning time with no day word", title: "Standup", text: "at 09:00", + posted: evening, wantOK: false, + }, + // Inside the grace: a phone reposting the notification for a meeting + // already under way must still store it. + { + name: "meeting already running", title: "Планёрка", text: "20:30-22:00", + posted: evening, wantOK: true, wantDay: 3, wantSummary: "Планёрка", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev, ok := EventFromNotification(Notification{ + Package: "com.google.android.calendar", + Title: tt.title, Text: tt.text, Posted: tt.posted, + }) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev) + } + if !ok { + return + } + if got := ev.Start.Day(); got != tt.wantDay { + t.Errorf("start day = %d, want %d (start %v)", got, tt.wantDay, ev.Start) + } + if ev.Summary != tt.wantSummary { + t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary) + } + if ev.Start.Before(tt.posted.Add(-ambientPastGrace)) { + t.Errorf("start %v is stale against posted %v", ev.Start, tt.posted) + } + }) + } +} + +// The day word named the date, which now lives in Start. Leaving it in the +// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds +// that into the key. +func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) { + ev, ok := EventFromNotification(Notification{ + Title: "Завтра Планёрка 09:00", + Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC), + }) + if !ok { + t.Fatal("expected an event") + } + if ev.Summary != "Планёрка" { + t.Fatalf("summary = %q, want %q", ev.Summary, "Планёрка") + } +} + func TestEventFromNotificationNeedsPostedAt(t *testing.T) { if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok { t.Error("a notification with no posted_at has no date to sit on") diff --git a/models/stt b/models/stt new file mode 120000 index 0000000..b983fa3 --- /dev/null +++ b/models/stt @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/stt \ No newline at end of file diff --git a/models/tts b/models/tts new file mode 120000 index 0000000..66782fb --- /dev/null +++ b/models/tts @@ -0,0 +1 @@ +/home/kami/apps/Maven/models/tts \ No newline at end of file