package calendar import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) // GoogleConnConfig ist der verschlüsselt gespeicherte Zustand einer // Google-Kalenderverbindung. type GoogleConnConfig struct { Token *oauth2.Token `json:"token"` CalendarID string `json:"calendar_id"` } // GoogleOAuthConfig baut die OAuth2-Konfiguration. Die FreeBusy-API von Google // liefert ausschließlich busy-Zeiträume – gar keine Termindetails. func GoogleOAuthConfig(clientID, clientSecret, redirectURL string) *oauth2.Config { return &oauth2.Config{ ClientID: clientID, ClientSecret: clientSecret, RedirectURL: redirectURL, Scopes: []string{"https://www.googleapis.com/auth/calendar.readonly"}, Endpoint: google.Endpoint, } } type GoogleProvider struct { conf *oauth2.Config cfg GoogleConnConfig onTokenSaved func(*oauth2.Token) // wird aufgerufen, wenn das Token erneuert wurde } func NewGoogleProvider(conf *oauth2.Config, cfg GoogleConnConfig, onTokenSaved func(*oauth2.Token)) *GoogleProvider { return &GoogleProvider{conf: conf, cfg: cfg, onTokenSaved: onTokenSaved} } type googleFreeBusyRequest struct { TimeMin string `json:"timeMin"` TimeMax string `json:"timeMax"` Items []googleFreeBusyRequestItem `json:"items"` } type googleFreeBusyRequestItem struct { ID string `json:"id"` } type googleFreeBusyResponse struct { Calendars map[string]struct { Busy []struct { Start time.Time `json:"start"` End time.Time `json:"end"` } `json:"busy"` Errors []struct { Reason string `json:"reason"` } `json:"errors"` } `json:"calendars"` } func (p *GoogleProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) { ts := p.conf.TokenSource(ctx, p.cfg.Token) tok, err := ts.Token() if err != nil { return nil, fmt.Errorf("Google-Zugriff ungültig (neu verbinden): %w", err) } if tok.AccessToken != p.cfg.Token.AccessToken { saved := *tok p.cfg.Token = &saved if p.onTokenSaved != nil { p.onTokenSaved(&saved) } } body, err := json.Marshal(googleFreeBusyRequest{ TimeMin: from.UTC().Format(time.RFC3339), TimeMax: to.UTC().Format(time.RFC3339), Items: []googleFreeBusyRequestItem{{ID: p.cfg.CalendarID}}, }) if err != nil { return nil, err } client := oauth2.NewClient(ctx, ts) req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/calendar/v3/freeBusy", bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("Google FreeBusy-Anfrage fehlgeschlagen: %w", err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("Google FreeBusy-Fehler (HTTP %d): %.200s", resp.StatusCode, string(raw)) } var out googleFreeBusyResponse if err := json.Unmarshal(raw, &out); err != nil { return nil, fmt.Errorf("Google FreeBusy-Antwort unlesbar: %w", err) } cal, ok := out.Calendars[p.cfg.CalendarID] if !ok { return nil, fmt.Errorf("Google: Kalender %s nicht in Antwort", p.cfg.CalendarID) } if len(cal.Errors) > 0 { return nil, fmt.Errorf("Google: %s", cal.Errors[0].Reason) } intervals := make([]Interval, 0, len(cal.Busy)) for _, b := range cal.Busy { intervals = append(intervals, Interval{Start: b.Start, End: b.End}) } return intervals, nil } // GooglePrimaryCalendar liefert ID und Namen des Primärkalenders – genutzt // direkt nach dem OAuth-Flow. func GooglePrimaryCalendar(ctx context.Context, client *http.Client) (id, summary string, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/calendar/v3/calendars/primary", nil) if err != nil { return "", "", err } resp, err := client.Do(req) if err != nil { return "", "", err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return "", "", fmt.Errorf("Google: Primärkalender nicht lesbar (HTTP %d): %.200s", resp.StatusCode, string(raw)) } var cal struct { ID string `json:"id"` Summary string `json:"summary"` } if err := json.Unmarshal(raw, &cal); err != nil { return "", "", err } return cal.ID, cal.Summary, nil }