From 49dfeb879eb5a14711f698629e2c02f3299db44b Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 13:55:20 +0400 Subject: [PATCH 1/3] mavweb: gate the voice path on step-up like the text path POST /api/ptt and /ws were listed as ungated on the grounds that mavend's voice port is only reachable inside the deploy. mavweb is the thing proxying into it from outside, so that argument does not hold. Audio posted to /api/ptt runs the same router, the same LLM and the same applyAction that POST /api/chat was gated on, which means speaking a light-switch act reached the act path while typing it did not. Both now take stepUpOK, so they fail open by default and deny under -require-stepup exactly like the other four. Registration moved down next to /api/chat because the gate needs stepUpSession. The route table records the reason and names the session-scoped assertion the hands-free case wants as a separate task. The SECURITY startup lines are one surface per line now. Found in review of #51. --- cmd/mavweb/handlers_test.go | 79 +++++++++++++++++++++++++++++++++++++ cmd/mavweb/main.go | 69 ++++++++++++++++++++++++++------ models/stt | 1 + models/tts | 1 + 4 files changed, 137 insertions(+), 13 deletions(-) create mode 120000 models/stt create mode 120000 models/tts 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/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 From 0e83ddf3dfe05e9208bd3579fd0c76c640299b33 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 13:56:44 +0400 Subject: [PATCH 2/3] deploy: stop mavweb becoming the default nginx server by file order The maven block was first in nginx.conf, and nginx serves the first block for a listen address when no server_name matches. Those two ports used to default to nexus. After the maven block landed, a request with an unknown or absent Host header reached mavweb instead, which is the one surface in the file that can define and run argv. The ACL still held, so this was not an exposure, but it is the wrong default to acquire by accident. The nexus block is now marked default_server so the choice is explicit, and the maven block moved last as a second guard. Also raises client_body_timeout and proxy_send_timeout to match client_max_body_size 32m, since a slow push-to-talk upload was cut at the 60s default on both while proxy_read_timeout was already 300s. Found in review of #52. --- deploy/ecosystem/nginx.conf | 99 ++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 40 deletions(-) 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 + } +} From 4e4c9170e32019b99a6572b38c7cb8bd64ed5fc5 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 13:59:30 +0400 Subject: [PATCH 3/3] calendar: date an ambient event by its day word, and refuse stale ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventFromNotification took the date from the notification's own day, on the grounds that a meeting notification is about today or it would not be firing. Calendar apps break that. A 21:00 reminder reading "Tomorrow at 09:00" became an event at 09:00 today, twelve hours in the past, and FactKey filed that wrong meeting under today's date. Storing a wrong meeting is the one outcome this parse works to avoid. An explicit day word now moves the date: завтра, tomorrow, послезавтра, сегодня, today, tonight. Matched whole, so послезавтра is not read as завтра, and stripped from the summary so the meeting is not named after the day. Anything still landing more than two hours before the notification is refused, which covers the cases with no day word at all. The grace keeps a repost for a meeting already under way. Also matches the bearer scheme with EqualFold. A phone sending "bearer " fell through to the X-Maven-Token branch and got a 401 that looked like a wrong token. A bare token with no scheme in Authorization is now rejected rather than silently accepted. The route table in mavweb gains its /api/ambient row, and the missing calendar_busy write is recorded as a known gap. Found in review of #57. --- cmd/mavweb/ambient.go | 17 +++++- cmd/mavweb/ambient_test.go | 21 +++++++ internal/calendar/ambient.go | 66 ++++++++++++++++++++-- internal/calendar/ambient_test.go | 91 +++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 6 deletions(-) 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/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")