calendar: date an ambient event by its day word, and refuse stale ones

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 <tok>"
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.
This commit is contained in:
kami
2026-08-01 13:59:30 +04:00
parent 0e83ddf3df
commit 4e4c9170e3
4 changed files with 189 additions and 6 deletions
+16 -1
View File
@@ -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"))
}
+21
View File
@@ -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)
+61 -5
View File
@@ -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) {
+91
View File
@@ -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")