calcomblock/client.go
Alex Dunmow 0d56f6adf1 feat(calcom): route Cal.com egress through host OutboundHTTP (ADR 0023)
Under wasm the guest has no network, so every Cal.com v2 call was dying at
"dial tcp: lookup api.cal.com". Thread deps.OutboundHTTP (http.RoundTripper)
from NewCalcomRouter onto CalcomHandler.rt and into NewCalcomClient, building
the client with Transport: rt. A nil rt (native/DESCRIBE/test) falls back to
the default transport, so bundled/httptest paths are unchanged.

Declare allowed_hosts = ["api.cal.com"] (deny-by-default; admin-granted at
install per the Phase 3 consent gate) and bump pluginsdk v0.2.4 -> v0.2.5 for
CoreServices.OutboundHTTP. Bump 2.0.5 -> 2.0.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:57:10 +08:00

609 lines
22 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode"
)
// Cal.com v2 API constants. Each endpoint requires its own `cal-api-version`
// value — historically they have all diverged.
const (
versionSlots = "2024-09-04"
versionEventTypes = "2024-06-14"
versionBookings = "2026-02-25"
versionMe = "2024-06-11"
versionSchedules = "2024-06-11"
)
// calcomBaseURL is the Cal.com v2 API root. Declared as a var (not const) so
// the integration tests in handler_test.go can override it to point at a fake
// httptest.NewServer; in production it is set exactly once at startup and
// never mutated. The handlers call NewCalcomClient(apiKey) inline, which reads
// this value, so a single var swap reroutes every public handler at once.
var calcomBaseURL = "https://api.cal.com/v2"
// CalcomClient is a thin wrapper over Cal.com's v2 REST API. Each method sets
// the version header appropriate for the endpoint it targets.
type CalcomClient struct {
http *http.Client
apiKey string
baseURL string
}
// NewCalcomClient builds a client with the standard 30s HTTP timeout. rt is the
// host-mediated egress transport (deps.OutboundHTTP, ADR 0023) — under wasm the
// guest has no network, so every Cal.com request must ride it. A nil rt falls
// back to http.DefaultTransport, keeping native/DESCRIBE and httptest paths
// working (tests reach their fake server by overriding calcomBaseURL).
func NewCalcomClient(apiKey string, rt http.RoundTripper) *CalcomClient {
return &CalcomClient{
http: &http.Client{Transport: rt, Timeout: 30 * time.Second},
apiKey: apiKey,
baseURL: calcomBaseURL,
}
}
// SlotsResponse mirrors the shape of `GET /v2/slots`:
//
// { "status": "success", "data": { "YYYY-MM-DD": [{ "time": "..." }] } }
type SlotsResponse struct {
Status string `json:"status"`
Data map[string][]SlotEntry `json:"data"`
}
// SlotEntry is a single slot row in the SlotsResponse.Data map.
//
// The slot's timestamp field is cal-api-version dependent. The version this
// client pins — versionSlots = "2024-09-04" — returns {"start": "<RFC3339>"},
// whereas the older 2024-08-13 shape returned {"time": "<RFC3339>"}. Cal.com
// has a long history of diverging response shapes across versions (see the
// version-const note above), so we decode BOTH fields and resolve the
// effective value via When() — keeping the widget working even if the pinned
// version is ever rolled forward or back.
type SlotEntry struct {
Start string `json:"start"`
Time string `json:"time"`
}
// When returns the slot's RFC3339 start timestamp, preferring the 2024-09-04
// `start` field and falling back to the legacy `time` field.
func (s SlotEntry) When() string {
if s.Start != "" {
return s.Start
}
return s.Time
}
// GetSlots returns the slots Cal.com would offer for the given event type
// between [start, end] in the requested timezone. start/end should be RFC3339.
func (c *CalcomClient) GetSlots(ctx context.Context, username, eventTypeSlug, start, end, timeZone string) (SlotsResponse, error) {
q := url.Values{}
q.Set("username", username)
q.Set("eventTypeSlug", eventTypeSlug)
q.Set("start", start)
q.Set("end", end)
if timeZone != "" {
q.Set("timeZone", timeZone)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/slots?"+q.Encode(), nil)
if err != nil {
return SlotsResponse{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("cal-api-version", versionSlots)
resp, err := c.http.Do(req)
if err != nil {
return SlotsResponse{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return SlotsResponse{}, statusError("slots", resp)
}
var out SlotsResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return SlotsResponse{}, fmt.Errorf("decode slots: %w", err)
}
return out, nil
}
// ListEventTypes returns the compact summary list used to populate the editor
// dropdown.
func (c *CalcomClient) ListEventTypes(ctx context.Context, username string) ([]EventTypeSummary, error) {
q := url.Values{}
q.Set("username", username)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/event-types?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("cal-api-version", versionEventTypes)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, statusError("event-types", resp)
}
var wrap struct {
Status string `json:"status"`
Data []EventTypeSummary `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil {
return nil, fmt.Errorf("decode event-types: %w", err)
}
return wrap.Data, nil
}
// MeResponse is the subset of Cal.com's /v2/me response that the plugin uses
// to identify the API key's owner so the block editor doesn't have to ask the
// admin to type their own username.
type MeResponse struct {
Username string `json:"username"`
Email string `json:"email"`
}
// GetMe returns the account identity associated with the configured API key.
// This is the source of truth for "whose Cal.com is this plugin configured
// for", and lets us drop the manual username input from the block editor.
func (c *CalcomClient) GetMe(ctx context.Context) (MeResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/me", nil)
if err != nil {
return MeResponse{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("cal-api-version", versionMe)
resp, err := c.http.Do(req)
if err != nil {
return MeResponse{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return MeResponse{}, statusError("me", resp)
}
var wrap struct {
Status string `json:"status"`
Data MeResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil {
return MeResponse{}, fmt.Errorf("decode me: %w", err)
}
return wrap.Data, nil
}
// ListSchedules returns the availability schedules of the account the API key
// belongs to. Used to resolve the meeting's timezone: an event type either
// pins a schedule (ScheduleID) or uses the account's default (IsDefault).
func (c *CalcomClient) ListSchedules(ctx context.Context) ([]Schedule, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/schedules", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("cal-api-version", versionSchedules)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, statusError("schedules", resp)
}
var wrap struct {
Status string `json:"status"`
Data []Schedule `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil {
return nil, fmt.Errorf("decode schedules: %w", err)
}
return wrap.Data, nil
}
// GetEventType fetches the full event type record — including the configured
// `bookingFields` array — so the public form can render dynamic questions.
func (c *CalcomClient) GetEventType(ctx context.Context, username, eventTypeSlug string) (EventType, error) {
q := url.Values{}
q.Set("username", username)
q.Set("eventSlug", eventTypeSlug)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/event-types?"+q.Encode(), nil)
if err != nil {
return EventType{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("cal-api-version", versionEventTypes)
resp, err := c.http.Do(req)
if err != nil {
return EventType{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return EventType{}, statusError("event-type", resp)
}
var wrap struct {
Status string `json:"status"`
Data []EventType `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil {
return EventType{}, fmt.Errorf("decode event-type: %w", err)
}
if len(wrap.Data) == 0 {
return EventType{}, fmt.Errorf("event type %q not found for user %q", eventTypeSlug, username)
}
et := wrap.Data[0]
et.BookingFields = normalizeBookingFields(et.BookingFields)
return et, nil
}
// normalizeBookingFields adapts Cal.com's v2 bookingFields to the shape the
// booking form template renders. Without it, default/system fields decode with
// an empty Name (the v2 identifier is `slug`, not `name`) and an empty Label
// (Cal.com leaves system-field labels blank and renders them client-side from
// i18n) — producing unlabelled inputs with empty `name` attributes, which are
// both invisible to the visitor AND drop their values on submit. Hidden system
// fields (e.g. `title`, `rescheduleReason`) are dropped so they never render.
func normalizeBookingFields(fields []BookingField) []BookingField {
if len(fields) == 0 {
return fields
}
out := make([]BookingField, 0, len(fields))
for _, f := range fields {
if f.Hidden || f.Editable == "system-but-hidden" {
continue
}
// Reschedule-only system fields (rescheduleReason carries
// views:[{id:"reschedule"}]) must never render on a NEW booking.
if isRescheduleOnlyView(f.Views) {
continue
}
// System fields the submit path drops because they have no valid
// new-booking home (location → structured top-level object handled by
// Cal.com's default; rescheduleReason → reschedule-only; title →
// system-managed). Drop them from the form too, so we never render an
// input whose value would be silently discarded. (rescheduleReason is
// also covered here by slug for event types that omit the views flag.)
if systemDropSlugs[f.Slug] || systemDropSlugs[f.Name] {
continue
}
if f.Name == "" {
f.Name = f.Slug
}
if f.Label == "" {
f.Label = bookingFieldLabel(f.Name)
}
out = append(out, f)
}
return out
}
// isRescheduleOnlyView reports whether a booking field's `views` restrict it to
// the reschedule flow. Cal.com sets views:[{id:"reschedule"}] on rescheduleReason;
// such fields are invalid on a create booking and must not be rendered.
func isRescheduleOnlyView(views []BookingFieldView) bool {
if len(views) == 0 {
return false
}
for _, v := range views {
if v.ID != "reschedule" {
return false
}
}
return true
}
// bookingFieldLabel resolves a human-readable label for a booking field whose
// Cal.com `label` came back empty — always the case for default/system fields.
// Known system slugs map to their conventional booker labels; anything else
// (a custom field that somehow lacks a label) is humanized from its slug.
func bookingFieldLabel(slug string) string {
switch slug {
case "name":
return "Name"
case "email":
return "Email"
case "location":
return "Location"
case "notes":
return "Additional notes"
case "guests":
return "Guests"
case "rescheduleReason":
return "Reason for reschedule"
case "attendeePhoneNumber", "phone", "smsReminderNumber":
return "Phone number"
case "title":
return "What is this meeting about?"
}
return humanizeSlug(slug)
}
// humanizeSlug turns a field slug like "company-size" or "company_size" into a
// sentence-case display label ("Company size"). Empty input yields "Field" so a
// label element is never rendered completely blank.
func humanizeSlug(slug string) string {
s := strings.TrimSpace(strings.NewReplacer("-", " ", "_", " ").Replace(slug))
if s == "" {
return "Field"
}
r := []rune(s)
r[0] = unicode.ToUpper(r[0])
return string(r)
}
// BookingAttendee carries the built-in fields that Cal.com expects every
// booking to include. Custom event-type fields travel in BookingFieldsResponses.
type BookingAttendee struct {
Name string `json:"name"`
Email string `json:"email"`
TimeZone string `json:"timeZone"`
Phone string `json:"phoneNumber,omitempty"`
Language string `json:"language,omitempty"`
}
// BookingRequest is the v2 `POST /v2/bookings` body. The event type is
// identified by slug + username — no separate ID lookup.
type BookingRequest struct {
EventTypeSlug string `json:"eventTypeSlug"`
Username string `json:"username"`
Start string `json:"start"`
Attendee BookingAttendee `json:"attendee"`
BookingFieldsResponses map[string]any `json:"bookingFieldsResponses,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Guests []string `json:"guests,omitempty"`
}
// BookingResponse is the parsed `POST /v2/bookings` success envelope.
type BookingResponse struct {
Status string `json:"status"`
Data BookingResponseData `json:"data"`
}
// BookingResponseData carries the Cal.com-provided identifiers and the meeting
// location surfaced to the attendee.
//
// Per the 2026-02-25 POST /v2/bookings response, `location` is the canonical
// meeting field and `meetingUrl` is deprecated ("rely on 'location' instead"),
// kept here only as a fallback. The response does NOT carry rescheduleUrl /
// cancelUrl — those reach the attendee via Cal.com's confirmation email, so we
// no longer decode (or surface) them.
type BookingResponseData struct {
UID string `json:"uid"`
StartTime string `json:"start"`
EndTime string `json:"end"`
Location string `json:"location"`
MeetingURL string `json:"meetingUrl"` // deprecated by Cal.com; fallback only
}
// MeetingLink returns the best available join URL. It prefers the canonical
// `location` field, but only when that is an http(s) URL — for non-video event
// types `location` can be a physical address or phone number, which must not be
// surfaced as a link. Falls back to the deprecated `meetingUrl`.
func (d BookingResponseData) MeetingLink() string {
if isHTTPURL(d.Location) {
return d.Location
}
if isHTTPURL(d.MeetingURL) {
return d.MeetingURL
}
return ""
}
// isHTTPURL reports whether s is an http(s) URL.
func isHTTPURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
// CreateBooking submits a new booking to Cal.com. For a regular or instant
// event type the response `data` is a single object; for a recurring event
// type it is an array (one entry per occurrence). Both shapes are resolved —
// a recurring booking surfaces its first occurrence.
func (c *CalcomClient) CreateBooking(ctx context.Context, br BookingRequest) (BookingResponse, error) {
body, err := json.Marshal(br)
if err != nil {
return BookingResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/bookings", bytes.NewReader(body))
if err != nil {
return BookingResponse{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("cal-api-version", versionBookings)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return BookingResponse{}, err
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return BookingResponse{}, statusErrorWithBody("booking", resp.StatusCode, respBody)
}
// The bookings `data` is a single object for regular/instant bookings but an
// ARRAY for recurring event types. Decode the envelope with a raw `data` so
// both shapes resolve instead of hard-failing on the array form.
var env struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(respBody, &env); err != nil {
return BookingResponse{}, fmt.Errorf("decode booking: %w", err)
}
out := BookingResponse{Status: env.Status}
switch trimmed := bytes.TrimSpace(env.Data); {
case len(trimmed) == 0:
// no data object — leave zero-value Data
case trimmed[0] == '[':
var arr []BookingResponseData
if err := json.Unmarshal(trimmed, &arr); err != nil {
return BookingResponse{}, fmt.Errorf("decode booking (recurring): %w", err)
}
if len(arr) == 0 {
return BookingResponse{}, fmt.Errorf("calcom booking: recurring data array was empty")
}
out.Data = arr[0]
default:
if err := json.Unmarshal(trimmed, &out.Data); err != nil {
return BookingResponse{}, fmt.Errorf("decode booking: %w", err)
}
}
return out, nil
}
// CancelBooking cancels an existing booking on Cal.com.
//
// Endpoint (confirmed against the Cal.com v2 docs, 2026-06-21):
//
// POST /v2/bookings/{bookingUid}/cancel
// cal-api-version: 2026-02-25 (the same version the create path pins —
// passing the wrong value silently routes to an
// older endpoint shape)
// body: { "cancellationReason": "<reason>" } (reason optional)
//
// Authorization is UID-based, mirroring Cal.com's own emailed cancel-link
// model: the booking UID is an unguessable identifier, so possession of it is
// the capability to cancel. The caller is responsible for the abuse guard (the
// public handler rate-limits by IP). uid is URL-path-escaped so a UID with
// path-significant characters can't break out of the route.
//
// A non-2xx response becomes a *CalcomAPIError (via statusErrorWithBody) so the
// handler can classify it; the raw Cal.com message stays server-side only.
func (c *CalcomClient) CancelBooking(ctx context.Context, uid, reason string) error {
body, err := json.Marshal(struct {
CancellationReason string `json:"cancellationReason"`
}{CancellationReason: reason})
if err != nil {
return err
}
endpoint := c.baseURL + "/bookings/" + url.PathEscape(uid) + "/cancel"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("cal-api-version", versionBookings)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return statusErrorWithBody("cancel-booking", resp.StatusCode, respBody)
}
return nil
}
// CalcomAPIError is a typed, non-2xx Cal.com API response. It carries the
// structured pieces the booking handler needs to classify a failure into
// user-actionable, curated copy — the HTTP Status, Cal.com's error Code (e.g.
// "BadRequestException"), and the raw Message ("Attempting to book a meeting in
// the past.") — WITHOUT the handler having to re-parse a flattened log string.
//
// Network/transport failures (DNS, timeout, connection reset) are NOT
// CalcomAPIErrors — those stay plain wrapped errors so the handler routes them
// to the admin-authored Tier-2 message. Only an HTTP response that came back
// with a non-2xx status produces a CalcomAPIError.
//
// Error() reproduces the exact string the previous fmt.Errorf form logged, so
// server-side logging is byte-for-byte unchanged. The Message is for
// classification + logs only — it is NEVER rendered to a visitor verbatim.
type CalcomAPIError struct {
Endpoint string // the Cal.com endpoint that failed, e.g. "booking"
Status int // HTTP status of the non-2xx response
Code string // Cal.com error.code, e.g. "BadRequestException" / "BAD_REQUEST" ("" if absent)
Message string // raw Cal.com error.message ("" if the body carried none)
}
func (e *CalcomAPIError) Error() string {
// Preserve the historical log shape: "calcom <endpoint>: status <n>[: <code>: <msg>]".
combined := e.Message
if e.Code != "" && e.Message != "" {
combined = e.Code + ": " + e.Message
} else if e.Code != "" {
combined = e.Code
}
if combined == "" {
return fmt.Sprintf("calcom %s: status %d", e.Endpoint, e.Status)
}
return fmt.Sprintf("calcom %s: status %d: %s", e.Endpoint, e.Status, combined)
}
// statusError builds a sanitized error from a non-2xx response. The status
// code is exposed; the body is captured but only included for non-5xx so we
// don't leak upstream incident traces to operators.
func statusError(endpoint string, resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
return statusErrorWithBody(endpoint, resp.StatusCode, body)
}
// statusErrorWithBody returns a *CalcomAPIError for a non-2xx Cal.com response.
// The handler can errors.As it to branch on Status/Code/Message; everything
// else (and log output) sees the same flattened string as before.
func statusErrorWithBody(endpoint string, status int, body []byte) error {
code, msg := extractCalcomError(body)
return &CalcomAPIError{Endpoint: endpoint, Status: status, Code: code, Message: msg}
}
// extractCalcomError pulls the structured (code, message) pair out of a Cal.com
// error body. Cal.com v2 nests these under `error` as an OBJECT
// ({"error":{"code","message"}}); older/other shapes use a flat top-level
// `message` or a string `error`. All three are handled so a failed booking can
// be classified (and logged) by its real reason ("Attempting to book a meeting
// in the past.") instead of a bare "status 400". Both pieces stay server-side
// only — public handlers always render curated visitor-facing copy.
func extractCalcomError(body []byte) (code, message string) {
var parsed struct {
Message string `json:"message"`
Error json.RawMessage `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", ""
}
if parsed.Message != "" {
return "", parsed.Message
}
if len(parsed.Error) == 0 {
return "", ""
}
// `error` may be a structured object {code,message} (v2) or a plain string.
var errObj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(parsed.Error, &errObj); err == nil && errObj.Message != "" {
return errObj.Code, errObj.Message
}
var errStr string
if err := json.Unmarshal(parsed.Error, &errStr); err == nil && errStr != "" {
return "", errStr
}
return "", ""
}