diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 9ce9972..173a456 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -102,12 +102,15 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri log.Printf("voice: calendar events: %v", err) return "не получилось проверить календарь.", true } - values := make([]string, len(events)) + // Provenance travels with each event. A work meeting relayed off a phone + // notification (source ambient:notif, #126) is stored below full confidence + // and gets hedged; a CalDAV read is recited plainly. + entries := make([]router.CalendarEntry, len(events)) for i, e := range events { - values[i] = e.Value + entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0} } var f router.CalendarEventFormatter - return f.Format(values, date), true + return f.FormatEntries(entries, date), true } func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) { diff --git a/cmd/mavweb/ambient.go b/cmd/mavweb/ambient.go new file mode 100644 index 0000000..c6a57af --- /dev/null +++ b/cmd/mavweb/ambient.go @@ -0,0 +1,135 @@ +package main + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "strings" + + "github.com/kami/maven/internal/calendar" + "github.com/kami/maven/internal/ipc" +) + +// POST /api/ambient — the work calendar read (Vikunja #126). +// +// Maven does not hold a work credential. A corp mail or calendar session on the +// homelab ties the box's blast radius to the employer's data, so the work +// calendar is read as a SIGNAL instead: an Android notification-listener on the +// owner's phone posts meeting notifications here over wg/LAN, and the ones that +// clearly describe a meeting become calendar events at source=ambient:notif, +// confidence below 1.0. Mail as a notification signal, not a mailbox. +// +// Off unless configured: no -ambient-token, no route. The token is a shared +// secret because the poster is a phone service, not a browser — WebAuthn has no +// answer for a background Android service. The endpoint is write-only and +// accepts exactly one shape of write; it cannot read anything back out. +// +// 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. + +// ambientMaxBody bounds the request. A notification is two short lines. +const ambientMaxBody = 8 << 10 + +type ambientResp struct { + Stored bool `json:"stored"` + Key string `json:"key,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// handleAmbient ingests one relayed notification. token is the configured +// shared secret; an empty token means the capability is off and the handler is +// never registered, so it is treated as a hard failure here too. +func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + if token == "" { + http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable) + return + } + if !ambientAuthorized(r, token) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if core == nil { + http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable) + return + } + + var n calendar.Notification + body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody)) + if err != nil { + http.Error(w, "read failed", http.StatusBadRequest) + return + } + if err := json.Unmarshal(body, &n); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if n.Posted.IsZero() { + writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"}) + return + } + + ev, ok := calendar.EventFromNotification(n) + if !ok { + // Not an event. 202: the relay did its job, there is just nothing here + // worth remembering, and it must not retry. + writeAmbient(w, http.StatusAccepted, ambientResp{Reason: "no meeting time in notification"}) + return + } + + key := calendar.FactKey(ev) + val := calendar.FactValue(ev) + + // Append-only discipline, same as cmd/mavcaldav: a phone reposts the same + // notification many times, and each repost is the same event. + if prev, err := core.LatestFactBySource(r.Context(), key, calendar.SourceAmbient); err == nil && prev.Value == val { + writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"}) + return + } else if err != nil && !errors.Is(err, ipc.ErrNoFact) { + log.Printf("ambient: read %s: %v", key, err) + http.Error(w, "read failed", http.StatusBadGateway) + return + } + + // kind=env: an observation about the world, never a self-fact — a passive + // signal does not write truth about the owner. Confidence below 1.0 is the + // honest part: this is a notification about a meeting, not a reading of a + // calendar, and the query path hedges when it recites one. + if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{ + Ts: ev.Start, + Kind: "env", + Key: key, + Value: val, + Source: calendar.SourceAmbient, + Confidence: calendar.AmbientConfidence, + }); err != nil { + log.Printf("ambient: write %s: %v", key, err) + http.Error(w, "write failed", http.StatusBadGateway) + return + } + log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package) + writeAmbient(w, http.StatusCreated, ambientResp{Stored: true, Key: key}) +} + +// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token +// header, compared in constant time. +func ambientAuthorized(r *http.Request, token string) bool { + got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer")) + if got == "" { + got = strings.TrimSpace(r.Header.Get("X-Maven-Token")) + } + return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1 +} + +func writeAmbient(w http.ResponseWriter, code int, resp ambientResp) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(resp) +} diff --git a/cmd/mavweb/ambient_test.go b/cmd/mavweb/ambient_test.go new file mode 100644 index 0000000..f6870ac --- /dev/null +++ b/cmd/mavweb/ambient_test.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/calendar" + "github.com/kami/maven/internal/ipc" +) + +const ambientTestToken = "s3cret" + +// ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path +// needs. +type ambientCore struct { + fakeCore + latest map[string]ipc.Fact // "key|source" → fact + readErr error +} + +func (c *ambientCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) { + if c.readErr != nil { + return ipc.Fact{}, c.readErr + } + f, ok := c.latest[key+"|"+source] + if !ok { + return ipc.Fact{}, ipc.ErrNoFact + } + return f, nil +} + +func postAmbient(t *testing.T, core ipc.CoreAPI, token string, n calendar.Notification) (*httptest.ResponseRecorder, ambientResp) { + t.Helper() + body, err := json.Marshal(n) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(string(body))) + req.Header.Set("Authorization", "Bearer "+ambientTestToken) + rr := httptest.NewRecorder() + handleAmbient(rr, req, core, token) + var resp ambientResp + json.Unmarshal(rr.Body.Bytes(), &resp) + return rr, resp +} + +func meetingNotification() calendar.Notification { + return calendar.Notification{ + Package: "com.google.android.gm", + Title: "Планёрка", + Text: "10:00-10:30", + Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC), + } +} + +func TestHandleAmbientStoresMeeting(t *testing.T) { + core := &ambientCore{} + rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification()) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body) + } + if !resp.Stored { + t.Errorf("resp = %+v, want stored", resp) + } + if len(core.writeLog) != 1 { + t.Fatalf("expected 1 fact write, got %d", len(core.writeLog)) + } + got := core.writeLog[0] + if got.Source != calendar.SourceAmbient { + t.Errorf("source = %q, want %q", got.Source, calendar.SourceAmbient) + } + if got.Confidence >= 1.0 { + t.Errorf("confidence = %v — a notification is not a calendar read", got.Confidence) + } + if got.Confidence != calendar.AmbientConfidence { + t.Errorf("confidence = %v, want %v", got.Confidence, calendar.AmbientConfidence) + } + if got.Kind != "env" { + t.Errorf("kind = %q — a passive signal never writes a self-fact", got.Kind) + } + if want := "calendar_event_20260803_"; !strings.HasPrefix(got.Key, want) { + t.Errorf("key = %q, want prefix %q", got.Key, want) + } + if got.Value != "Планёрка @ 10:00-10:30" { + t.Errorf("value = %q", got.Value) + } +} + +// A phone reposts the same notification many times. Each repost is the same +// event, and the append-only log must not fill with duplicates. +func TestHandleAmbientDedupesReposts(t *testing.T) { + core := &ambientCore{} + postAmbient(t, core, ambientTestToken, meetingNotification()) + if len(core.writeLog) != 1 { + t.Fatalf("first post did not write") + } + w := core.writeLog[0] + core.latest = map[string]ipc.Fact{w.Key + "|" + w.Source: {Value: w.Value}} + + rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification()) + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200 for an unchanged repost", rr.Code) + } + if resp.Stored { + t.Error("a repost must not be stored again") + } + if len(core.writeLog) != 1 { + t.Errorf("wrote %d facts, want 1", len(core.writeLog)) + } +} + +// The conservative half: noise stores nothing at all. +func TestHandleAmbientIgnoresNonMeetings(t *testing.T) { + core := &ambientCore{} + rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{ + Package: "com.google.android.gm", + Title: "3 новых письма", + Posted: time.Now(), + }) + if rr.Code != http.StatusAccepted { + t.Errorf("status = %d, want 202 (accepted, nothing to store — the relay must not retry)", rr.Code) + } + if resp.Stored { + t.Error("a notification with no meeting time must store nothing") + } + if len(core.writeLog) != 0 { + t.Fatalf("wrote %d facts for a non-meeting", len(core.writeLog)) + } +} + +func TestHandleAmbientAuth(t *testing.T) { + body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}` + + newReq := func(hdr, val string) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body)) + if hdr != "" { + r.Header.Set(hdr, val) + } + return r + } + + t.Run("no token rejected", func(t *testing.T) { + core := &ambientCore{} + rr := httptest.NewRecorder() + handleAmbient(rr, newReq("", ""), core, ambientTestToken) + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } + if len(core.writeLog) != 0 { + t.Error("an unauthorized post must not write") + } + }) + + t.Run("wrong token rejected", func(t *testing.T) { + rr := httptest.NewRecorder() + handleAmbient(rr, newReq("Authorization", "Bearer nope"), &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) + if rr.Code != http.StatusCreated { + t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body) + } + }) + + t.Run("capability off", func(t *testing.T) { + rr := httptest.NewRecorder() + handleAmbient(rr, newReq("Authorization", "Bearer "+ambientTestToken), &ambientCore{}, "") + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503 when no token is configured", rr.Code) + } + }) + + t.Run("GET rejected", func(t *testing.T) { + rr := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/ambient", nil) + handleAmbient(rr, r, &ambientCore{}, ambientTestToken) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405 — the ingest is write-only", rr.Code) + } + }) +} + +func TestHandleAmbientBadInput(t *testing.T) { + t.Run("bad json", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader("{nope")) + req.Header.Set("X-Maven-Token", ambientTestToken) + rr := httptest.NewRecorder() + handleAmbient(rr, req, &ambientCore{}, ambientTestToken) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } + }) + + t.Run("missing posted_at", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(`{"title":"Планёрка 10:00"}`)) + req.Header.Set("X-Maven-Token", ambientTestToken) + rr := httptest.NewRecorder() + handleAmbient(rr, req, &ambientCore{}, ambientTestToken) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rr.Code) + } + }) + + t.Run("read error surfaces", func(t *testing.T) { + core := &ambientCore{readErr: fmt.Errorf("socket closed")} + rr, _ := postAmbient(t, core, ambientTestToken, meetingNotification()) + if rr.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502", rr.Code) + } + }) +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index a05fba5..3cf712b 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -334,6 +334,10 @@ func main() { 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)") hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)") + // Shared secret for POST /api/ambient, the notification-relay ingest that + // reads the work calendar as a signal instead of holding a work credential + // (see ambient.go). Empty ⇒ the route is not registered at all. + ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)") flag.Parse() var core ipc.CoreAPI @@ -381,6 +385,14 @@ func main() { mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) { handleSignal(w, r, core) }) + // Off unless configured: no token, no route — an unconfigured ingest is not + // a 503 waiting to be probed, it does not exist. + if *ambientToken != "" { + mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) { + handleAmbient(w, r, core, *ambientToken) + }) + log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient") + } mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) { handleDash(w, r, core) }) diff --git a/internal/calendar/ambient.go b/internal/calendar/ambient.go new file mode 100644 index 0000000..bc1272a --- /dev/null +++ b/internal/calendar/ambient.go @@ -0,0 +1,221 @@ +package calendar + +import ( + "strings" + "time" + "unicode" +) + +// Ambient events — the work calendar read (Vikunja #126). +// +// The work calendar is not read by holding a work credential. A corp mail or +// calendar session living on the homelab ties the box's blast radius to the +// employer's data, which is the thing the task exists to refuse. What maven +// reads instead is the SIGNAL: an Android notification-listener on the owner's +// phone relays meeting notifications over wg/LAN, and maven turns the ones that +// clearly describe a meeting into calendar events. +// +// That makes the provenance honest. A notification is evidence about an event, +// not a reading of the calendar, so it is stored under SourceAmbient at +// AmbientConfidence — never indistinguishable from a real CalDAV read, and the +// query path hedges when it recites one. +// +// The parse is deliberately conservative. A notification with no recognisable +// clock reading produces nothing at all: maven is not a guesser-of-truth, and a +// mailbox full of noise turned into invented events is worse than a gap. Mail +// as a notification signal, not a mailbox. + +// Notification — one relayed Android notification. Package is the posting app +// (for the log and for the owner to see where a wrong event came from), Title +// and Text are the notification's two text lines, Posted is when the phone +// showed it. Nothing else off the notification is kept. +type Notification struct { + Package string `json:"package"` + Title string `json:"title"` + Text string `json:"text"` + Posted time.Time `json:"posted_at"` +} + +// 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. +func EventFromNotification(n Notification) (Event, bool) { + if n.Posted.IsZero() { + return Event{}, false + } + line := strings.TrimSpace(n.Title + " " + n.Text) + start, end, ok := parseTimeRange(line) + if !ok { + return Event{}, false + } + summary := notificationSummary(n) + if summary == "" { + return Event{}, false + } + + y, m, d := n.Posted.Date() + loc := n.Posted.Location() + s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc) + var e time.Time + if end != nil { + e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc) + // A range that ends before it starts crossed midnight. + if !e.After(s) { + e = e.AddDate(0, 0, 1) + } + } else { + e = s.Add(DefaultReminderDuration) + } + return Event{Summary: summary, Start: s, End: e}, true +} + +// 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.Trim(s, " \t-–—,;:@|·") + s = strings.Join(strings.Fields(s), " ") + if hasLetters(s) { + return s + } + } + return "" +} + +type clock struct{ hour, min int } + +// parseTimeRange finds the first clock reading in s, and a second one if the +// text spells a range. Accepted separators between hours and minutes are ":" +// and "."; between the two ends of a range, "-", "–", "—" or "до". +// +// Bare hours ("в 14") are NOT accepted. Loose digits in a notification are far +// more often a count, a date or an unread badge than a meeting time, and an +// invented event is worse than no event. +func parseTimeRange(s string) (start clock, end *clock, ok bool) { + first, _, firstEnd, ok := nextClock(s, 0) + if !ok { + return clock{}, nil, false + } + sep := strings.TrimLeft(s[firstEnd:], " \t") + for _, p := range []string{"-", "–", "—", "до "} { + if !strings.HasPrefix(sep, p) { + continue + } + if second, _, _, ok2 := nextClock(strings.TrimPrefix(sep, p), 0); ok2 { + return first, &second, true + } + break + } + return first, nil, true +} + +// nextClock scans s from byte offset `from` for the first HH:MM (or HH.MM) and +// returns it with the byte range it occupied. Digits and separators are ASCII, +// so byte offsets are safe over Cyrillic text. +func nextClock(s string, from int) (c clock, start, end int, ok bool) { + for i := from; i < len(s); i++ { + if !isDigit(s[i]) { + continue + } + j := i + for j < len(s) && isDigit(s[j]) { + j++ + } + // A run longer than two digits is a year, an id or an unread count. + if j-i > 2 { + i = j + continue + } + if j >= len(s) || (s[j] != ':' && s[j] != '.') { + i = j + continue + } + k := j + 1 + for k < len(s) && isDigit(s[k]) { + k++ + } + if k-(j+1) != 2 { + i = j + continue + } + // Reject a group that is a link in a longer dotted or colon chain: + // "2026.08.15" would otherwise offer "08.15" as 08:15, and a deadline + // date invented as a meeting time is exactly the wrong kind of guess. + // A trailing ":ss" is fine — that is a time with seconds. + if i > 0 && (s[i-1] == '.' || s[i-1] == ':' || isDigit(s[i-1])) { + i = k + continue + } + if k < len(s) && s[k] == '.' && k+1 < len(s) && isDigit(s[k+1]) { + i = k + continue + } + hour, min := atoi(s[i:j]), atoi(s[j+1:k]) + if hour > 23 || min > 59 { + i = k + continue + } + return clock{hour, min}, i, k, true + } + return clock{}, 0, 0, false +} + +func isDigit(b byte) bool { return b >= '0' && b <= '9' } + +func atoi(s string) int { + n := 0 + for i := 0; i < len(s); i++ { + n = n*10 + int(s[i]-'0') + } + return n +} + +// stripClock removes every clock reading from a summary candidate, along with +// the preposition or separator that introduced it. +func stripClock(s string) string { + for { + _, start, end, ok := nextClock(s, 0) + if !ok { + return s + } + head := trimTrailingPreposition(strings.TrimRight(s[:start], "0123456789:.-–— \t")) + s = strings.TrimSpace(strings.TrimSpace(head) + " " + strings.TrimSpace(s[end:])) + } +} + +// trimTrailingPreposition drops the word that introduced a clock reading, so +// "Встреча в 14:00" becomes "Встреча" and "с 11:30 до 12:15 Созвон" does not +// keep a dangling "с". It repeats, because a range has two of them. +func trimTrailingPreposition(s string) string { + preps := []string{"в", "с", "до", "от", "at", "from", "to"} + for again := true; again; { + again = false + s = strings.TrimRight(s, " \t") + for _, p := range preps { + if s == p { + return "" + } + if strings.HasSuffix(s, " "+p) { + s = s[:len(s)-len(p)-1] + again = true + break + } + } + } + return s +} + +func hasLetters(s string) bool { + for _, r := range s { + if unicode.IsLetter(r) { + return true + } + } + return false +} diff --git a/internal/calendar/ambient_test.go b/internal/calendar/ambient_test.go new file mode 100644 index 0000000..1dd4577 --- /dev/null +++ b/internal/calendar/ambient_test.go @@ -0,0 +1,148 @@ +package calendar + +import ( + "testing" + "time" +) + +func TestEventFromNotification(t *testing.T) { + posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600)) + + tests := []struct { + name string + title, text string + wantOK bool + wantSummary string + wantStart string // "15:04" + wantEnd string + }{ + { + name: "range in the body", + title: "Планёрка", + text: "10:00-10:30", + wantOK: true, + wantSummary: "Планёрка", + wantStart: "10:00", wantEnd: "10:30", + }, + { + name: "russian preposition and single time", + title: "Встреча с подрядчиком в 14:00", + wantOK: true, + wantSummary: "Встреча с подрядчиком", + wantStart: "14:00", wantEnd: "14:30", + }, + { + name: "en dash range", + title: "Sprint review", + text: "Today 16:00 – 17:00, Meet", + wantOK: true, + wantSummary: "Sprint review", + wantStart: "16:00", wantEnd: "17:00", + }, + { + name: "до as a range separator", + title: "Созвон", + text: "с 11:30 до 12:15", + wantOK: true, + wantSummary: "Созвон", + wantStart: "11:30", wantEnd: "12:15", + }, + { + name: "dotted clock", + title: "Обед 13.00", + wantOK: true, + wantSummary: "Обед", + wantStart: "13:00", wantEnd: "13:30", + }, + { + name: "range crossing midnight", + title: "Ночной релиз", + text: "23:30-00:30", + wantOK: true, + wantSummary: "Ночной релиз", + wantStart: "23:30", wantEnd: "00:30", + }, + // The conservative half: no clock reading, no event. + {name: "no time at all", title: "3 новых письма", wantOK: false}, + {name: "bare hour is not a time", title: "Планёрка в 14", wantOK: false}, + {name: "unread count", title: "Входящие", text: "12 непрочитанных", wantOK: false}, + {name: "a date is not a clock", title: "Отчёт", text: "срок 2026.08.15", wantOK: false}, + {name: "time but nothing named", title: "10:00-10:30", wantOK: false}, + {name: "impossible clock", title: "Смена 99:99", wantOK: false}, + {name: "empty", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev, ok := EventFromNotification(Notification{ + Package: "com.google.android.gm", + Title: tt.title, + Text: tt.text, + Posted: posted, + }) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev) + } + if !ok { + return + } + if ev.Summary != tt.wantSummary { + t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary) + } + if got := ev.Start.Format("15:04"); got != tt.wantStart { + t.Errorf("start = %s, want %s", got, tt.wantStart) + } + if got := ev.End.Format("15:04"); got != tt.wantEnd { + t.Errorf("end = %s, want %s", got, tt.wantEnd) + } + if !ev.End.After(ev.Start) { + t.Errorf("end %v must be after start %v", ev.End, ev.Start) + } + // The event lands on the day the phone showed it, in the phone's + // location — not shifted into UTC. + if ev.Start.Location() != posted.Location() { + t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location()) + } + if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 { + t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d) + } + }) + } +} + +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") + } +} + +// An ambient event must never be indistinguishable from a calendar read. +func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) { + ev, ok := EventFromNotification(Notification{ + Title: "Планёрка 10:00-10:30", + Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC), + }) + if !ok { + t.Fatal("expected an event") + } + if FactKey(ev) == "" || FactValue(ev) == "" { + t.Fatal("ambient events must use the shared fact encoding") + } + if AmbientConfidence >= 1.0 { + t.Fatal("ambient confidence must be below a calendar read's") + } +} + +func TestStripClock(t *testing.T) { + tests := []struct{ in, want string }{ + {"Встреча в 14:00", "Встреча"}, + {"Планёрка 10:00-10:30", "Планёрка"}, + {"с 11:30 до 12:15 Созвон", "Созвон"}, + {"Ничего", "Ничего"}, + } + for _, tt := range tests { + if got := stripClock(tt.in); got != tt.want { + t.Errorf("stripClock(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/internal/router/calendar.go b/internal/router/calendar.go index 72890ea..6188672 100644 --- a/internal/router/calendar.go +++ b/internal/router/calendar.go @@ -9,11 +9,41 @@ import ( // CalendarEventFormatter formats calendar events into a Russian reply string. type CalendarEventFormatter struct{} -// Format returns a Russian reply for the given calendar events on the given date. -func (CalendarEventFormatter) Format(events []string, date time.Time) string { +// CalendarEntry — one event to recite. Uncertain marks an event maven did not +// read off a calendar server: the work calendar arrives as relayed phone +// notifications (Vikunja #126), stored below full confidence, and she says so +// rather than reciting a guess as fact. +type CalendarEntry struct { + Text string + Uncertain bool +} + +// Format returns a Russian reply for the given calendar events on the given +// date. Every event is treated as certain — use FormatEntries when provenance +// differs between them. +func (f CalendarEventFormatter) Format(events []string, date time.Time) string { + entries := make([]CalendarEntry, len(events)) + for i, e := range events { + entries[i] = CalendarEntry{Text: e} + } + return f.FormatEntries(entries, date) +} + +// FormatEntries returns a Russian reply, hedging the entries maven is not sure +// about. "похоже" and not "возможно": the notification did arrive, what is +// uncertain is whether it describes the meeting correctly. +func (CalendarEventFormatter) FormatEntries(entries []CalendarEntry, date time.Time) string { dateStr := date.Format("02.01.2006") - if len(events) == 0 { + if len(entries) == 0 { return fmt.Sprintf("на %s ничего нет.", dateStr) } - return fmt.Sprintf("на %s: %s", dateStr, strings.Join(events, "; ")) + parts := make([]string, len(entries)) + for i, e := range entries { + if e.Uncertain { + parts[i] = "похоже, " + e.Text + continue + } + parts[i] = e.Text + } + return fmt.Sprintf("на %s: %s", dateStr, strings.Join(parts, "; ")) } diff --git a/internal/router/calendar_test.go b/internal/router/calendar_test.go index ba5867f..b955560 100644 --- a/internal/router/calendar_test.go +++ b/internal/router/calendar_test.go @@ -24,3 +24,24 @@ func TestCalendarEventFormatter(t *testing.T) { t.Errorf("multiple: got %q", got) } } + +func TestCalendarEventFormatterHedgesUncertainEntries(t *testing.T) { + f := CalendarEventFormatter{} + date := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC) + + // An event relayed off a phone notification is not a calendar read, and she + // says so instead of reciting a guess as fact. + got := f.FormatEntries([]CalendarEntry{ + {Text: "Standup @ 10:00-10:30"}, + {Text: "Планёрка @ 14:00-14:30", Uncertain: true}, + }, date) + want := "на 06.07.2026: Standup @ 10:00-10:30; похоже, Планёрка @ 14:00-14:30" + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } + + // Format is FormatEntries with everything certain. + if got := f.FormatEntries(nil, date); got != "на 06.07.2026 ничего нет." { + t.Errorf("empty: got %q", got) + } +} diff --git a/internal/store/facts.go b/internal/store/facts.go index 5388eb2..d6590bf 100644 --- a/internal/store/facts.go +++ b/internal/store/facts.go @@ -6,7 +6,10 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" + + "github.com/kami/maven/internal/calendar" ) // WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for @@ -79,17 +82,29 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) { return out, rows.Err() } -// CalendarEvents returns caldav facts whose key date falls within [from, to). +// CalendarEvents returns calendar facts whose key date falls within [from, to). // Calendar event keys have the format calendar_event_YYYYMMDD_. +// +// Every calendar source is included, not just the personal CalDAV poll: the work +// calendar arrives as ambient:notif notifications (Vikunja #126) and belongs in +// the same answer. The source stays on each Fact, along with its confidence, so +// the caller can hedge a reading it did not get from a calendar server — +// filtering by source here would have thrown that judgement away. func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { - prefixFrom := fmt.Sprintf("calendar_event_%s", from.Format("20060102")) - prefixTo := fmt.Sprintf("calendar_event_%s", to.Format("20060102")) + prefixFrom := calendar.KeyPrefixForDay(from) + prefixTo := calendar.KeyPrefixForDay(to) + sources := calendar.Sources() + args := make([]any, 0, len(sources)+2) + for _, src := range sources { + args = append(args, src) + } + args = append(args, prefixFrom, prefixTo) rows, err := s.db.QueryContext(ctx, ` SELECT id, ts, kind, key, value, source, confidence, voids_id FROM facts - WHERE source = 'poll:caldav' + WHERE source IN (`+placeholders(len(sources))+`) AND key >= ? AND key < ? - ORDER BY key`, prefixFrom, prefixTo) + ORDER BY key`, args...) if err != nil { return nil, fmt.Errorf("calendar events: %w", err) } @@ -254,3 +269,8 @@ func scanFact(r rowScanner) (Fact, error) { f.VoidsID = voids return f, nil } + +// placeholders renders n comma-separated SQL bind markers. +func placeholders(n int) string { + return strings.TrimSuffix(strings.Repeat("?,", n), ",") +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 3e915b2..3a46f99 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "testing" "time" + + "github.com/kami/maven/internal/calendar" ) func newTestStore(t *testing.T) *Store { @@ -378,3 +380,47 @@ func TestCalendarEvents(t *testing.T) { t.Fatalf("expected 0 events on July 8, got %d", len(events)) } } + +// The work calendar arrives as relayed phone notifications, not a CalDAV read +// (Vikunja #126). Those events belong in the same day's answer, and their +// provenance has to survive the query so the caller can hedge them. +func TestCalendarEventsIncludesAmbientSource(t *testing.T) { + store := newTestStore(t) + defer store.Close() + + ctx := context.Background() + day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC) + + store.WriteFact(ctx, day.Add(10*time.Hour), KindEnv, "calendar_event_20260803_Aaa-personal", + `"Aaa personal @ 10:00-10:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{}) + store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Bbb-work", + `"Bbb work @ 14:00-14:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{}) + // A fact that merely looks like one must still be excluded by source. + store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, "calendar_event_20260803_Ccc-forged", + `"Ccc forged @ 16:00-16:30"`, "tap:voice", 1.0, sql.NullInt64{}) + + events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1)) + if err != nil { + t.Fatalf("CalendarEvents: %v", err) + } + if len(events) != 2 { + t.Fatalf("got %d events, want the personal and the ambient one: %+v", len(events), events) + } + bySource := map[string]Fact{} + for _, e := range events { + bySource[e.Source] = e + } + if _, ok := bySource[calendar.SourcePersonal]; !ok { + t.Error("the personal CalDAV event is missing") + } + amb, ok := bySource[calendar.SourceAmbient] + if !ok { + t.Fatal("the ambient work event is missing") + } + if amb.Confidence >= 1.0 { + t.Errorf("ambient confidence = %v, must stay below a calendar read's", amb.Confidence) + } + if _, ok := bySource["tap:voice"]; ok { + t.Error("a non-calendar source must not be read as a calendar event") + } +}