Merge branch 'fix/g02' into fix/integrated
This commit is contained in:
+16
-1
@@ -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 <tok>" 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"))
|
||||
}
|
||||
|
||||
@@ -166,6 +166,27 @@ func TestHandleAmbientAuth(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// RFC 7235 says the scheme is case-insensitive. A phone sending
|
||||
// "bearer <tok>" 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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+56
-13
@@ -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)
|
||||
|
||||
+59
-40
@@ -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.<domain> → 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.<domain>. 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.<domain> → 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/kami/apps/Maven/models/stt
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/kami/apps/Maven/models/tts
|
||||
Reference in New Issue
Block a user