49f089d8a6
Maven does not get 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 this task exists to refuse. What she reads instead is the signal: an Android notification-listener on the phone relays meeting notifications over wg/LAN to POST /api/ambient, and the ones that clearly describe a meeting become calendar events at source=ambient:notif, confidence 0.6. The provenance is the point. A notification is evidence about a meeting, not a reading of a calendar, so it is never indistinguishable from one: it is stored below full confidence, store.CalendarEvents keeps the source and confidence on every row it returns, and the query path hedges — "похоже, Планёрка @ 14:00" for a relayed event, plain text for a CalDAV read. The parse is deliberately conservative (internal/calendar/ambient.go). It needs a real clock reading and a summary that is not just that clock reading; otherwise it stores nothing at all. A bare hour is not a time, an unread count is not a time, and "срок 2026.08.15" does not offer 08:15 as a meeting — loose digits in a notification are far more often a badge or a date, and a mailbox of noise rendered as invented meetings is worse than a gap. The ingest is off unless configured: no -ambient-token, no route registered. The token is a shared secret compared in constant time, because the poster is a background Android service and WebAuthn has no answer for one. The endpoint is write-only, accepts one shape of write, and cannot read anything back out. Reposts of the same notification dedupe against the latest fact for that key+source, the same append-only discipline cmd/mavcaldav follows. Not shipped: the Android relay app itself, which is a separate artifact and a device, not Go in this repo.
222 lines
6.7 KiB
Go
222 lines
6.7 KiB
Go
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
|
||
}
|