7683a9b32c
The cold-start crash-loop wasn't mavweb-specific — mavpoll and mavcaldav also ipc.Dial + exit on failure, so they crash-looped until core booted too. Moved the retry into ipc.DialWait (capped backoff, bounded) and switched mavweb, mavpoll, mavcaldav to it. mavweb's local dialCoreWithRetry is gone. Test: server appears after DialWait starts → it waits and connects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
315 lines
8.2 KiB
Go
315 lines
8.2 KiB
Go
// 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 ("<summary> @ <start>-<end>") — 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
|
|
}
|