A grant refused while no calendar was chosen (for example during calendar listing) made a later push return no_calendar instead of google_reconnect_required, sending the household to choose a calendar when only connecting again helps. grantedConnection now decides the precedence for every Calendar call that uses the grant (listing, choosing, pushing): not connected, then a refused grant, then anything else such as no calendar chosen. It holds the existing cleared-token check, moved out of accessToken. Fixes F16-T4-01 for Maestro task b8d8da5a-0450-4571-963e-b3dfead24283. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
7.5 KiB
Go
218 lines
7.5 KiB
Go
package gcal
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"time"
|
|
)
|
|
|
|
// maxEventBody caps how much of an events response is read.
|
|
const maxEventBody = 64 << 10
|
|
|
|
var (
|
|
// ErrNoCalendar means Google Calendar is connected but no calendar is
|
|
// chosen to push to.
|
|
ErrNoCalendar = errors.New("gcal: no calendar is chosen for Push to Calendar")
|
|
// ErrCalendarUnavailable means the chosen calendar was deleted or the
|
|
// connected account can no longer add events to it. Such a calendar
|
|
// cannot be chosen again; the household must choose another.
|
|
ErrCalendarUnavailable = errors.New("gcal: the chosen calendar was deleted or MealQuest can no longer add dinners to it")
|
|
)
|
|
|
|
// Google's rule for a client-chosen event id: base32hex characters
|
|
// (lowercase a-v and 0-9), 5 to 1024 of them.
|
|
var eventIDChars = regexp.MustCompile(`^[a-v0-9]+$`)
|
|
|
|
const (
|
|
minEventID = 5
|
|
maxEventID = 1024
|
|
)
|
|
|
|
// Event is one event to write to the chosen calendar.
|
|
type Event struct {
|
|
// ID is the event's id, chosen by the caller so that writing the same
|
|
// event again updates it; it must satisfy Google's id rule.
|
|
ID string
|
|
Summary string
|
|
Description string
|
|
Start, End time.Time
|
|
// TimeZone is the IANA zone the event is in, such as America/New_York.
|
|
TimeZone string
|
|
}
|
|
|
|
func (e Event) validate() error {
|
|
switch {
|
|
case len(e.ID) < minEventID, len(e.ID) > maxEventID, !eventIDChars.MatchString(e.ID):
|
|
return fmt.Errorf("gcal: event id %q is not 5 to 1024 base32hex characters", e.ID)
|
|
case e.Summary == "":
|
|
return fmt.Errorf("gcal: event %s has no summary", e.ID)
|
|
case !e.End.After(e.Start):
|
|
return fmt.Errorf("gcal: event %s does not end after it starts", e.ID)
|
|
case e.TimeZone == "":
|
|
return fmt.Errorf("gcal: event %s has no time zone", e.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// eventBody is an event as the Calendar API takes it.
|
|
type eventBody struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Summary string `json:"summary"`
|
|
Description string `json:"description"`
|
|
Start eventTime `json:"start"`
|
|
End eventTime `json:"end"`
|
|
}
|
|
|
|
type eventTime struct {
|
|
DateTime string `json:"dateTime"`
|
|
TimeZone string `json:"timeZone"`
|
|
}
|
|
|
|
// PushEvents writes events, in order, to the chosen calendar: each is
|
|
// inserted with its id, and an id Google already has (HTTP 409, which
|
|
// includes an event deleted in Calendar) is updated in place instead, so
|
|
// writing an event again never adds a second one. It stops at the first
|
|
// failure; the events before it stay written, and writing them all again
|
|
// is safe. Nothing connected is ErrNotConnected, a grant already recorded
|
|
// as refused is ErrReconnect whatever calendar is chosen, no calendar
|
|
// chosen is ErrNoCalendar, a chosen calendar that was deleted or is no longer
|
|
// writable is ErrCalendarUnavailable, a grant Google no longer accepts is
|
|
// ErrReconnect (recorded as reconnect required, see recordRefusal), and any
|
|
// other Google failure is a *CalendarError, retryable when Google answered
|
|
// every try with HTTP 429 or 5xx.
|
|
//
|
|
// Once every event is written, PushEvents returns record(), which the
|
|
// caller uses to store what it pushed. record runs under the same hold of
|
|
// the account as the writes, so the chosen calendar cannot change between
|
|
// them; it is not run when a write fails.
|
|
func (s *Service) PushEvents(ctx context.Context, events []Event, record func() error) error {
|
|
if len(events) == 0 {
|
|
return errors.New("gcal: no events to push")
|
|
}
|
|
for _, e := range events {
|
|
if err := e.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.account.Lock()
|
|
defer s.account.Unlock()
|
|
return s.recordRefusal(ctx, s.pushEvents(ctx, events, record))
|
|
}
|
|
|
|
// pushEvents is PushEvents once the events are valid. The caller holds
|
|
// s.account. A refused grant is reported before no calendar chosen
|
|
// (grantedConnection): choosing a calendar cannot fix it, connecting again
|
|
// can.
|
|
func (s *Service) pushEvents(ctx context.Context, events []Event, record func() error) error {
|
|
row, err := s.grantedConnection(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !row.CalendarID.Valid || row.CalendarID.String == "" {
|
|
return ErrNoCalendar
|
|
}
|
|
access, err := s.accessToken(ctx, row)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
base := s.calendarAPI + "/calendars/" + url.PathEscape(row.CalendarID.String) + "/events"
|
|
for _, e := range events {
|
|
if err := s.writeEvent(ctx, access, base, e); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return record()
|
|
}
|
|
|
|
// writeEvent inserts e at base, the chosen calendar's events collection,
|
|
// and updates it when Google already has its id.
|
|
func (s *Service) writeEvent(ctx context.Context, access, base string, e Event) error {
|
|
body, err := json.Marshal(eventBody{
|
|
ID: e.ID,
|
|
Status: "confirmed",
|
|
Summary: e.Summary,
|
|
Description: e.Description,
|
|
Start: eventTime{DateTime: e.Start.Format(time.RFC3339), TimeZone: e.TimeZone},
|
|
End: eventTime{DateTime: e.End.Format(time.RFC3339), TimeZone: e.TimeZone},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("gcal: encode event %s: %w", e.ID, err)
|
|
}
|
|
status, err := s.eventRequest(ctx, access, http.MethodPost, base, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != http.StatusConflict {
|
|
return nil
|
|
}
|
|
// The status "confirmed" in the body restores an event deleted in
|
|
// Calendar, which Google keeps as cancelled under the same id.
|
|
_, err = s.eventRequest(ctx, access, http.MethodPut, base+"/"+url.PathEscape(e.ID), body)
|
|
return err
|
|
}
|
|
|
|
// eventRequest sends an event insert (POST) or update (PUT), retrying a
|
|
// transient failure (send), and returns HTTP 200 or, for an insert, 409;
|
|
// every other answer is an error. An insert Google took but answered with
|
|
// a transient failure is answered 409 when retried, and updated.
|
|
func (s *Service) eventRequest(ctx context.Context, access, method, target string, body []byte) (int, error) {
|
|
what := "event insert"
|
|
if method == http.MethodPut {
|
|
what = "event update"
|
|
}
|
|
status, reply, err := s.send(ctx, what, maxEventBody, func() (*http.Request, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+access)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req, nil
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
switch {
|
|
case status == http.StatusOK:
|
|
return http.StatusOK, nil
|
|
case status == http.StatusConflict && method == http.MethodPost:
|
|
return http.StatusConflict, nil
|
|
case status == http.StatusUnauthorized:
|
|
// The access token was just refreshed from the grant.
|
|
return 0, ErrReconnect
|
|
case status == http.StatusNotFound && method == http.MethodPost,
|
|
status == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
|
|
// An insert into a deleted calendar, or one the account no longer
|
|
// sees, is not found; a write to one it can only read is
|
|
// requiredAccessLevel. An update not found is the event missing
|
|
// from the calendar after Google reported its id taken, which
|
|
// says nothing about the calendar.
|
|
return 0, ErrCalendarUnavailable
|
|
}
|
|
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, status)}
|
|
}
|
|
|
|
// errorReason is the reason of the first error in a Calendar API error
|
|
// body, or "" when there is none. Only the reason is read; the rest of
|
|
// Google's text is never kept.
|
|
func errorReason(body []byte) string {
|
|
var reply struct {
|
|
Error struct {
|
|
Errors []struct {
|
|
Reason string `json:"reason"`
|
|
} `json:"errors"`
|
|
} `json:"error"`
|
|
}
|
|
if json.Unmarshal(body, &reply) != nil || len(reply.Error.Errors) == 0 {
|
|
return ""
|
|
}
|
|
return reply.Error.Errors[0].Reason
|
|
}
|