package main import ( "context" "flag" "fmt" "log" "net/http" "net/url" "os" "os/signal" "slices" "strings" "syscall" "time" "github.com/gen2brain/beeep" ) func main() { ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT) defer can() fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) gcal := fs.Bool("gcal", false, "wait for google cal events") ntfy := fs.String("http", "", "https://squeaky2x3:dYbtypGkHXFtq1E00k2H42SPGUowi@ntfy.home.blapointe.com/alerts-render") if err := fs.Parse(os.Args[1:]); err != nil { panic(err) } alerts, err := alerts(ctx, *gcal, fs.Args()) if err != nil { panic(err) } for alert := range alerts { if ctx.Err() != nil { break } if err := alertAt(ctx, *ntfy, time.Now(), alert, time.Now().Format("15:04")); err != nil && ctx.Err() == nil { panic(err) } if ctx.Err() != nil { break } } if err := ctx.Err(); err != nil { os.Exit(1) } } func alerts(ctx context.Context, gcal bool, args []string) (chan string, error) { if gcal { return alertsGCal(ctx) } duration, err := time.ParseDuration(args[0]) if err != nil { return nil, err } msg := "alerting after " + duration.String() if len(args) > 1 { msg = fmt.Sprintf("%s (%s)", args[1], duration.String()) } return alertsAfter(ctx, duration, msg) } func alertsAfter(ctx context.Context, dur time.Duration, msg string) (chan string, error) { ch := make(chan string) deadline := time.Now().Add(dur) go func() { defer close(ch) var prev string for ctx.Err() == nil && time.Now().Before(deadline) { seconds := int(time.Until(deadline).Seconds()) cur := fmt.Sprintf("%v", time.Duration(seconds)*time.Second) if prev != "" { fmt.Printf("\r%s\r", strings.Repeat(" ", len(prev))) } fmt.Printf("%s", cur) prev = cur select { case <-ctx.Done(): case <-time.After(time.Second): } } if ctx.Err() == nil { fmt.Println(msg) } ch <- msg }() return ch, nil } func alertsGCal(ctx context.Context) (chan string, error) { gcal := NewGCal() if err := gcal.Login(ctx); err != nil { return nil, err } var events []Event refresh := func() error { es, err := gcal.EventsToday(ctx) es = slices.DeleteFunc(es, func(s Event) bool { return time.Now().After(s.Time) }) if !slices.Equal(es, events) { events = es if len(events) > 0 { log.Println("alerting about", events[0].Name, "at", events[0].Time.Format("15:04"), "(", time.Until(events[0].Time), ")") } } return err } if err := refresh(); err != nil { return nil, err } ch := make(chan string) go func() { defer close(ch) c := time.NewTicker(time.Minute * 13) defer c.Stop() for ctx.Err() == nil && len(events) > 0 { select { case <-c.C: if err := refresh(); err != nil { panic(err) } case <-time.After(time.Until(events[0].Time)): select { case <-ctx.Done(): case ch <- events[0].Name: if events[0].Duration > 0 { select { case <-ctx.Done(): case <-time.After(events[0].Duration): select { case <-ctx.Done(): case ch <- "/" + events[0].Name: } } } } events = events[1:] case <-ctx.Done(): } } }() return ch, nil } func alertAt(ctx context.Context, ntfy string, deadline time.Time, title, msg string) error { c := time.NewTicker(time.Second) defer c.Stop() func() { ctx, can := context.WithDeadline(ctx, deadline) defer can() for ctx.Err() == nil { select { case <-c.C: case <-ctx.Done(): return } fmt.Printf("\r%s ", deadline.Sub(time.Now())) } }() fmt.Println() if err := ctx.Err(); err == nil { beeep.Alert(title, msg, "/dev/null") if ntfy != "" { u, _ := url.Parse(ntfy) req, _ := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(fmt.Sprintf("%s: %s", title, msg))) req = req.WithContext(ctx) http.DefaultClient.Do(req) } } return ctx.Err() }