// mavcaldav — the CalDAV poller module. // // Polls a Radicale (or any CalDAV) server for today's events and writes // `facts (kind=env, source=poll:caldav)` through core's IPC socket. // Key-free, restart-free, fail-independent — crashes can't touch the // store key, worst case a stale calendar_busy fact until the next poll. // // Two facts written: // - calendar_busy ("true"/"false") — read by the loop gate to suppress // nudges during meetings // - calendar_event (" @ -") — per-event for query // // Append-only discipline: a fact is written only when its value CHANGED // vs the latest for that key+source. package main import ( "context" "flag" "fmt" "io" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/kami/maven/internal/ipc" ) func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, "mavcaldav:", err) os.Exit(1) } } func run(args []string) error { fs := flag.NewFlagSet("mavcaldav", flag.ContinueOnError) socket := fs.String("socket", "", "core IPC socket path (required)") url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)") user := fs.String("user", "", "CalDAV basic-auth username (required)") pass := fs.String("pass", "", "CalDAV basic-auth password (required)") interval := fs.Duration("interval", 5*time.Minute, "poll cadence") timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout") if err := fs.Parse(args); err != nil { return err } if *socket == "" { return fmt.Errorf("-socket is required") } if *url == "" || *user == "" || *pass == "" { return fmt.Errorf("-url, -user, -pass are required") } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() core, err := ipc.DialWait(*socket, 60*time.Second) if err != nil { return err } defer core.Close() p := &poller{ core: core, http: &http.Client{Timeout: *timeout}, url: strings.TrimRight(*url, "/"), user: *user, pass: *pass, } log.Printf("mavcaldav: polling %s every %s", *url, *interval) p.pollOnce(ctx) // fire immediately t := time.NewTicker(*interval) defer t.Stop() for { select { case <-ctx.Done(): log.Printf("mavcaldav: bye") return nil case <-t.C: p.pollOnce(ctx) } } } type poller struct { core ipc.CoreAPI http *http.Client url string user string pass string } type icalEvent struct { start time.Time end time.Time summary string } func (p *poller) pollOnce(ctx context.Context) { now := time.Now() events, err := p.fetchEvents(ctx, now) if err != nil { log.Printf("mavcaldav: fetch: %v", err) return } busy := false for _, e := range events { if !now.Before(e.start) && now.Before(e.end) { busy = true break } } busyVal := "false" if busy { busyVal = "true" } // Write calendar_busy on change. if err := p.writeIfChanged(ctx, "calendar_busy", "poll:caldav", busyVal, now); err != nil { log.Printf("mavcaldav: write calendar_busy: %v", err) return } // Write per-event facts (one per event, keyed by event summary + start). // This lets the note RAG path answer "what's on my calendar" without // reaching back to Radicale. for _, e := range events { val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04")) eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary)) if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil { log.Printf("mavcaldav: write %s: %v", eventKey, err) } } } // fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response. func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil) if err != nil { return nil, err } req.SetBasicAuth(p.user, p.pass) req.Header.Set("Accept", "text/calendar") resp, err := p.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status) } return parseICal(body, now), nil } // parseICal scans iCal text for VEVENT components. Returns events that overlap // with today (UTC day boundaries) to keep the response manageable. func parseICal(body []byte, now time.Time) []icalEvent { todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) todayEnd := todayStart.AddDate(0, 0, 1) var events []icalEvent text := string(body) for { veventStart := strings.Index(text, "BEGIN:VEVENT") if veventStart < 0 { break } text = text[veventStart+len("BEGIN:VEVENT"):] veventEnd := strings.Index(text, "END:VEVENT") if veventEnd < 0 { break } block := text[:veventEnd] text = text[veventEnd+len("END:VEVENT"):] e := parseVEVENT(block) if e == nil { continue } // Only keep events overlapping today. if e.end.After(todayStart) && e.start.Before(todayEnd) { events = append(events, *e) } } return events } // parseVEVENT extracts start, end, summary from a VEVENT block. // Supports both UTC (DTEND:20260703T100000Z) and local (DTSTART;TZID=...:...) // formats. Returns nil for all-day events (no DTSTART/DTEND time component) or // parse failures. func parseVEVENT(block string) *icalEvent { var e icalEvent lines := strings.Split(block, "\n") for _, line := range lines { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "DTSTART"): if t, ok := parseDT(line); ok { e.start = t } case strings.HasPrefix(line, "DTEND"): if t, ok := parseDT(line); ok { e.end = t } case strings.HasPrefix(line, "SUMMARY"): if idx := strings.Index(line, ":"); idx >= 0 { e.summary = strings.TrimSpace(line[idx+1:]) } } } if e.start.IsZero() || e.end.IsZero() { return nil } return &e } // parseDT parses a DTSTART/DTEND value. Supports: // - UTC: DTEND:20260703T100000Z // - Local: DTSTART;TZID=Europe/Moscow:20260703T130000 // - Value-date (all-day): DTSTART;VALUE=DATE:20260703 (returns zero time) func parseDT(line string) (time.Time, bool) { if strings.Contains(line, "VALUE=DATE:") { return time.Time{}, false // all-day, skip } idx := strings.LastIndex(line, ":") if idx < 0 { return time.Time{}, false } val := line[idx+1:] val = strings.TrimSuffix(val, "Z") // Try UTC first (has Z suffix, or ended in Z before TrimSuffix). if strings.HasSuffix(line, "Z") { t, err := time.Parse("20060102T150405", val) if err != nil { return time.Time{}, false } return t.UTC(), true } // Local time — treat as UTC for simplicity (CalDAV server and poller // run in the same timezone; the gate only needs busy/not-busy accuracy). t, err := time.Parse("20060102T150405", val) if err != nil { return time.Time{}, false } return t.UTC(), true } // safeKey makes an event summary safe to use as a fact key (alphanumeric + dash). func safeKey(s string) string { var b strings.Builder for _, r := range s { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { b.WriteRune(r) } else if r == ' ' || r == '_' { b.WriteRune('-') } } return b.String() } // writeIfChanged writes a fact only when the value differs from the latest. func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error { prev, err := p.core.LatestFactBySource(ctx, key, source) switch { case err == nil && prev.Value == val: return nil // unchanged case err != nil && err != ipc.ErrNoFact && !isNoFact(err): return fmt.Errorf("read %s: %w", key, err) } _, err = p.core.WriteFact(ctx, ipc.WriteFactReq{ Ts: ts, Kind: "env", Key: key, Value: val, Source: source, Confidence: 1.0, }) if err != nil { return fmt.Errorf("write %s: %w", key, err) } log.Printf("mavcaldav: %s=%s (%s)", key, val, source) return nil } // isNoFact unwarps error chains to find ipc.ErrNoFact. func isNoFact(err error) bool { for e := err; e != nil; { if e == ipc.ErrNoFact { return true } u, ok := e.(interface{ Unwrap() error }) if !ok { return false } e = u.Unwrap() } return false }