diff --git a/CHANGELOG.md b/CHANGELOG.md index 07adee51..c28c9797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 0.17.1 - Unreleased +### Added + +- Calendar: add --with-zoom / --regenerate-zoom / --remove-zoom that create, regenerate, and remove Zoom meetings and attach the join URL + meeting ID + passcode to the Calendar event description. Google's Calendar API rejects conferenceData writes asserting `conferenceSolution.key.type="addOn"` from non-Workspace-Marketplace OAuth clients, so the description-mode integration is the path that round-trips through Google's storage; trade-off is no native "Join with Zoom" conference card. (#589, #590) — thanks @alexisperumal and @mvanhorn. +- Auth: add gog zoom auth setup / doctor for Zoom S2S OAuth credential storage. (#590) — thanks @mvanhorn. + ### Fixed - CLI: harden backup writes, config/credentials atomic saves, keyring write verification, line input buffering, disabled-API hints, JSON transform number handling, and untrusted-content wrapping after ClawPatch review. diff --git a/README.md b/README.md index 8e9cab02..9ad334e0 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,8 @@ gog --gmail-no-send gmail drafts create --to you@example.com --subject test Docs: [`gog calendar`](docs/commands/gog-calendar.md), [`calendar create`](docs/commands/gog-calendar-create.md), [`calendar update`](docs/commands/gog-calendar-update.md), -[`calendar move`](docs/commands/gog-calendar-move.md). +[`calendar move`](docs/commands/gog-calendar-move.md), +[Zoom setup](docs/zoom-auth-setup.md). ```bash gog calendar events --today @@ -161,6 +162,11 @@ gog calendar create primary --summary "Coffee" \ --to "2026-05-06T10:30:00+02:00" \ --location-search "Elysian Coffee Vancouver" gog calendar update primary --with-meet +gog zoom auth setup +gog calendar create primary --summary "Client sync" \ + --from "2026-05-06T11:00:00+02:00" \ + --to "2026-05-06T11:30:00+02:00" \ + --with-zoom gog calendar move primary team-calendar@example.com gog calendar appointments ``` @@ -469,7 +475,7 @@ Docs: [Command index](docs/commands/README.md), Common user services: -- Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Meet, Apps Script +- Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Meet, Zoom, Apps Script - Analytics and Search Console - Contacts, People, Tasks, Classroom - Chat for Workspace accounts diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 1dec7162..f12cda04 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -562,3 +562,7 @@ Generated from `gog schema --json`. - [`gog youtube (yt) playlists (playlist) list (ls) [flags]`](commands/gog-youtube-playlists-list.md) - List playlists by channel or authenticated user - [`gog youtube (yt) videos (video) `](commands/gog-youtube-videos.md) - List or get videos - [`gog youtube (yt) videos (video) list (ls) [flags]`](commands/gog-youtube-videos-list.md) - List videos by ID or chart + - [`gog zoom [flags]`](commands/gog-zoom.md) - Zoom + - [`gog zoom auth `](commands/gog-zoom-auth.md) - Manage Zoom Server-to-Server OAuth credentials + - [`gog zoom auth doctor [flags]`](commands/gog-zoom-auth-doctor.md) - Validate Zoom credentials + - [`gog zoom auth setup [flags]`](commands/gog-zoom-auth-setup.md) - Store Zoom Server-to-Server OAuth credentials diff --git a/docs/commands/README.md b/docs/commands/README.md index 04de3601..b512ffcd 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 560. +Generated pages: 564. ## Top-level Commands @@ -49,6 +49,7 @@ Generated pages: 560. - [gog version](gog-version.md) - Print version - [gog whoami](gog-whoami.md) - Show your profile (alias for 'people me') - [gog youtube](gog-youtube.md) - YouTube Data API (activities, videos, playlists, comments, channels) +- [gog zoom](gog-zoom.md) - Zoom ## All Commands @@ -612,3 +613,7 @@ Generated pages: 560. - [gog youtube playlists list](gog-youtube-playlists-list.md) - List playlists by channel or authenticated user - [gog youtube videos](gog-youtube-videos.md) - List or get videos - [gog youtube videos list](gog-youtube-videos-list.md) - List videos by ID or chart + - [gog zoom](gog-zoom.md) - Zoom + - [gog zoom auth](gog-zoom-auth.md) - Manage Zoom Server-to-Server OAuth credentials + - [gog zoom auth doctor](gog-zoom-auth-doctor.md) - Validate Zoom credentials + - [gog zoom auth setup](gog-zoom-auth-setup.md) - Store Zoom Server-to-Server OAuth credentials diff --git a/docs/commands/gog-calendar-create.md b/docs/commands/gog-calendar-create.md index e7ff39b4..b90192b7 100644 --- a/docs/commands/gog-calendar-create.md +++ b/docs/commands/gog-calendar-create.md @@ -42,6 +42,7 @@ gog calendar (cal) create (add,new) [flags] | `--guests-can-modify` | `*bool` | | Allow guests to modify event | | `--guests-can-see-others` | `*bool` | | Allow guests to see other guests | | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--include-passwords` | `bool` | | Do not redact Zoom meeting passwords in output | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--location` | `string` | | Location | | `--location-search` | `string` | | Resolve a Google Places text search and use the best match as event location | @@ -69,6 +70,7 @@ gog calendar (cal) create (add,new) [flags] | `--version` | `kong.VersionFlag` | | Print version and exit | | `--visibility` | `string` | | Event visibility: default, public, private, confidential | | `--with-meet` | `bool` | | Create a Google Meet video conference for this event | +| `--with-zoom` | `bool` | | Create a Zoom video conference for this event | | `--working-building-id` | `string` | | Working location building ID | | `--working-custom-label` | `string` | | Working location custom label | | `--working-desk-id` | `string` | | Working location desk ID | diff --git a/docs/commands/gog-calendar-update.md b/docs/commands/gog-calendar-update.md index 61387a78..24d79632 100644 --- a/docs/commands/gog-calendar-update.md +++ b/docs/commands/gog-calendar-update.md @@ -42,6 +42,7 @@ gog calendar (cal) update (edit,set) [flags] | `--guests-can-modify` | `*bool` | | Allow guests to modify event | | `--guests-can-see-others` | `*bool` | | Allow guests to see other guests | | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--include-passwords` | `bool` | | Do not redact Zoom meeting passwords in output | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--location` | `string` | | New location (set empty to clear) | | `--location-search` | `string` | | Resolve a Google Places text search and use the best match as event location | @@ -55,7 +56,9 @@ gog calendar (cal) update (edit,set) [flags] | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--private-prop` | `[]string` | | Private extended property (key=value, can be repeated) | | `--regenerate-meet` | `bool` | | Replace the event's Google Meet video conference | +| `--regenerate-zoom` | `bool` | | Replace the event's Zoom video conference | | `--reminder` | `[]string` | | Custom reminders as method:duration (e.g., popup:30m, email:1d). Can be repeated (max 5). Set empty to clear. | +| `--remove-zoom` | `bool` | | Remove the event's Zoom video conference | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--rrule` | `[]string` | | Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated. Set empty to clear. | | `--scope` | `string` | all | For recurring events: single, future, all | @@ -70,6 +73,7 @@ gog calendar (cal) update (edit,set) [flags] | `--version` | `kong.VersionFlag` | | Print version and exit | | `--visibility` | `string` | | Event visibility: default, public, private, confidential | | `--with-meet` | `bool` | | Create a Google Meet video conference for this event | +| `--with-zoom` | `bool` | | Create a Zoom video conference for this event | | `--working-building-id` | `string` | | Working location building ID | | `--working-custom-label` | `string` | | Working location custom label | | `--working-desk-id` | `string` | | Working location desk ID | diff --git a/docs/commands/gog-zoom-auth-doctor.md b/docs/commands/gog-zoom-auth-doctor.md new file mode 100644 index 00000000..f0778679 --- /dev/null +++ b/docs/commands/gog-zoom-auth-doctor.md @@ -0,0 +1,44 @@ +# `gog zoom auth doctor` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Validate Zoom credentials + +## Usage + +```bash +gog zoom auth doctor [flags] +``` + +## Parent + +- [gog zoom auth](gog-zoom-auth.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) | +| `--alias` | `string` | default | Zoom credential alias | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog zoom auth](gog-zoom-auth.md) +- [Command index](README.md) diff --git a/docs/commands/gog-zoom-auth-setup.md b/docs/commands/gog-zoom-auth-setup.md new file mode 100644 index 00000000..3cc5f601 --- /dev/null +++ b/docs/commands/gog-zoom-auth-setup.md @@ -0,0 +1,48 @@ +# `gog zoom auth setup` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Store Zoom Server-to-Server OAuth credentials + +## Usage + +```bash +gog zoom auth setup [flags] +``` + +## Parent + +- [gog zoom auth](gog-zoom-auth.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) | +| `--account-id` | `string` | | Zoom Server-to-Server OAuth account ID | +| `--alias` | `string` | default | Zoom credential alias | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--client-id` | `string` | | Zoom Server-to-Server OAuth client ID | +| `--client-secret` | `string` | | Zoom Server-to-Server OAuth client secret | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--skip-validate` | `bool` | | Store credentials without calling Zoom /users/me | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog zoom auth](gog-zoom-auth.md) +- [Command index](README.md) diff --git a/docs/commands/gog-zoom-auth.md b/docs/commands/gog-zoom-auth.md new file mode 100644 index 00000000..c71d06be --- /dev/null +++ b/docs/commands/gog-zoom-auth.md @@ -0,0 +1,48 @@ +# `gog zoom auth` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Manage Zoom Server-to-Server OAuth credentials + +## Usage + +```bash +gog zoom auth +``` + +## Parent + +- [gog zoom](gog-zoom.md) + +## Subcommands + +- [gog zoom auth doctor](gog-zoom-auth-doctor.md) - Validate Zoom credentials +- [gog zoom auth setup](gog-zoom-auth-setup.md) - Store Zoom Server-to-Server OAuth credentials + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog zoom](gog-zoom.md) +- [Command index](README.md) diff --git a/docs/commands/gog-zoom.md b/docs/commands/gog-zoom.md new file mode 100644 index 00000000..d000cacf --- /dev/null +++ b/docs/commands/gog-zoom.md @@ -0,0 +1,47 @@ +# `gog zoom` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Zoom + +## Usage + +```bash +gog zoom [flags] +``` + +## Parent + +- [gog](gog.md) + +## Subcommands + +- [gog zoom auth](gog-zoom-auth.md) - Manage Zoom Server-to-Server OAuth credentials + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog](gog.md) +- [Command index](README.md) diff --git a/docs/commands/gog.md b/docs/commands/gog.md index c80ac706..42abb0e4 100644 --- a/docs/commands/gog.md +++ b/docs/commands/gog.md @@ -6,7 +6,7 @@ Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Sl Config: file: /gogcli/config.json - keyring backend: file (source: config) + keyring backend: auto (source: default) ## Usage @@ -59,6 +59,7 @@ gog [flags] - [gog version](gog-version.md) - Print version - [gog whoami](gog-whoami.md) - Show your profile (alias for 'people me') - [gog youtube](gog-youtube.md) - YouTube Data API (activities, videos, playlists, comments, channels) +- [gog zoom](gog-zoom.md) - Zoom ## Flags diff --git a/docs/zoom-auth-setup.md b/docs/zoom-auth-setup.md new file mode 100644 index 00000000..21d76062 --- /dev/null +++ b/docs/zoom-auth-setup.md @@ -0,0 +1,124 @@ +# Zoom S2S OAuth Setup + +`gog calendar create --with-zoom` and `gog calendar update --with-zoom` create +Zoom meetings through Zoom's Server-to-Server OAuth app type, then attach the +join URL, meeting ID, and passcode to the Calendar event description. + +> **Note on rendering.** The Zoom info is written into the event description, +> not Calendar's native `conferenceData` surface. Google's Calendar API rejects +> `conferenceData` writes that assert `conferenceSolution.key.type="addOn"` from +> non-Workspace-Marketplace OAuth clients (400 "Invalid conference data") and +> silently drops the field entirely when `key.type` is omitted. Description +> mode renders as a clickable Zoom link in every Calendar UI; the trade-off is +> no native "Join with Zoom" conference card. Becoming a registered +> Workspace Marketplace Conference Add-on is the only path to the native card. + +## Prerequisites + +`gog` must already be set up against your Google account (`gog auth login`). +See the gogcli quickstart for how to provision a Google OAuth client and seed +`credentials.json` before running `gog auth login`. + +## Create the Zoom App + +1. Open the Zoom Marketplace. +2. Choose **Develop** > **Build App**. +3. Select **Server-to-Server OAuth**. +4. Name the app for your automation or organization. +5. Copy the app's **Account ID**, **Client ID**, and **Client Secret**. +6. Add the required scopes. User-level scopes (`meeting:write`, `meeting:read`, + `user:read`) are sufficient on accounts where they are exposed; on accounts + where the Marketplace UI exposes only granular admin variants, use: + - `meeting:write:meeting:admin` + - `meeting:read:meeting:admin` + - `user:read:user:admin` +7. Activate the app after Zoom shows the credentials and scopes are complete. + +Do not request `*:admin` scopes for delegated host selection on this workflow. +Tier 1 Zoom calendar support creates meetings as the authenticated app account +user and does not implement `--zoom-host` delegation. + +## Store Credentials + +Run: + +```bash +gog zoom auth setup +``` + +The setup command prompts for the account ID, client ID, and client secret. The +client secret is read with masked terminal input, stored in gogcli's existing OS +keyring, and validated with Zoom before it is saved. + +Non-secret metadata is written to: + +```text +~/.config/gogcli/zoom/default.json +``` + +The directory is created with `0700` permissions and the metadata file is written +with `0600` permissions. Secrets use namespaced keyring entries: + +```text +zoom-account/default/client-secret +zoom-account/default/access-token +``` + +## Environment Overrides + +For CI or ephemeral automation, you can skip stored credentials and set: + +```bash +export GOG_ZOOM_ACCOUNT_ID=... +export GOG_ZOOM_CLIENT_ID=... +export GOG_ZOOM_CLIENT_SECRET=... +``` + +Prefer `gog zoom auth setup` for long-lived machines. Environment variables can +be visible to other processes running as the same user on some systems, so avoid +putting `GOG_ZOOM_CLIENT_SECRET` in shared shell profiles or service logs. + +## Verify + +Run: + +```bash +gog zoom auth doctor +``` + +The doctor command checks stored or environment credentials, validates them with +Zoom, reports the cached access-token expiry when present, and warns when +`GOG_ZOOM_CLIENT_SECRET` is set. + +## Create a Calendar Event With Zoom + +```bash +gog calendar create primary \ + --summary "Client sync" \ + --from "2026-05-06T11:00:00+02:00" \ + --to "2026-05-06T11:30:00+02:00" \ + --with-zoom +``` + +This creates a Zoom meeting via the Zoom API and appends a block like the +following to the event description: + +```text + +Join Zoom Meeting: https://us06web.zoom.us/j/86823956608?pwd=... +Meeting ID: 86823956608 +Passcode: + +``` + +The HTML comment markers let `--regenerate-zoom` replace the block in place and +`--remove-zoom` strip it out without disturbing surrounding description content. + +Command output redacts the managed block's `pwd=` value and `Passcode:` line by +default. Use `--include-passwords` on create/update output only when you really +need to print the password; `gog calendar raw` stays lossless for raw API +inspection. + +Use `--regenerate-zoom` on `gog calendar update` to replace the Zoom meeting, or +`--remove-zoom` to delete the Zoom meeting and strip the Zoom block from the +Calendar event description. diff --git a/internal/cmd/calendar_build.go b/internal/cmd/calendar_build.go index f28bdfc8..66a1a3be 100644 --- a/internal/cmd/calendar_build.go +++ b/internal/cmd/calendar_build.go @@ -79,20 +79,44 @@ func extractTimezone(value string) string { return "" } -func buildConferenceData(withMeet bool) *calendar.ConferenceData { - if !withMeet { - return nil - } - return &calendar.ConferenceData{ - CreateRequest: &calendar.CreateConferenceRequest{ - RequestId: fmt.Sprintf("gogcli-%d", time.Now().UnixNano()), - ConferenceSolutionKey: &calendar.ConferenceSolutionKey{ - Type: "hangoutsMeet", +type conferenceChoice struct { + provider string +} + +const ( + conferenceProviderMeet = "meet" + conferenceProviderZoom = "zoom" +) + +func buildConferenceData(c conferenceChoice) *calendar.ConferenceData { + switch c.provider { + case conferenceProviderMeet: + return &calendar.ConferenceData{ + CreateRequest: &calendar.CreateConferenceRequest{ + RequestId: fmt.Sprintf("gogcli-%d", time.Now().UnixNano()), + ConferenceSolutionKey: &calendar.ConferenceSolutionKey{ + Type: "hangoutsMeet", + }, }, - }, + } + case conferenceProviderZoom: + // Zoom is attached via the event description (see zoom_description.go), + // not conferenceData. Google's Calendar API rejects conferenceData + // writes that assert conferenceSolution.key.type="addOn" from + // non-Workspace-Marketplace OAuth clients with 400 "Invalid conference + // data", and silently drops the field entirely when key.type is + // omitted. Description-mode preserves the join URL + meeting ID + + // passcode in a form that round-trips through Google's storage. + return nil + default: + return nil } } +func buildMeetConferenceData() *calendar.ConferenceData { + return buildConferenceData(conferenceChoice{provider: conferenceProviderMeet}) +} + func buildRecurrence(rules []string) []string { if len(rules) == 0 { return nil diff --git a/internal/cmd/calendar_edit.go b/internal/cmd/calendar_edit.go index 6e9521ed..3be538cc 100644 --- a/internal/cmd/calendar_edit.go +++ b/internal/cmd/calendar_edit.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "reflect" "strings" @@ -10,8 +11,11 @@ import ( "google.golang.org/api/calendar/v3" "github.com/steipete/gogcli/internal/ui" + "github.com/steipete/gogcli/internal/zoom" ) +var errZoomConferenceAlreadyHandled = errors.New("zoom conference already handled") + type CalendarCreateCmd struct { CalendarID string `arg:"" name:"calendarId" help:"Calendar ID"` Summary string `name:"summary" help:"Event summary/title"` @@ -37,6 +41,8 @@ type CalendarCreateCmd struct { GuestsCanModify *bool `name:"guests-can-modify" help:"Allow guests to modify event"` GuestsCanSeeOthers *bool `name:"guests-can-see-others" help:"Allow guests to see other guests"` WithMeet bool `name:"with-meet" help:"Create a Google Meet video conference for this event"` + WithZoom bool `name:"with-zoom" help:"Create a Zoom video conference for this event"` + IncludePasswords bool `name:"include-passwords" help:"Do not redact Zoom meeting passwords in output" env:"GOG_ZOOM_INCLUDE_PASSWORDS"` SourceUrl string `name:"source-url" help:"URL where event was created/imported from"` SourceTitle string `name:"source-title" help:"Title of the source"` Attachments []string `name:"attachment" help:"File attachment URL (can be repeated)"` @@ -58,6 +64,10 @@ type CalendarCreateCmd struct { } func (c *CalendarCreateCmd) Run(ctx context.Context, flags *RootFlags, kctx *kong.Context) error { + ctx = withZoomIncludePasswords(ctx, c.IncludePasswords) + if kctx != nil && flagProvided(kctx, "with-meet") && flagProvided(kctx, "with-zoom") { + return usage("use only one of --with-zoom or --with-meet") + } if flags != nil && flags.DryRun { placeLookup, err := validateCalendarPlaceLookup(calendarPlaceLookup{ LocationSet: flagProvided(kctx, "location") || strings.TrimSpace(c.Location) != "", @@ -86,6 +96,9 @@ func (c *CalendarCreateCmd) Run(ctx context.Context, flags *RootFlags, kctx *kon "supports_attachments": len(plan.Event.Attachments) > 0, "event": plan.Event, } + if plan.WithZoom { + request["zoom"] = zoomDryRunPayload("create") + } if placeLookup != nil { request["place_lookup"] = placeLookup.dryRunPayload() } @@ -109,7 +122,7 @@ func (c *CalendarCreateCmd) Run(ctx context.Context, flags *RootFlags, kctx *kon if dryRunErr := dryRunExit(ctx, flags, "calendar.create", map[string]any{ "calendar_id": calendarID, "send_updates": plan.SendUpdates, - "conference_version_1": plan.WithMeet, + "conference_version_1": plan.WithMeet || plan.WithZoom, "supports_attachments": len(plan.Event.Attachments) > 0, "event": plan.Event, }); dryRunErr != nil { @@ -121,12 +134,24 @@ func (c *CalendarCreateCmd) Run(ctx context.Context, flags *RootFlags, kctx *kon return err } + var zoomMeeting *zoom.Meeting + if plan.WithZoom { + zoomMeeting, err = createZoomMeetingForEvent(ctx, plan.Event) + if err != nil { + return err + } + plan.Event.Description = applyZoomDescriptionBlock(plan.Event.Description, buildZoomDescriptionBlock(zoomMeeting)) + } + created, err := mutation.insertEvent(ctx, plan.Event, calendarInsertOptions{ sendUpdates: plan.SendUpdates, conferenceVersion1: plan.WithMeet, supportsAttachments: len(plan.Event.Attachments) > 0, }) if err != nil { + if zoomMeeting != nil { + _ = cancelZoomMeeting(ctx, zoomMeetingID(zoomMeeting), "delete") + } return err } return mutation.writeEvent(ctx, created) @@ -271,6 +296,10 @@ type CalendarUpdateCmd struct { GuestsCanSeeOthers *bool `name:"guests-can-see-others" help:"Allow guests to see other guests"` WithMeet bool `name:"with-meet" help:"Create a Google Meet video conference for this event"` RegenerateMeet bool `name:"regenerate-meet" help:"Replace the event's Google Meet video conference"` + WithZoom bool `name:"with-zoom" help:"Create a Zoom video conference for this event"` + RegenerateZoom bool `name:"regenerate-zoom" help:"Replace the event's Zoom video conference"` + RemoveZoom bool `name:"remove-zoom" help:"Remove the event's Zoom video conference"` + IncludePasswords bool `name:"include-passwords" help:"Do not redact Zoom meeting passwords in output" env:"GOG_ZOOM_INCLUDE_PASSWORDS"` Scope string `name:"scope" help:"For recurring events: single, future, all" default:"all"` OriginalStartTime string `name:"original-start" help:"Original start time of instance (required for scope=single,future)"` PrivateProps []string `name:"private-prop" help:"Private extended property (key=value, can be repeated)"` @@ -289,9 +318,12 @@ type CalendarUpdateCmd struct { WorkingCustomLabel string `name:"working-custom-label" help:"Working location custom label"` SendUpdates string `name:"send-updates" help:"Notification mode: all, externalOnly, none (default: none)"` resolvedPlace *calendarPlace + createdZoomMeetingID string } +//nolint:gocyclo,cyclop // Calendar update already handles many flag families; Zoom adds one narrow branch. func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { + ctx = withZoomIncludePasswords(ctx, c.IncludePasswords) calendarID, err := prepareCalendarID(c.CalendarID, false) if err != nil { return err @@ -320,6 +352,9 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * if flagProvided(kctx, "with-meet") && flagProvided(kctx, "regenerate-meet") { return usage("use only one of --with-meet or --regenerate-meet") } + if mutexErr := validateZoomConferenceFlagMutex(kctx); mutexErr != nil { + return mutexErr + } placeLookup, err := validateCalendarPlaceLookup(calendarPlaceLookup{ LocationSet: flagProvided(kctx, "location"), LocationSearch: c.LocationSearch, @@ -367,12 +402,15 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * "add_attendee": strings.TrimSpace(c.AddAttendee), "patch": patch, "wants_add_attendee": wantsAddAttendee, - "conference_version_1": patch.ConferenceData != nil, + "conference_version_1": patchHasConferenceDataMutation(patch), "supports_attachments": len(patch.Attachments) > 0, } if placeLookup != nil { request["place_lookup"] = placeLookup.dryRunPayload() } + if zoomPayload := zoomUpdateDryRunPayload(kctx); zoomPayload != nil { + request["zoom"] = zoomPayload + } if dryRunErr := dryRunExit(ctx, flags, "calendar.update", request); dryRunErr != nil { return dryRunErr } @@ -398,7 +436,18 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * } } - if patch.ConferenceData != nil && !flagProvided(kctx, "regenerate-meet") && patchOnlyConferenceData(patch) { + if flagProvidedAny(kctx, "with-zoom", "regenerate-zoom", "remove-zoom") { + var zoomErr error + patch, _, zoomErr = c.prepareZoomConferencePatch(ctx, mutation, eventID, scope, c.OriginalStartTime, patch, changed, kctx) + if errors.Is(zoomErr, errZoomConferenceAlreadyHandled) { + return nil + } + if zoomErr != nil { + return zoomErr + } + } + + if patch.ConferenceData != nil && !flagProvided(kctx, "regenerate-meet") && !flagProvidedAny(kctx, "with-zoom", "regenerate-zoom", "remove-zoom") && patchOnlyConferenceData(patch) { resolution, resolveErr := resolveRecurringScopeResolution(ctx, mutation.svc, mutation.calendarID, eventID, scope, c.OriginalStartTime) if resolveErr != nil { return resolveErr @@ -416,7 +465,7 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * if err != nil { return err } - if patch.ConferenceData != nil && !flagProvided(kctx, "regenerate-meet") { + if patch.ConferenceData != nil && !flagProvided(kctx, "regenerate-meet") && !flagProvidedAny(kctx, "with-zoom", "regenerate-zoom", "remove-zoom") { existing, getErr := mutation.svc.Events.Get(mutation.calendarID, targetEventID).Context(ctx).Do() if getErr != nil { return fmt.Errorf("failed to fetch current event for conference data: %w", getErr) @@ -437,6 +486,9 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * updated, err := mutation.patchEvent(ctx, targetEventID, patch, sendUpdates) if err != nil { + if c.createdZoomMeetingID != "" { + _ = cancelZoomMeeting(ctx, c.createdZoomMeetingID, "delete") + } return err } if scope == scopeFuture { @@ -766,10 +818,17 @@ func (c *CalendarUpdateCmd) applyGuestOptions(kctx *kong.Context, patch *calenda } func (c *CalendarUpdateCmd) applyConferenceData(kctx *kong.Context, patch *calendar.Event) bool { + if flagProvided(kctx, "remove-zoom") { + patch.NullFields = append(patch.NullFields, "ConferenceData") + return true + } + if flagProvided(kctx, "with-zoom") || flagProvided(kctx, "regenerate-zoom") { + return true + } if !flagProvided(kctx, "with-meet") && !flagProvided(kctx, "regenerate-meet") { return false } - patch.ConferenceData = buildConferenceData(true) + patch.ConferenceData = buildMeetConferenceData() return true } @@ -792,14 +851,187 @@ func eventHasConferenceLink(event *calendar.Event) bool { } func patchOnlyConferenceData(event *calendar.Event) bool { - if event == nil || event.ConferenceData == nil { + if event == nil || !patchHasConferenceDataMutation(event) { return false } clone := *event clone.ConferenceData = nil + clone.NullFields = removeStringField(clone.NullFields, "ConferenceData") return reflect.DeepEqual(clone, calendar.Event{}) } +func validateZoomConferenceFlagMutex(kctx *kong.Context) error { + pairs := [][2]string{ + {"with-zoom", "regenerate-zoom"}, + {"with-zoom", "remove-zoom"}, + {"regenerate-zoom", "remove-zoom"}, + {"with-zoom", "with-meet"}, + {"with-zoom", "regenerate-meet"}, + {"regenerate-zoom", "with-meet"}, + {"regenerate-zoom", "regenerate-meet"}, + } + for _, pair := range pairs { + if flagProvided(kctx, pair[0]) && flagProvided(kctx, pair[1]) { + return usage(fmt.Sprintf("use only one of --%s or --%s", pair[0], pair[1])) + } + } + return nil +} + +func zoomUpdateDryRunPayload(kctx *kong.Context) map[string]any { + switch { + case flagProvided(kctx, "with-zoom"): + return zoomDryRunPayload("create") + case flagProvided(kctx, "regenerate-zoom"): + return zoomDryRunPayload("regenerate") + case flagProvided(kctx, "remove-zoom"): + return zoomDryRunPayload("remove") + default: + return nil + } +} + +func zoomDryRunPayload(action string) map[string]any { + return map[string]any{ + "action": action, + "description_mode": true, + } +} + +func (c *CalendarUpdateCmd) prepareZoomConferencePatch( + ctx context.Context, + mutation *calendarMutationContext, + eventID, scope, originalStartTime string, + patch *calendar.Event, + changed bool, + kctx *kong.Context, +) (*calendar.Event, bool, error) { + resolution, err := resolveRecurringScopeResolution(ctx, mutation.svc, mutation.calendarID, eventID, scope, originalStartTime) + if err != nil { + return patch, changed, err + } + existing, err := mutation.svc.Events.Get(mutation.calendarID, resolution.TargetEventID).Context(ctx).Do() + if err != nil { + return patch, changed, fmt.Errorf("failed to fetch current event for conference data: %w", err) + } + + switch { + case flagProvided(kctx, "with-zoom"): + provider := eventConferenceProvider(existing) + switch provider { + case conferenceProviderZoom: + if patchOnlyConferenceData(patch) || patchEffectivelyEmpty(patch) { + if err := mutation.writeEvent(ctx, existing); err != nil { + return patch, false, err + } + return patch, false, errZoomConferenceAlreadyHandled + } + return patch, changed, nil + case conferenceProviderMeet: + return patch, changed, usage("event already has a Meet conference; use --remove-meet first, then --with-zoom") + case "other": + return patch, changed, usage("event already has a conference; remove it before using --with-zoom") + } + meeting, createErr := createZoomMeetingForEvent(ctx, mergeEventPatch(existing, patch)) + if createErr != nil { + return patch, changed, createErr + } + c.createdZoomMeetingID = zoomMeetingID(meeting) + patch.Description = applyZoomDescriptionBlock(descriptionForPatch(existing, patch), buildZoomDescriptionBlock(meeting)) + return patch, true, nil + + case flagProvided(kctx, "regenerate-zoom"): + if meetingID, ok := extractZoomMeetingID(existing); ok { + if err := cancelZoomMeeting(ctx, meetingID, "regenerate"); err != nil && !errors.Is(err, zoom.ErrMeetingNotFound) { + return patch, changed, err + } + } else { + warnUnparseableZoomMeeting(mutation.u) + } + meeting, createErr := createZoomMeetingForEvent(ctx, mergeEventPatch(existing, patch)) + if createErr != nil { + return patch, changed, createErr + } + c.createdZoomMeetingID = zoomMeetingID(meeting) + patch.Description = applyZoomDescriptionBlock(descriptionForPatch(existing, patch), buildZoomDescriptionBlock(meeting)) + return patch, true, nil + + case flagProvided(kctx, "remove-zoom"): + if meetingID, ok := extractZoomMeetingID(existing); ok { + if err := cancelZoomMeeting(ctx, meetingID, "delete"); err != nil && !errors.Is(err, zoom.ErrMeetingNotFound) { + if mutation.u != nil { + mutation.u.Err().Linef("warning\tfailed to delete Zoom meeting %s: %v", meetingID, err) + } + } + } else { + warnUnparseableZoomMeeting(mutation.u) + } + // Strip the gog-managed Zoom block from the description. Also clear + // any legacy ConferenceData (events created by the Zoom for Google + // Workspace add-on, or future re-introduction of the Marketplace + // add-on path) so --remove-zoom is idempotent across both shapes. + patch.Description = applyZoomDescriptionBlock(descriptionForPatch(existing, patch), "") + if existing != nil && existing.ConferenceData != nil && isZoomConferenceData(existing.ConferenceData) { + patch.ConferenceData = nil + patch.NullFields = append(patch.NullFields, "ConferenceData") + } + return patch, true, nil + } + return patch, changed, nil +} + +func mergeEventPatch(existing, patch *calendar.Event) *calendar.Event { + if existing == nil { + return patch + } + merged := *existing + if patch == nil { + return &merged + } + if strings.TrimSpace(patch.Summary) != "" { + merged.Summary = patch.Summary + } + if strings.TrimSpace(patch.Description) != "" { + merged.Description = patch.Description + } + if patch.Start != nil { + merged.Start = patch.Start + } + if patch.End != nil { + merged.End = patch.End + } + return &merged +} + +func patchHasConferenceDataMutation(event *calendar.Event) bool { + if event == nil { + return false + } + if event.ConferenceData != nil { + return true + } + for _, field := range event.NullFields { + if field == "ConferenceData" { + return true + } + } + return false +} + +func patchEffectivelyEmpty(event *calendar.Event) bool { + return event == nil || reflect.DeepEqual(*event, calendar.Event{}) +} + +func removeStringField(fields []string, value string) []string { + out := fields[:0] + for _, field := range fields { + if field != value { + out = append(out, field) + } + } + return out +} + func (c *CalendarUpdateCmd) applyExtendedProperties(kctx *kong.Context, patch *calendar.Event) bool { if !flagProvided(kctx, "private-prop") && !flagProvided(kctx, "shared-prop") { return false diff --git a/internal/cmd/calendar_event_plan.go b/internal/cmd/calendar_event_plan.go index ffe89edf..e1409e6e 100644 --- a/internal/cmd/calendar_event_plan.go +++ b/internal/cmd/calendar_event_plan.go @@ -22,6 +22,7 @@ type calendarCreatePlan struct { CalendarID string SendUpdates string WithMeet bool + WithZoom bool Event *calendar.Event } @@ -84,7 +85,7 @@ func buildCalendarCreatePlan(c *CalendarCreateCmd) (*calendarCreatePlan, error) ColorId: colorID, Visibility: applyEventTypeVisibilityDefault(visibility, eventType), Transparency: applyEventTypeTransparencyDefault(transparency, eventType), - ConferenceData: buildConferenceData(c.WithMeet), + ConferenceData: buildConferenceData(conferenceChoice{provider: conferenceProvider(c.WithMeet, c.WithZoom)}), Attachments: buildAttachments(c.Attachments), ExtendedProperties: buildExtendedProperties(c.PrivateProps, c.SharedProps), } @@ -116,10 +117,22 @@ func buildCalendarCreatePlan(c *CalendarCreateCmd) (*calendarCreatePlan, error) CalendarID: strings.TrimSpace(c.CalendarID), SendUpdates: sendUpdates, WithMeet: c.WithMeet, + WithZoom: c.WithZoom, Event: event, }, nil } +func conferenceProvider(withMeet, withZoom bool) string { + switch { + case withMeet: + return conferenceProviderMeet + case withZoom: + return conferenceProviderZoom + default: + return "" + } +} + func buildFocusTimeProperties(input focusTimeInput) (*calendar.EventFocusTimeProperties, error) { autoDecline := strings.TrimSpace(input.AutoDecline) if autoDecline == "" { diff --git a/internal/cmd/calendar_events_cmds.go b/internal/cmd/calendar_events_cmds.go index a542577f..84b587d6 100644 --- a/internal/cmd/calendar_events_cmds.go +++ b/internal/cmd/calendar_events_cmds.go @@ -147,6 +147,7 @@ func (c *CalendarEventCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } + redactCalendarEventForOutput(ctx, event) tz, loc, _ := getCalendarLocation(ctx, svc, calendarID) if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"event": wrapEventWithDaysWithTimezone(event, tz, loc)}) diff --git a/internal/cmd/calendar_list.go b/internal/cmd/calendar_list.go index b423b089..aa8f3a7f 100644 --- a/internal/cmd/calendar_list.go +++ b/internal/cmd/calendar_list.go @@ -58,6 +58,7 @@ func listCalendarEvents(ctx context.Context, svc *calendar.Service, calendarID, } events := make([]*eventWithCalendar, 0, len(items)) for _, item := range items { + redactCalendarEventForOutput(ctx, item) events = append(events, wrapEventWithCalendar(item, "", calendarTimezone, loc)) } sortEventsBy(events, sortKey, sortOrder) @@ -168,6 +169,7 @@ func listCalendarIDsEvents(ctx context.Context, svc *calendar.Service, calendarI } for _, e := range events { + redactCalendarEventForOutput(ctx, e) all = append(all, wrapEventWithCalendar(e, calID, calendarTimezone, loc)) } } diff --git a/internal/cmd/calendar_mutation_helpers.go b/internal/cmd/calendar_mutation_helpers.go index ee45205d..80ddba03 100644 --- a/internal/cmd/calendar_mutation_helpers.go +++ b/internal/cmd/calendar_mutation_helpers.go @@ -57,7 +57,7 @@ func (m *calendarMutationContext) patchEvent(ctx context.Context, eventID string if sendUpdates != "" { call = call.SendUpdates(sendUpdates) } - if patch.ConferenceData != nil { + if patchHasConferenceDataMutation(patch) { call = call.ConferenceDataVersion(1) } return call.Do() @@ -80,6 +80,7 @@ func (m *calendarMutationContext) moveEvent(ctx context.Context, eventID, destin } func (m *calendarMutationContext) writeEvent(ctx context.Context, event *calendar.Event) error { + redactCalendarEventForOutput(ctx, event) tz, loc, _ := getCalendarLocation(ctx, m.svc, m.calendarID) if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"event": wrapEventWithDaysWithTimezone(event, tz, loc)}) diff --git a/internal/cmd/calendar_search.go b/internal/cmd/calendar_search.go index f6c7466f..feefea80 100644 --- a/internal/cmd/calendar_search.go +++ b/internal/cmd/calendar_search.go @@ -56,6 +56,7 @@ func (c *CalendarSearchCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } + redactCalendarEventsForOutput(ctx, resp.Items) if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ diff --git a/internal/cmd/calendar_zoom.go b/internal/cmd/calendar_zoom.go new file mode 100644 index 00000000..940f028b --- /dev/null +++ b/internal/cmd/calendar_zoom.go @@ -0,0 +1,244 @@ +package cmd + +import ( + "context" + "fmt" + "net/url" + "os" + "regexp" + "strings" + "time" + + "google.golang.org/api/calendar/v3" + + "github.com/steipete/gogcli/internal/ui" + "github.com/steipete/gogcli/internal/zoom" +) + +type zoomMeetingClient interface { + CreateMeeting(context.Context, string, zoom.CreateMeetingRequest) (*zoom.Meeting, error) + DeleteMeeting(context.Context, string) error +} + +var newZoomMeetingClient = func(alias string) (zoomMeetingClient, error) { + creds, err := zoom.LoadCredentials(alias) + if err != nil { + return nil, err + } + return zoom.NewClient(alias, creds) +} + +func createZoomMeetingForEvent(ctx context.Context, event *calendar.Event) (*zoom.Meeting, error) { + client, err := newZoomMeetingClient("") + if err != nil { + return nil, err + } + return client.CreateMeeting(ctx, "me", zoomMeetingRequestFromEvent(event)) +} + +func zoomMeetingRequestFromEvent(event *calendar.Event) zoom.CreateMeetingRequest { + req := zoom.CreateMeetingRequest{Type: 2} + if event == nil { + return req + } + req.Topic = strings.TrimSpace(event.Summary) + req.Agenda = strings.TrimSpace(event.Description) + if event.Start != nil { + req.Timezone = strings.TrimSpace(event.Start.TimeZone) + if strings.TrimSpace(event.Start.DateTime) != "" { + if start, err := time.Parse(time.RFC3339, event.Start.DateTime); err == nil { + req.StartTime = start + } + } + } + if req.Timezone == "" { + req.Timezone = tzUTC + } + req.Duration = eventDurationMinutes(event) + return req +} + +func eventDurationMinutes(event *calendar.Event) int { + if event == nil || event.Start == nil || event.End == nil { + return 0 + } + start, startErr := time.Parse(time.RFC3339, event.Start.DateTime) + end, endErr := time.Parse(time.RFC3339, event.End.DateTime) + if startErr != nil || endErr != nil || !end.After(start) { + return 0 + } + return int(end.Sub(start).Round(time.Minute) / time.Minute) +} + +func cancelZoomMeeting(ctx context.Context, meetingID, action string) error { + client, err := newZoomMeetingClient("") + if err != nil { + return err + } + logZoomAudit(meetingID, action) + return client.DeleteMeeting(ctx, meetingID) +} + +func zoomMeetingID(meeting *zoom.Meeting) string { + if meeting == nil { + return "" + } + if meeting.ID != 0 { + return fmt.Sprintf("%d", meeting.ID) + } + return strings.TrimSpace(meeting.UUID) +} + +func logZoomAudit(meetingID, action string) { + fmt.Fprintf(os.Stderr, "[zoom] meeting=%s action=%s ts=%s cmd=%s\n", + meetingID, + action, + time.Now().UTC().Format(time.RFC3339), + os.Args[0], + ) +} + +var zoomMeetingIDPath = regexp.MustCompile(`/j/(\d+)`) + +func extractZoomMeetingID(event *calendar.Event) (id string, ok bool) { + if event == nil { + return "", false + } + // Primary path: gog-managed Zoom block in the event description carries + // the meeting ID in its start marker. This is the shape gog itself writes. + if id, ok = extractZoomMeetingIDFromDescription(event.Description); ok { + return id, true + } + // Legacy / interoperability path: events created by the Zoom for Google + // Workspace add-on (or any tool that populated conferenceData directly) + // expose the meeting ID via the join URL or addOn parameters. + if event.ConferenceData == nil { + return "", false + } + for _, ep := range event.ConferenceData.EntryPoints { + if ep == nil { + continue + } + u, err := url.Parse(strings.TrimSpace(ep.Uri)) + if err != nil { + continue + } + host := strings.ToLower(u.Hostname()) + if !strings.HasSuffix(host, "zoom.us") && !strings.HasSuffix(host, "zoomgov.com") { + continue + } + m := zoomMeetingIDPath.FindStringSubmatch(u.Path) + if len(m) == 2 { + return m[1], true + } + } + if params := event.ConferenceData.Parameters; params != nil && params.AddOnParameters != nil { + if uuid := strings.TrimSpace(params.AddOnParameters.Parameters["meetingUuid"]); uuid != "" { + return uuid, true + } + } + return "", false +} + +func eventConferenceProvider(event *calendar.Event) string { + if event == nil { + return "" + } + // Description-mode Zoom block takes precedence: gog writes its Zoom info + // here and never sets conferenceData on the Zoom path. + if descriptionHasZoomBlock(event.Description) { + return conferenceProviderZoom + } + if event.ConferenceData == nil { + if strings.TrimSpace(eventHangoutLink(event)) != "" { + return conferenceProviderMeet + } + return "" + } + if isZoomConferenceData(event.ConferenceData) { + return conferenceProviderZoom + } + if strings.TrimSpace(event.HangoutLink) != "" { + return conferenceProviderMeet + } + if sol := event.ConferenceData.ConferenceSolution; sol != nil && sol.Key != nil && sol.Key.Type == "hangoutsMeet" { + return conferenceProviderMeet + } + for _, ep := range event.ConferenceData.EntryPoints { + if ep == nil { + continue + } + uri := strings.ToLower(strings.TrimSpace(ep.Uri)) + if strings.Contains(uri, "meet.google.com") { + return conferenceProviderMeet + } + if strings.Contains(uri, "zoom.us") || strings.Contains(uri, "zoomgov.com") { + return conferenceProviderZoom + } + } + if eventHasConferenceLink(event) { + return "other" + } + return "" +} + +func eventHangoutLink(event *calendar.Event) string { + if event == nil { + return "" + } + return event.HangoutLink +} + +func isZoomConferenceData(data *calendar.ConferenceData) bool { + if data == nil { + return false + } + if sol := data.ConferenceSolution; sol != nil { + if strings.EqualFold(strings.TrimSpace(sol.Name), "Zoom Meeting") { + return true + } + } + for _, ep := range data.EntryPoints { + if ep == nil { + continue + } + uri := strings.ToLower(strings.TrimSpace(ep.Uri)) + if strings.Contains(uri, "zoom.us") || strings.Contains(uri, "zoomgov.com") { + return true + } + } + return false +} + +func redactEventZoomURLs(event *calendar.Event, includePasswords bool) { + if includePasswords || event == nil { + return + } + event.Description = redactZoomDescription(event.Description) + if event.ConferenceData == nil { + return + } + for _, ep := range event.ConferenceData.EntryPoints { + if ep != nil { + ep.Uri = zoom.RedactZoomURL(ep.Uri) + } + } +} + +func redactCalendarEventForOutput(ctx context.Context, event *calendar.Event) { + redactEventZoomURLs(event, zoomIncludePasswordsFromContext(ctx)) +} + +func redactCalendarEventsForOutput(ctx context.Context, events []*calendar.Event) { + for _, event := range events { + redactCalendarEventForOutput(ctx, event) + } +} + +func warnUnparseableZoomMeeting(u *ui.UI) { + if u != nil { + u.Err().Println("warning\tcould not find prior Zoom meeting ID; Calendar conference data will still be replaced") + return + } + fmt.Fprintln(os.Stderr, "warning\tcould not find prior Zoom meeting ID; Calendar conference data will still be replaced") +} diff --git a/internal/cmd/calendar_zoom_test.go b/internal/cmd/calendar_zoom_test.go new file mode 100644 index 00000000..f281bb23 --- /dev/null +++ b/internal/cmd/calendar_zoom_test.go @@ -0,0 +1,600 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "google.golang.org/api/calendar/v3" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/zoom" +) + +func newCalendarServiceFromZoomTestServer(t *testing.T, ctx context.Context, srv *httptest.Server) *calendar.Service { + t.Helper() + svc, err := calendar.NewService(ctx, + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + return svc +} + +type fakeZoomCalendarClient struct { + created int + deleted []string + err error +} + +func (f *fakeZoomCalendarClient) CreateMeeting(context.Context, string, zoom.CreateMeetingRequest) (*zoom.Meeting, error) { + if f.err != nil { + return nil, f.err + } + f.created++ + return &zoom.Meeting{ + ID: int64(1000 + f.created), + JoinURL: "https://example.zoom.us/j/1001?pwd=secret", + Password: "secret", + IconURI: "https://example.com/zoom.png", + }, nil +} + +func (f *fakeZoomCalendarClient) DeleteMeeting(_ context.Context, id string) error { + f.deleted = append(f.deleted, id) + return f.err +} + +func withFakeZoomClient(t *testing.T, client *fakeZoomCalendarClient) { + t.Helper() + orig := newZoomMeetingClient + newZoomMeetingClient = func(string) (zoomMeetingClient, error) { + if client.err != nil && errors.Is(client.err, zoom.ErrCredentialsNotFound) { + return nil, client.err + } + return client, nil + } + t.Cleanup(func() { newZoomMeetingClient = orig }) +} + +func TestCalendarCreateCmd_WithZoomAndAttachments(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + zoomClient := &fakeZoomCalendarClient{} + withFakeZoomClient(t, zoomClient) + + var sawZoomDescription, sawNoConferenceData, sawAttachments bool + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + if r.Method == http.MethodPost && path == "/calendars/cal@example.com/events" { + var body calendar.Event + _ = json.NewDecoder(r.Body).Decode(&body) + // Zoom info lives in the event description, not conferenceData. + // Google rejects conferenceData writes asserting key.type="addOn" + // from non-Workspace-Marketplace OAuth clients. + sawZoomDescription = descriptionHasZoomBlock(body.Description) + sawNoConferenceData = body.ConferenceData == nil + sawAttachments = len(body.Attachments) > 0 + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + return + } + http.NotFound(w, r) + }))) + defer srv.Close() + newCalendarService = func(ctx context.Context, _ string) (*calendar.Service, error) { + return newCalendarServiceFromZoomTestServer(t, ctx, srv), nil + } + ctx := newCalendarJSONOutputContext(t, os.Stdout, os.Stderr) + cmd := &CalendarCreateCmd{} + if err := runKong(t, cmd, []string{ + "cal@example.com", "--summary", "Zoom", "--from", "2025-01-02T10:00:00Z", "--to", "2025-01-02T11:00:00Z", + "--with-zoom", "--attachment", "https://example.com/file", + }, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + if !sawZoomDescription || !sawNoConferenceData || !sawAttachments || zoomClient.created != 1 { + t.Fatalf("expected zoom description+attachments, sawZoomDescription=%v sawNoConferenceData=%v sawAttachments=%v created=%d", + sawZoomDescription, sawNoConferenceData, sawAttachments, zoomClient.created) + } +} + +func TestCalendarCreateCmd_DryRunWithZoomReportsZoomIntent(t *testing.T) { + origNewZoom := newZoomMeetingClient + newZoomMeetingClient = func(string) (zoomMeetingClient, error) { + t.Fatal("dry-run must not create Zoom client") + return nil, errors.New("unexpected Zoom client") + } + t.Cleanup(func() { newZoomMeetingClient = origNewZoom }) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--dry-run", + "--no-input", + "calendar", "create", "primary", + "--summary", "Zoom", + "--from", "2026-05-18T10:00:00Z", + "--to", "2026-05-18T10:30:00Z", + "--with-zoom", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var got struct { + DryRun bool `json:"dry_run"` + Request struct { + Zoom map[string]any `json:"zoom"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("json parse: %v\nout=%s", err, out) + } + if !got.DryRun || got.Request.Zoom["action"] != "create" || got.Request.Zoom["description_mode"] != true { + t.Fatalf("unexpected dry-run zoom payload: %#v", got) + } +} + +func TestCalendarUpdateCmd_DryRunWithZoomReportsZoomIntent(t *testing.T) { + origNewZoom := newZoomMeetingClient + origNewCalendar := newCalendarService + newZoomMeetingClient = func(string) (zoomMeetingClient, error) { + t.Fatal("dry-run must not create Zoom client") + return nil, errors.New("unexpected Zoom client") + } + newCalendarService = func(context.Context, string) (*calendar.Service, error) { + t.Fatal("dry-run must not create Calendar service") + return nil, errors.New("unexpected Calendar service") + } + t.Cleanup(func() { + newZoomMeetingClient = origNewZoom + newCalendarService = origNewCalendar + }) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--dry-run", + "--no-input", + "calendar", "update", "primary", "event-id", + "--regenerate-zoom", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var got struct { + DryRun bool `json:"dry_run"` + Request struct { + Zoom map[string]any `json:"zoom"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("json parse: %v\nout=%s", err, out) + } + if !got.DryRun || got.Request.Zoom["action"] != "regenerate" || got.Request.Zoom["description_mode"] != true { + t.Fatalf("unexpected dry-run zoom payload: %#v", got) + } +} + +func TestRedactEventZoomURLsRedactsDescriptionModePasswords(t *testing.T) { + event := &calendar.Event{ + Description: buildZoomDescriptionBlock(&zoom.Meeting{ + ID: 1001, + JoinURL: "https://example.zoom.us/j/1001?pwd=secret&from=addon", + }), + } + + redactEventZoomURLs(event, false) + + if strings.Contains(event.Description, "secret") { + t.Fatalf("description leaked password: %s", event.Description) + } + if !strings.Contains(event.Description, "pwd=REDACTED") { + t.Fatalf("description did not redact join URL password: %s", event.Description) + } + if !strings.Contains(event.Description, "Passcode: REDACTED") { + t.Fatalf("description did not redact passcode line: %s", event.Description) + } +} + +func TestRedactEventZoomURLsIncludePasswordsPreservesDescriptionModePasswords(t *testing.T) { + event := &calendar.Event{ + Description: buildZoomDescriptionBlock(&zoom.Meeting{ + ID: 1001, + JoinURL: "https://example.zoom.us/j/1001?pwd=secret&from=addon", + }), + } + + redactEventZoomURLs(event, true) + + if !strings.Contains(event.Description, "secret") { + t.Fatalf("description should preserve password with includePasswords: %s", event.Description) + } +} + +func TestRedactEventZoomURLsLeavesUnmanagedDescriptionText(t *testing.T) { + event := &calendar.Event{ + Description: "Agenda\nPasscode: keep-me\nhttps://example.com/path?pwd=not-zoom", + } + + redactEventZoomURLs(event, false) + + if !strings.Contains(event.Description, "keep-me") || !strings.Contains(event.Description, "not-zoom") { + t.Fatalf("unmanaged description text should be preserved: %s", event.Description) + } +} + +func TestCalendarEventCmd_RedactsZoomDescriptionInJSON(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + desc := buildZoomDescriptionBlock(&zoom.Meeting{ + ID: 1001, + JoinURL: "https://example.zoom.us/j/1001?pwd=secret", + }) + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + switch { + case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "ev", + "summary": "Zoom", + "description": desc, + "start": map[string]any{"dateTime": "2026-05-18T10:00:00Z"}, + "end": map[string]any{"dateTime": "2026-05-18T10:30:00Z"}, + }) + case r.Method == http.MethodGet && path == "/calendars/cal@example.com": + _ = json.NewEncoder(w).Encode(map[string]any{"id": "cal@example.com", "timeZone": "UTC"}) + default: + http.NotFound(w, r) + } + }))) + defer srv.Close() + newCalendarService = func(ctx context.Context, _ string) (*calendar.Service, error) { + return newCalendarServiceFromZoomTestServer(t, ctx, srv), nil + } + + out := captureStdout(t, func() { + ctx := newCalendarJSONOutputContext(t, os.Stdout, os.Stderr) + if err := runKong(t, &CalendarEventCmd{}, []string{"cal@example.com", "ev"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + }) + + if strings.Contains(out, "secret") { + t.Fatalf("event output leaked zoom password: %s", out) + } + if !strings.Contains(out, "pwd=REDACTED") || !strings.Contains(out, "Passcode: REDACTED") { + t.Fatalf("event output did not redact zoom password: %s", out) + } +} + +func TestListCalendarEventsJSONRedactsZoomDescription(t *testing.T) { + desc := buildZoomDescriptionBlock(&zoom.Meeting{ + ID: 1001, + JoinURL: "https://example.zoom.us/j/1001?pwd=secret", + }) + svc, closeServer := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/calendars/cal1/events") && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"items":[{"id":"e1","summary":"Zoom","description":%q,"start":{"dateTime":"2026-05-18T10:00:00Z"},"end":{"dateTime":"2026-05-18T10:30:00Z"}}]}`, desc) + return + } + http.NotFound(w, r) + })) + defer closeServer() + + out := captureStdout(t, func() { + ctx := newCalendarJSONContext(t) + if err := listCalendarEvents(ctx, svc, "cal1", "2026-05-18T00:00:00Z", "2026-05-19T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "", ""); err != nil { + t.Fatalf("listCalendarEvents: %v", err) + } + }) + + if strings.Contains(out, "secret") { + t.Fatalf("events output leaked zoom password: %s", out) + } + if !strings.Contains(out, "pwd=REDACTED") || !strings.Contains(out, "Passcode: REDACTED") { + t.Fatalf("events output did not redact zoom password: %s", out) + } +} + +func TestCalendarUpdateCmd_WithZoom(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + zoomClient := &fakeZoomCalendarClient{} + withFakeZoomClient(t, zoomClient) + + var sawZoomPatch, sawNoConferenceData bool + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + switch { + case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev": + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ev", "summary": "Existing"}) + case r.Method == http.MethodPatch && path == "/calendars/cal@example.com/events/ev": + var body calendar.Event + _ = json.NewDecoder(r.Body).Decode(&body) + // Description-mode: patch carries the Zoom block in the + // description, not conferenceData. conferenceDataVersion is not + // required because we are not mutating conferenceData. + sawZoomPatch = descriptionHasZoomBlock(body.Description) + sawNoConferenceData = body.ConferenceData == nil + _ = json.NewEncoder(w).Encode(body) + default: + http.NotFound(w, r) + } + }))) + defer srv.Close() + newCalendarService = func(ctx context.Context, _ string) (*calendar.Service, error) { + return newCalendarServiceFromZoomTestServer(t, ctx, srv), nil + } + ctx := newCalendarJSONOutputContext(t, os.Stdout, os.Stderr) + if err := runKong(t, &CalendarUpdateCmd{}, []string{"cal@example.com", "ev", "--with-zoom"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + if !sawZoomPatch || !sawNoConferenceData || zoomClient.created != 1 { + t.Fatalf("expected zoom patch/no-conference-data/create, sawZoomPatch=%v sawNoConferenceData=%v created=%d", + sawZoomPatch, sawNoConferenceData, zoomClient.created) + } +} + +func TestCalendarUpdateCmd_WithZoomExistingConferenceIsIdempotent(t *testing.T) { + testCalendarUpdateWithZoomExistingConferenceIsIdempotent(t, "all") +} + +func TestCalendarUpdateCmd_WithZoomScopeFutureExistingConferenceIsIdempotent(t *testing.T) { + testCalendarUpdateWithZoomExistingConferenceIsIdempotent(t, "future") +} + +func testCalendarUpdateWithZoomExistingConferenceIsIdempotent(t *testing.T, scope string) { + t.Helper() + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + zoomClient := &fakeZoomCalendarClient{} + withFakeZoomClient(t, zoomClient) + var patchCalled bool + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + switch { + case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev": + event := zoomEventJSON("ev", "1001") + if scope == "future" { + event["recurringEventId"] = "series" + } + _ = json.NewEncoder(w).Encode(event) + case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/series": + _ = json.NewEncoder(w).Encode(map[string]any{"id": "series", "recurrence": []string{"RRULE:FREQ=DAILY"}}) + case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev/instances": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"id": "ev", "originalStartTime": map[string]any{"dateTime": "2025-01-02T10:00:00Z"}}}}) + case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/series/instances": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"id": "ev", "originalStartTime": map[string]any{"dateTime": "2025-01-02T10:00:00Z"}}}}) + case r.Method == http.MethodPatch: + patchCalled = true + _ = json.NewEncoder(w).Encode(map[string]any{"id": "patched"}) + default: + http.NotFound(w, r) + } + }))) + defer srv.Close() + newCalendarService = func(ctx context.Context, _ string) (*calendar.Service, error) { + return newCalendarServiceFromZoomTestServer(t, ctx, srv), nil + } + args := []string{"cal@example.com", "ev", "--with-zoom"} + if scope == "future" { + args = append(args, "--scope", "future", "--original-start", "2025-01-02T10:00:00Z") + } + ctx := newCalendarJSONOutputContext(t, os.Stdout, os.Stderr) + if err := runKong(t, &CalendarUpdateCmd{}, args, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + if patchCalled || zoomClient.created != 0 { + t.Fatalf("expected idempotent skip, patchCalled=%v created=%d", patchCalled, zoomClient.created) + } +} + +func TestCalendarUpdateCmd_RegenerateZoomReplacesConference(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + zoomClient := &fakeZoomCalendarClient{} + withFakeZoomClient(t, zoomClient) + var sawPatch bool + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + switch { + case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev": + _ = json.NewEncoder(w).Encode(zoomEventJSON("ev", "999")) + case r.Method == http.MethodPatch && path == "/calendars/cal@example.com/events/ev": + sawPatch = true + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ev"}) + default: + http.NotFound(w, r) + } + }))) + defer srv.Close() + newCalendarService = func(ctx context.Context, _ string) (*calendar.Service, error) { + return newCalendarServiceFromZoomTestServer(t, ctx, srv), nil + } + ctx := newCalendarJSONOutputContext(t, os.Stdout, os.Stderr) + if err := runKong(t, &CalendarUpdateCmd{}, []string{"cal@example.com", "ev", "--regenerate-zoom"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + if !sawPatch || zoomClient.created != 1 || len(zoomClient.deleted) != 1 || zoomClient.deleted[0] != "999" { + t.Fatalf("expected delete/create/patch, sawPatch=%v created=%d deleted=%v", sawPatch, zoomClient.created, zoomClient.deleted) + } +} + +func TestCalendarUpdateCmd_RemoveZoom(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + zoomClient := &fakeZoomCalendarClient{} + withFakeZoomClient(t, zoomClient) + var cleared bool + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + switch { + case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev": + _ = json.NewEncoder(w).Encode(zoomEventJSON("ev", "999")) + case r.Method == http.MethodPatch && path == "/calendars/cal@example.com/events/ev": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + _, cleared = body["conferenceData"] + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ev"}) + default: + http.NotFound(w, r) + } + }))) + defer srv.Close() + newCalendarService = func(ctx context.Context, _ string) (*calendar.Service, error) { + return newCalendarServiceFromZoomTestServer(t, ctx, srv), nil + } + ctx := newCalendarJSONOutputContext(t, os.Stdout, os.Stderr) + if err := runKong(t, &CalendarUpdateCmd{}, []string{"cal@example.com", "ev", "--remove-zoom"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + if !cleared || len(zoomClient.deleted) != 1 || zoomClient.deleted[0] != "999" { + t.Fatalf("expected cleared/delete, cleared=%v deleted=%v", cleared, zoomClient.deleted) + } +} + +func TestCalendarUpdateCmd_WithZoomOnExistingMeetEventRejects(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + withFakeZoomClient(t, &fakeZoomCalendarClient{}) + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ev", "hangoutLink": "https://meet.google.com/aaa-bbbb-ccc"}) + return + } + http.NotFound(w, r) + }))) + defer srv.Close() + newCalendarService = func(ctx context.Context, _ string) (*calendar.Service, error) { + return newCalendarServiceFromZoomTestServer(t, ctx, srv), nil + } + err := runKong(t, &CalendarUpdateCmd{}, []string{"cal@example.com", "ev", "--with-zoom"}, newCalendarJSONOutputContext(t, os.Stdout, os.Stderr), &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), "event already has a Meet conference") { + t.Fatalf("error = %v, want existing Meet rejection", err) + } +} + +func TestCalendarUpdateCmd_WithZoomNoCredentialsErrors(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + withFakeZoomClient(t, &fakeZoomCalendarClient{err: zoom.ErrCredentialsNotFound}) + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ev"}) + return + } + http.NotFound(w, r) + }))) + defer srv.Close() + newCalendarService = func(ctx context.Context, _ string) (*calendar.Service, error) { + return newCalendarServiceFromZoomTestServer(t, ctx, srv), nil + } + err := runKong(t, &CalendarUpdateCmd{}, []string{"cal@example.com", "ev", "--with-zoom"}, newCalendarJSONOutputContext(t, os.Stdout, os.Stderr), &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), "Zoom credentials not found") { + t.Fatalf("error = %v, want credentials message", err) + } +} + +func TestCalendarUpdateCmd_RegenerateZoomWithUnparseablePriorMeetingWarns(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + zoomClient := &fakeZoomCalendarClient{} + withFakeZoomClient(t, zoomClient) + srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + switch { + case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "ev", + "conferenceData": map[string]any{ + "conferenceSolution": map[string]any{"key": map[string]any{"type": "addOn"}, "name": "Zoom Meeting"}, + "entryPoints": []map[string]any{{"entryPointType": "video", "uri": "https://example.zoom.us/not-a-meeting"}}, + }, + }) + case r.Method == http.MethodPatch: + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ev"}) + default: + http.NotFound(w, r) + } + }))) + defer srv.Close() + newCalendarService = func(ctx context.Context, _ string) (*calendar.Service, error) { + return newCalendarServiceFromZoomTestServer(t, ctx, srv), nil + } + ctx := newCalendarJSONOutputContext(t, os.Stdout, os.Stderr) + err := runKong(t, &CalendarUpdateCmd{}, []string{"cal@example.com", "ev", "--regenerate-zoom"}, ctx, &RootFlags{Account: "a@b.com"}) + if err != nil { + t.Fatalf("runKong: %v", err) + } + if zoomClient.created != 1 || len(zoomClient.deleted) != 0 { + t.Fatalf("expected create without delete, created=%d deleted=%v", zoomClient.created, zoomClient.deleted) + } +} + +func TestCalendarUpdateCmd_FlagMutex_WithZoomRegenerateZoom(t *testing.T) { + assertCalendarUpdateZoomMutex(t, "--with-zoom", "--regenerate-zoom") +} + +func TestCalendarUpdateCmd_FlagMutex_WithZoomRemoveZoom(t *testing.T) { + assertCalendarUpdateZoomMutex(t, "--with-zoom", "--remove-zoom") +} + +func TestCalendarUpdateCmd_FlagMutex_RegenerateZoomRemoveZoom(t *testing.T) { + assertCalendarUpdateZoomMutex(t, "--regenerate-zoom", "--remove-zoom") +} + +func TestCalendarUpdateCmd_FlagMutex_WithZoomWithMeet(t *testing.T) { + assertCalendarUpdateZoomMutex(t, "--with-zoom", "--with-meet") +} + +func TestCalendarUpdateCmd_FlagMutex_WithZoomRegenerateMeet(t *testing.T) { + assertCalendarUpdateZoomMutex(t, "--with-zoom", "--regenerate-meet") +} + +func TestCalendarUpdateCmd_FlagMutex_RegenerateZoomWithMeet(t *testing.T) { + assertCalendarUpdateZoomMutex(t, "--regenerate-zoom", "--with-meet") +} + +func TestCalendarUpdateCmd_FlagMutex_RegenerateZoomRegenerateMeet(t *testing.T) { + assertCalendarUpdateZoomMutex(t, "--regenerate-zoom", "--regenerate-meet") +} + +func assertCalendarUpdateZoomMutex(t *testing.T, flags ...string) { + t.Helper() + args := append([]string{"cal@example.com", "ev"}, flags...) + err := runKong(t, &CalendarUpdateCmd{}, args, newCalendarJSONOutputContext(t, os.Stdout, os.Stderr), &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), "use only one of") { + t.Fatalf("error = %v, want mutex for %v", err, flags) + } +} + +func zoomEventJSON(id, meetingID string) map[string]any { + return map[string]any{ + "id": id, + "conferenceData": map[string]any{ + "conferenceSolution": map[string]any{"key": map[string]any{"type": "addOn"}, "name": "Zoom Meeting"}, + "entryPoints": []map[string]any{{ + "entryPointType": "video", + "uri": "https://example.zoom.us/j/" + meetingID + "?pwd=secret", + }}, + }, + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ed15e832..67319e80 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -85,6 +85,7 @@ type CLI struct { Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"` Sites SitesCmd `cmd:"" aliases:"site" help:"Google Sites (Drive-backed)"` Meet MeetCmd `cmd:"" aliases:"meeting" help:"Google Meet"` + Zoom ZoomCmd `cmd:"" help:"Zoom"` AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"` Analytics AnalyticsCmd `cmd:"" aliases:"ga" help:"Google Analytics"` SearchConsole SearchConsoleCmd `cmd:"" name:"searchconsole" aliases:"gsc,search-console,webmasters" help:"Google Search Console"` diff --git a/internal/cmd/zoom_auth.go b/internal/cmd/zoom_auth.go new file mode 100644 index 00000000..43567665 --- /dev/null +++ b/internal/cmd/zoom_auth.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "golang.org/x/term" + + "github.com/steipete/gogcli/internal/input" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" + "github.com/steipete/gogcli/internal/zoom" +) + +type ZoomCmd struct { + Auth ZoomAuthCmd `cmd:"" name:"auth" help:"Manage Zoom Server-to-Server OAuth credentials"` +} + +type ZoomAuthCmd struct { + Setup ZoomAuthSetupCmd `cmd:"" name:"setup" help:"Store Zoom Server-to-Server OAuth credentials"` + Doctor ZoomAuthDoctorCmd `cmd:"" name:"doctor" help:"Validate Zoom credentials"` +} + +type ZoomAuthSetupCmd struct { + Alias string `name:"alias" help:"Zoom credential alias" default:"default"` + AccountID string `name:"account-id" help:"Zoom Server-to-Server OAuth account ID" env:"GOG_ZOOM_ACCOUNT_ID"` + ClientID string `name:"client-id" help:"Zoom Server-to-Server OAuth client ID" env:"GOG_ZOOM_CLIENT_ID"` + ClientSecret string `name:"client-secret" help:"Zoom Server-to-Server OAuth client secret" env:"GOG_ZOOM_CLIENT_SECRET"` + SkipValidate bool `name:"skip-validate" help:"Store credentials without calling Zoom /users/me"` +} + +var defaultZoomScopes = []string{"meeting:write", "meeting:read", "user:read"} + +func (c *ZoomAuthSetupCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + alias := zoom.NormalizeAlias(c.Alias) + if flags != nil && flags.NoInput && (strings.TrimSpace(c.AccountID) == "" || strings.TrimSpace(c.ClientID) == "" || strings.TrimSpace(c.ClientSecret) == "") { + return usage("provide --account-id, --client-id, and --client-secret with --no-input") + } + if existing, err := zoom.LoadMetadata(alias); err == nil && flags != nil && !flags.Force { + return usage(fmt.Sprintf("Zoom credentials for alias %q already exist (account_id=%s); use --force to overwrite", alias, existing.AccountID)) + } + accountID, err := promptDefault(ctx, "Zoom account ID: ", c.AccountID) + if err != nil { + return err + } + clientID, err := promptDefault(ctx, "Zoom client ID: ", c.ClientID) + if err != nil { + return err + } + clientSecret := strings.TrimSpace(c.ClientSecret) + if clientSecret == "" { + clientSecret, err = promptSecret("Zoom client secret: ") + if err != nil { + return err + } + } + creds := zoom.Credentials{AccountID: accountID, ClientID: clientID, ClientSecret: clientSecret} + if !c.SkipValidate { + client, clientErr := zoom.NewClient(alias, creds) + if clientErr != nil { + return clientErr + } + if validateErr := client.Validate(ctx); validateErr != nil { + return fmt.Errorf("validate Zoom credentials: %w", validateErr) + } + } + if err := zoom.StoreCredentials(alias, zoom.Metadata{ + AccountID: accountID, + ClientID: clientID, + Scopes: defaultZoomScopes, + }, clientSecret); err != nil { + return err + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "saved": true, + "alias": alias, + "scopes": defaultZoomScopes, + }) + } + if u != nil { + u.Out().Linef("saved\ttrue") + u.Out().Linef("alias\t%s", alias) + u.Out().Linef("scopes\t%s", strings.Join(defaultZoomScopes, ",")) + } + return nil +} + +type ZoomAuthDoctorCmd struct { + Alias string `name:"alias" help:"Zoom credential alias" default:"default"` +} + +func (c *ZoomAuthDoctorCmd) Run(ctx context.Context, _ *RootFlags) error { + u := ui.FromContext(ctx) + alias := zoom.NormalizeAlias(c.Alias) + checks := make([]authDoctorCheck, 0) + add := func(name, status, detail, hint string) { + checks = append(checks, authDoctorCheck{Name: name, Status: status, Detail: detail, Hint: hint}) + } + creds, err := zoom.LoadCredentials(alias) + if err != nil { + add("zoom.credentials", doctorError, err.Error(), "run `gog zoom auth setup`") + return writeZoomDoctorResult(ctx, u, checks) + } + add("zoom.credentials", doctorOK, "loaded", "") + if zoom.EnvClientSecretSet() { + add("zoom.env_secret", doctorWarn, "GOG_ZOOM_CLIENT_SECRET is set", "environment secrets can be visible to same-user processes; prefer `gog zoom auth setup`") + } + if expiresAt, ok := zoom.CachedTokenExpiry(alias); ok { + add("zoom.token_cache", doctorOK, expiresAt.UTC().Format(time.RFC3339), "") + } else { + add("zoom.token_cache", doctorWarn, "no cached token", "a token will be fetched on first Zoom API call") + } + client, clientErr := zoom.NewClient(alias, creds) + if clientErr != nil { + add("zoom.client", doctorError, clientErr.Error(), "") + return writeZoomDoctorResult(ctx, u, checks) + } + if validateErr := client.Validate(ctx); validateErr != nil { + add("zoom.validate", doctorError, validateErr.Error(), "verify account ID, client ID, client secret, and scopes") + } else { + add("zoom.validate", doctorOK, "Zoom /users/me succeeded", "") + } + return writeZoomDoctorResult(ctx, u, checks) +} + +func writeZoomDoctorResult(ctx context.Context, u *ui.UI, checks []authDoctorCheck) error { + status := authDoctorStatus(checks) + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"status": status, "checks": checks}) + } + if u != nil { + for _, check := range checks { + u.Out().Linef("%s\t%s\t%s", check.Status, check.Name, check.Detail) + if check.Hint != "" { + u.Out().Linef("hint\t%s\t%s", check.Name, check.Hint) + } + } + u.Out().Linef("status\t%s", status) + } + return nil +} + +func promptDefault(ctx context.Context, prompt, value string) (string, error) { + value = strings.TrimSpace(value) + if value != "" { + return value, nil + } + line, err := input.PromptLine(ctx, prompt) + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil +} + +func promptSecret(prompt string) (string, error) { + _, _ = fmt.Fprint(os.Stderr, prompt) + b, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // os file descriptor fits int on supported targets + _, _ = fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} diff --git a/internal/cmd/zoom_auth_test.go b/internal/cmd/zoom_auth_test.go new file mode 100644 index 00000000..9834824e --- /dev/null +++ b/internal/cmd/zoom_auth_test.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/steipete/gogcli/internal/zoom" +) + +func withTempZoomAuthStore(t *testing.T) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("GOG_KEYRING_BACKEND", "file") + t.Setenv("GOG_KEYRING_PASSWORD", "test-pass") + t.Setenv("GOG_ZOOM_ACCOUNT_ID", "") + t.Setenv("GOG_ZOOM_CLIENT_ID", "") + t.Setenv("GOG_ZOOM_CLIENT_SECRET", "") +} + +func TestZoomAuthSetupCmd_StoresCredentialsWithoutValidation(t *testing.T) { + withTempZoomAuthStore(t) + cmd := &ZoomAuthSetupCmd{ + Alias: "work", + AccountID: "acct", + ClientID: "client", + ClientSecret: "secret", + SkipValidate: true, + } + if err := cmd.Run(newCmdJSONContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + creds, err := zoom.LoadCredentials("work") + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if creds.AccountID != "acct" || creds.ClientID != "client" || creds.ClientSecret != "secret" { + t.Fatalf("unexpected creds: %#v", creds) + } +} + +func TestZoomAuthDoctorCmd_NoCredentials(t *testing.T) { + withTempZoomAuthStore(t) + if err := (&ZoomAuthDoctorCmd{}).Run(newCmdJSONContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestZoomAuthSetupCmd_NoInputRequiresFlags(t *testing.T) { + withTempZoomAuthStore(t) + err := (&ZoomAuthSetupCmd{SkipValidate: true}).Run(context.Background(), &RootFlags{NoInput: true}) + if err == nil { + t.Fatalf("expected usage error") + } +} diff --git a/internal/cmd/zoom_context.go b/internal/cmd/zoom_context.go new file mode 100644 index 00000000..61d97e4a --- /dev/null +++ b/internal/cmd/zoom_context.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "context" + + "github.com/steipete/gogcli/internal/zoom" +) + +type zoomIncludePasswordsContextKey struct{} + +func withZoomIncludePasswords(ctx context.Context, include bool) context.Context { + return context.WithValue(ctx, zoomIncludePasswordsContextKey{}, include) +} + +func zoomIncludePasswordsFromContext(ctx context.Context) bool { + if include, ok := ctx.Value(zoomIncludePasswordsContextKey{}).(bool); ok { + return include + } + return zoom.IncludePasswordsFromEnv() +} diff --git a/internal/cmd/zoom_description.go b/internal/cmd/zoom_description.go new file mode 100644 index 00000000..4bef6e91 --- /dev/null +++ b/internal/cmd/zoom_description.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "google.golang.org/api/calendar/v3" + + "github.com/steipete/gogcli/internal/zoom" +) + +// Zoom info is attached to Calendar events via the event description rather +// than conferenceData. Google's Calendar API rejects conferenceData writes that +// assert conferenceSolution.key.type="addOn" from non-Workspace-Marketplace +// OAuth clients (400 "Invalid conference data") and silently drops the field +// entirely when key.type is omitted. Description-mode preserves the Zoom join +// URL + meeting ID + passcode in a form that round-trips through Google's +// storage and renders as clickable text in every Calendar UI; the trade-off is +// no native "Join with Zoom" conference card. +// +// The description block is delimited by HTML comment markers so it can be +// detected, replaced, or removed on subsequent --regenerate-zoom / --remove-zoom +// operations without disturbing other description content the user has typed. + +const ( + zoomBlockStartFmt = "" + zoomBlockEnd = "" +) + +var ( + zoomDescriptionBlockRE = regexp.MustCompile(`(?s).*?`) + zoomDescriptionURLRE = regexp.MustCompile(`https?://[^\s<>"']+`) + zoomDescriptionPasscodeRE = regexp.MustCompile(`(?m)^(Passcode:\s*)\S+`) + zoomDescriptionBlankLinesRE = regexp.MustCompile(`\n{3,}`) +) + +// buildZoomDescriptionBlock formats a Zoom meeting into a description block +// with stable start/end markers. Returns the empty string when meeting is nil +// or has no join URL. +func buildZoomDescriptionBlock(meeting *zoom.Meeting) string { + if meeting == nil { + return "" + } + join := strings.TrimSpace(meeting.JoinURL) + if join == "" { + return "" + } + id := zoomMeetingID(meeting) + pwd := "" + if u, err := url.Parse(join); err == nil { + pwd = u.Query().Get("pwd") + } + + var b strings.Builder + fmt.Fprintf(&b, zoomBlockStartFmt, id) + b.WriteString("\nJoin Zoom Meeting: ") + b.WriteString(join) + if id != "" { + b.WriteString("\nMeeting ID: ") + b.WriteString(id) + } + if pwd != "" { + b.WriteString("\nPasscode: ") + b.WriteString(pwd) + } + b.WriteString("\n") + b.WriteString(zoomBlockEnd) + return b.String() +} + +// applyZoomDescriptionBlock returns desc with the given Zoom block applied: +// any existing gog-managed Zoom block is removed first, then the new block is +// appended (separated from prior content by a blank line if prior content +// exists). Passing an empty block removes any existing block without appending. +func applyZoomDescriptionBlock(desc, block string) string { + stripped := removeZoomDescriptionBlock(desc) + if block == "" { + return stripped + } + if strings.TrimSpace(stripped) == "" { + return block + } + return strings.TrimRight(stripped, "\n") + "\n\n" + block +} + +// removeZoomDescriptionBlock returns desc with any gog-managed Zoom block +// stripped out. Surrounding whitespace is trimmed so a description that +// consisted only of a Zoom block becomes empty rather than blank lines. +func removeZoomDescriptionBlock(desc string) string { + if desc == "" { + return "" + } + out := zoomDescriptionBlockRE.ReplaceAllString(desc, "") + // Collapse runs of blank lines that may be left behind. + out = zoomDescriptionBlankLinesRE.ReplaceAllString(out, "\n\n") + return strings.Trim(out, "\n ") +} + +func redactZoomDescription(desc string) string { + if desc == "" { + return "" + } + return zoomDescriptionBlockRE.ReplaceAllStringFunc(desc, func(block string) string { + out := zoomDescriptionURLRE.ReplaceAllStringFunc(block, zoom.RedactZoomURL) + return zoomDescriptionPasscodeRE.ReplaceAllString(out, "${1}REDACTED") + }) +} + +// extractZoomMeetingIDFromDescription returns the meeting ID embedded in the +// gog-zoom-meeting start marker, if present. +func extractZoomMeetingIDFromDescription(desc string) (string, bool) { + if desc == "" { + return "", false + } + m := zoomDescriptionBlockRE.FindStringSubmatch(desc) + if len(m) < 2 { + return "", false + } + id := strings.TrimSpace(m[1]) + if id == "" { + return "", false + } + return id, true +} + +// descriptionHasZoomBlock reports whether the description contains a +// gog-managed Zoom block. +func descriptionHasZoomBlock(desc string) bool { + return zoomDescriptionBlockRE.MatchString(desc) +} + +// descriptionForPatch returns the description to base a description-mutation +// patch on. If the patch already carries a description (the caller is editing +// it), use that as the starting point; otherwise fall back to the existing +// event's description. The existing event is fetched once per patch flow. +func descriptionForPatch(existing, patch *calendar.Event) string { + if patch != nil && strings.TrimSpace(patch.Description) != "" { + return patch.Description + } + if existing != nil { + return existing.Description + } + return "" +} diff --git a/internal/zoom/client.go b/internal/zoom/client.go new file mode 100644 index 00000000..4d23e95a --- /dev/null +++ b/internal/zoom/client.go @@ -0,0 +1,266 @@ +//nolint:wsl_v5 +package zoom + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + defaultAPIBaseURL = "https://api.zoom.us/v2" + defaultTokenURL = "https://zoom.us/oauth/token" //nolint:gosec // OAuth token endpoint URL, not a credential. + tokenRefreshSkew = 60 * time.Second +) + +var ( + ErrCredentialsNotFound = errors.New("Zoom credentials not found. Run `gog zoom auth setup` or set GOG_ZOOM_ACCOUNT_ID, GOG_ZOOM_CLIENT_ID, GOG_ZOOM_CLIENT_SECRET.") //nolint:staticcheck // Exact user-facing string required by issue #589. + ErrMeetingNotFound = errors.New("zoom meeting not found") + ErrZoomRequestFailed = errors.New("zoom request failed") + ErrZoomTokenRequestFailed = errors.New("zoom token request failed") + ErrZoomTokenMissingAccessKey = errors.New("zoom token response missing access_token") +) + +type Credentials struct { + AccountID string + ClientID string + ClientSecret string +} + +type Client struct { + credentials Credentials + alias string + httpClient *http.Client + now func() time.Time +} + +type Option func(*Client) + +func WithRoundTripper(rt http.RoundTripper) Option { + return func(c *Client) { + c.httpClient = &http.Client{Transport: rt} + } +} + +func WithHTTPClient(httpClient *http.Client) Option { + return func(c *Client) { + if httpClient != nil { + c.httpClient = httpClient + } + } +} + +func WithNow(now func() time.Time) Option { + return func(c *Client) { + if now != nil { + c.now = now + } + } +} + +func NewClient(alias string, credentials Credentials, opts ...Option) (*Client, error) { + if strings.TrimSpace(credentials.AccountID) == "" || + strings.TrimSpace(credentials.ClientID) == "" || + strings.TrimSpace(credentials.ClientSecret) == "" { + return nil, ErrCredentialsNotFound + } + c := &Client{ + credentials: Credentials{ + AccountID: strings.TrimSpace(credentials.AccountID), + ClientID: strings.TrimSpace(credentials.ClientID), + ClientSecret: strings.TrimSpace(credentials.ClientSecret), + }, + alias: NormalizeAlias(alias), + httpClient: http.DefaultClient, + now: time.Now, + } + for _, opt := range opts { + opt(c) + } + return c, nil +} + +type Meeting struct { + ID int64 `json:"id,omitempty"` + UUID string `json:"uuid,omitempty"` + Topic string `json:"topic,omitempty"` + JoinURL string `json:"join_url,omitempty"` + Password string `json:"password,omitempty"` + StartURL string `json:"start_url,omitempty"` + HostEmail string `json:"host_email,omitempty"` + IconURI string `json:"icon_uri,omitempty"` +} + +type CreateMeetingRequest struct { + Topic string `json:"topic,omitempty"` + Type int `json:"type,omitempty"` + StartTime time.Time `json:"-"` + Duration int `json:"duration,omitempty"` + Timezone string `json:"timezone,omitempty"` + Agenda string `json:"agenda,omitempty"` +} + +type createMeetingJSON struct { + Topic string `json:"topic,omitempty"` + Type int `json:"type,omitempty"` + StartTime string `json:"start_time,omitempty"` + Duration int `json:"duration,omitempty"` + Timezone string `json:"timezone,omitempty"` + Agenda string `json:"agenda,omitempty"` +} + +func (c *Client) CreateMeeting(ctx context.Context, userID string, req CreateMeetingRequest) (*Meeting, error) { + userID = strings.TrimSpace(userID) + if userID == "" { + userID = "me" + } + if req.Type == 0 { + req.Type = 2 + } + payload := createMeetingJSON{ + Topic: strings.TrimSpace(req.Topic), + Type: req.Type, + Duration: req.Duration, + Timezone: strings.TrimSpace(req.Timezone), + Agenda: strings.TrimSpace(req.Agenda), + } + if !req.StartTime.IsZero() { + payload.StartTime = req.StartTime.UTC().Format("2006-01-02T15:04:05Z") + } + var meeting Meeting + if err := c.doJSON(ctx, http.MethodPost, defaultAPIBaseURL+"/users/"+url.PathEscape(userID)+"/meetings", payload, &meeting); err != nil { + return nil, err + } + return &meeting, nil +} + +func (c *Client) DeleteMeeting(ctx context.Context, meetingID string) error { + meetingID = strings.TrimSpace(meetingID) + if meetingID == "" { + return ErrMeetingNotFound + } + err := c.doJSON(ctx, http.MethodDelete, defaultAPIBaseURL+"/meetings/"+url.PathEscape(meetingID), nil, nil) + if errors.Is(err, ErrMeetingNotFound) { + return nil + } + return err +} + +func (c *Client) Validate(ctx context.Context) error { + return c.doJSON(ctx, http.MethodGet, defaultAPIBaseURL+"/users/me", nil, nil) +} + +func (c *Client) doJSON(ctx context.Context, method, endpoint string, payload any, out any) error { + token, err := c.accessToken(ctx) + if err != nil { + return err + } + var body io.Reader + if payload != nil { + b, marshalErr := json.Marshal(payload) + if marshalErr != nil { + return fmt.Errorf("encode zoom request: %w", marshalErr) + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return fmt.Errorf("build zoom request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("zoom request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { + return ErrMeetingNotFound + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%w: %s: %s", ErrZoomRequestFailed, resp.Status, readSmallBody(resp.Body)) + } + if out == nil || resp.StatusCode == http.StatusNoContent { + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode zoom response: %w", err) + } + return nil +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` +} + +func (c *Client) accessToken(ctx context.Context) (string, error) { + if tok, ok := c.cachedAccessToken(); ok { + return tok, nil + } + values := url.Values{} + values.Set("grant_type", "account_credentials") + values.Set("account_id", c.credentials.AccountID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, defaultTokenURL+"?"+values.Encode(), nil) + if err != nil { + return "", fmt.Errorf("build zoom token request: %w", err) + } + req.Header.Set("Authorization", "Basic "+basicAuth(c.credentials.ClientID, c.credentials.ClientSecret)) + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("zoom token request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%w: %s: %s", ErrZoomTokenRequestFailed, resp.Status, readSmallBody(resp.Body)) + } + var decoded tokenResponse + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return "", fmt.Errorf("decode zoom token response: %w", err) + } + if strings.TrimSpace(decoded.AccessToken) == "" { + return "", ErrZoomTokenMissingAccessKey + } + expiresIn := decoded.ExpiresIn + if expiresIn <= 0 { + expiresIn = 3600 + } + _ = StoreCachedToken(c.alias, CachedToken{ + AccessToken: decoded.AccessToken, + ExpiresAt: c.now().UTC().Add(time.Duration(expiresIn) * time.Second), + }) + return decoded.AccessToken, nil +} + +func (c *Client) cachedAccessToken() (string, bool) { + tok, err := LoadCachedToken(c.alias) + if err != nil { + return "", false + } + if strings.TrimSpace(tok.AccessToken) == "" { + return "", false + } + if !tok.ExpiresAt.After(c.now().UTC().Add(tokenRefreshSkew)) { + return "", false + } + return tok.AccessToken, true +} + +func basicAuth(clientID, clientSecret string) string { + return base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) +} + +func readSmallBody(r io.Reader) string { + b, _ := io.ReadAll(io.LimitReader(r, 4096)) + return strings.TrimSpace(string(b)) +} diff --git a/internal/zoom/client_test.go b/internal/zoom/client_test.go new file mode 100644 index 00000000..422fed79 --- /dev/null +++ b/internal/zoom/client_test.go @@ -0,0 +1,140 @@ +//nolint:wsl_v5 +package zoom + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +type rewriteTransport struct { + target string + base http.RoundTripper +} + +func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + targetURL, err := url.Parse(rt.target) + if err != nil { + return nil, fmt.Errorf("parse test target url: %w", err) + } + clone.URL.Scheme = targetURL.Scheme + clone.URL.Host = targetURL.Host + resp, err := rt.base.RoundTrip(clone) + if err != nil { + return nil, fmt.Errorf("round trip rewritten zoom request: %w", err) + } + return resp, nil +} + +func newTestClient(t *testing.T, srv *httptest.Server, alias string, now time.Time) *Client { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("GOG_KEYRING_BACKEND", "file") + t.Setenv("GOG_KEYRING_PASSWORD", "test-pass") + client, err := NewClient(alias, Credentials{ + AccountID: "acct", + ClientID: "client", + ClientSecret: "secret", + }, WithRoundTripper(rewriteTransport{target: srv.URL, base: srv.Client().Transport}), WithNow(func() time.Time { return now })) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return client +} + +func TestClientTokenRefreshUsesCachedTokenUntilSkew(t *testing.T) { + now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + tokenRequests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/oauth/token": + tokenRequests++ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "expires_in": 3600}) + case r.Method == http.MethodPost && r.URL.Path == "/v2/users/me/meetings": + if got := r.Header.Get("Authorization"); got != "Bearer tok" { + t.Fatalf("Authorization = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": 123, "join_url": "https://example.zoom.us/j/123?pwd=abc"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := newTestClient(t, srv, "cache", now) + if _, err := client.CreateMeeting(context.Background(), "me", CreateMeetingRequest{}); err != nil { + t.Fatalf("CreateMeeting first: %v", err) + } + if _, err := client.CreateMeeting(context.Background(), "me", CreateMeetingRequest{}); err != nil { + t.Fatalf("CreateMeeting cached: %v", err) + } + if tokenRequests != 1 { + t.Fatalf("tokenRequests = %d, want 1", tokenRequests) + } + + if err := StoreCachedToken("cache", CachedToken{AccessToken: "stale", ExpiresAt: now.Add(30 * time.Second)}); err != nil { + t.Fatalf("StoreCachedToken: %v", err) + } + if _, err := client.CreateMeeting(context.Background(), "me", CreateMeetingRequest{}); err != nil { + t.Fatalf("CreateMeeting refresh: %v", err) + } + if tokenRequests != 2 { + t.Fatalf("tokenRequests = %d, want 2", tokenRequests) + } +} + +func TestClientDeleteMeetingIgnores404And410(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusGone} { + t.Run(http.StatusText(status), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth/token" { + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "expires_in": 3600}) + return + } + w.WriteHeader(status) + })) + defer srv.Close() + client := newTestClient(t, srv, strings.ReplaceAll(http.StatusText(status), " ", "-"), time.Now()) + if err := client.DeleteMeeting(context.Background(), "123"); err != nil { + t.Fatalf("DeleteMeeting: %v", err) + } + }) + } +} + +func TestClientDeleteMeeting5xxError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth/token" { + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "expires_in": 3600}) + return + } + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + client := newTestClient(t, srv, "fivehundred", time.Now()) + err := client.DeleteMeeting(context.Background(), "123") + if err == nil || !strings.Contains(err.Error(), "500") { + t.Fatalf("DeleteMeeting error = %v, want 500", err) + } +} + +func TestRedactZoomURL(t *testing.T) { + t.Setenv("GOG_ZOOM_INCLUDE_PASSWORDS", "") + got := RedactZoomURL("https://example.zoom.us/j/123?pwd=secret&x=1") + if strings.Contains(got, "secret") || !strings.Contains(got, "pwd=REDACTED") { + t.Fatalf("RedactZoomURL = %q", got) + } + t.Setenv("GOG_ZOOM_INCLUDE_PASSWORDS", "1") + if !IncludePasswordsFromEnv() { + t.Fatalf("expected include passwords env") + } +} diff --git a/internal/zoom/redact.go b/internal/zoom/redact.go new file mode 100644 index 00000000..970668e9 --- /dev/null +++ b/internal/zoom/redact.go @@ -0,0 +1,42 @@ +//nolint:wsl_v5 +package zoom + +import ( + "net/url" + "os" + "strings" +) + +const includePasswordsEnv = "GOG_ZOOM_INCLUDE_PASSWORDS" //nolint:gosec // env var name, not a credential value. + +func IncludePasswordsFromEnv() bool { + return os.Getenv(includePasswordsEnv) == "1" +} + +func RedactZoomURL(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return redactZoomPasswordFallback(raw) + } + q := u.Query() + if _, ok := q["pwd"]; !ok { + return raw + } + q.Set("pwd", "REDACTED") + u.RawQuery = q.Encode() + return u.String() +} + +func redactZoomPasswordFallback(raw string) string { + const key = "pwd=" + i := strings.Index(raw, key) + if i < 0 { + return raw + } + start := i + len(key) + end := len(raw) + if j := strings.IndexAny(raw[start:], "&#"); j >= 0 { + end = start + j + } + return raw[:start] + "REDACTED" + raw[end:] +} diff --git a/internal/zoom/store.go b/internal/zoom/store.go new file mode 100644 index 00000000..b6c2bb78 --- /dev/null +++ b/internal/zoom/store.go @@ -0,0 +1,199 @@ +//nolint:wsl_v5 +package zoom + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/secrets" +) + +const ( + defaultAlias = "default" + envAccountID = "GOG_ZOOM_ACCOUNT_ID" + envClientID = "GOG_ZOOM_CLIENT_ID" + envClientSecret = "GOG_ZOOM_CLIENT_SECRET" //nolint:gosec // env var name, not a credential value + clientSecretKeyFmt = "zoom-account/%s/client-secret" //nolint:gosec // keyring item name, not a secret value. + accessTokenKeyFmt = "zoom-account/%s/access-token" //nolint:gosec // keyring item name, not a secret value. + metadataFileMode = 0o600 + metadataDirMode = 0o700 + metadataDirComponent = "zoom" +) + +type Metadata struct { + AccountID string `json:"account_id"` + ClientID string `json:"client_id"` + Alias string `json:"alias,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + +type CachedToken struct { + AccessToken string `json:"access_token"` + ExpiresAt time.Time `json:"expires_at"` +} + +func NormalizeAlias(alias string) string { + alias = strings.TrimSpace(alias) + if alias == "" { + return defaultAlias + } + return alias +} + +func EnvClientSecretSet() bool { + _, ok := os.LookupEnv(envClientSecret) + return ok +} + +func LoadCredentials(alias string) (Credentials, error) { + if creds, ok := credentialsFromEnv(); ok { + return creds, nil + } + alias = NormalizeAlias(alias) + meta, err := LoadMetadata(alias) + if err != nil { + return Credentials{}, ErrCredentialsNotFound + } + secret, err := secrets.GetSecret(clientSecretKey(alias)) + if err != nil { + return Credentials{}, ErrCredentialsNotFound + } + creds := Credentials{ + AccountID: strings.TrimSpace(meta.AccountID), + ClientID: strings.TrimSpace(meta.ClientID), + ClientSecret: strings.TrimSpace(string(secret)), + } + if creds.AccountID == "" || creds.ClientID == "" || creds.ClientSecret == "" { + return Credentials{}, ErrCredentialsNotFound + } + return creds, nil +} + +func credentialsFromEnv() (Credentials, bool) { + creds := Credentials{ + AccountID: strings.TrimSpace(os.Getenv(envAccountID)), + ClientID: strings.TrimSpace(os.Getenv(envClientID)), + ClientSecret: strings.TrimSpace(os.Getenv(envClientSecret)), + } + if creds.AccountID == "" && creds.ClientID == "" && creds.ClientSecret == "" { + return Credentials{}, false + } + if creds.AccountID == "" || creds.ClientID == "" || creds.ClientSecret == "" { + return Credentials{}, false + } + return creds, true +} + +func StoreCredentials(alias string, metadata Metadata, clientSecret string) error { + alias = NormalizeAlias(alias) + metadata.Alias = alias + if strings.TrimSpace(metadata.AccountID) == "" || strings.TrimSpace(metadata.ClientID) == "" || strings.TrimSpace(clientSecret) == "" { + return ErrCredentialsNotFound + } + if err := WriteMetadata(alias, metadata); err != nil { + return err + } + if err := secrets.SetSecret(clientSecretKey(alias), []byte(clientSecret)); err != nil { + return fmt.Errorf("store zoom client secret: %w", err) + } + return nil +} + +func LoadMetadata(alias string) (Metadata, error) { + path, err := metadataPath(alias) + if err != nil { + return Metadata{}, err + } + b, err := os.ReadFile(path) //nolint:gosec // path is inside gogcli config dir + if err != nil { + return Metadata{}, fmt.Errorf("read zoom metadata: %w", err) + } + var meta Metadata + if err := json.Unmarshal(b, &meta); err != nil { + return Metadata{}, fmt.Errorf("decode zoom metadata: %w", err) + } + return meta, nil +} + +func WriteMetadata(alias string, metadata Metadata) error { + dir, err := metadataDir() + if err != nil { + return err + } + if mkdirErr := os.MkdirAll(dir, metadataDirMode); mkdirErr != nil { + return fmt.Errorf("ensure zoom config dir: %w", mkdirErr) + } + b, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("encode zoom metadata: %w", err) + } + path, err := metadataPath(alias) + if err != nil { + return err + } + if err := config.WriteFileAtomic(path, append(b, '\n'), metadataFileMode); err != nil { + return fmt.Errorf("write zoom metadata: %w", err) + } + return nil +} + +func LoadCachedToken(alias string) (CachedToken, error) { + b, err := secrets.GetSecret(accessTokenKey(NormalizeAlias(alias))) + if err != nil { + return CachedToken{}, fmt.Errorf("read zoom cached token: %w", err) + } + var tok CachedToken + if err := json.Unmarshal(b, &tok); err != nil { + return CachedToken{}, fmt.Errorf("decode zoom cached token: %w", err) + } + return tok, nil +} + +func StoreCachedToken(alias string, tok CachedToken) error { + b, err := json.Marshal(tok) //nolint:gosec // Token cache is intentionally stored in the keyring. + if err != nil { + return fmt.Errorf("encode zoom cached token: %w", err) + } + if err := secrets.SetSecret(accessTokenKey(NormalizeAlias(alias)), b); err != nil { + return fmt.Errorf("store zoom cached token: %w", err) + } + return nil +} + +func CachedTokenExpiry(alias string) (time.Time, bool) { + tok, err := LoadCachedToken(alias) + if err != nil { + return time.Time{}, false + } + return tok.ExpiresAt, !tok.ExpiresAt.IsZero() +} + +func metadataDir() (string, error) { + dir, err := config.EnsureDir() + if err != nil { + return "", fmt.Errorf("ensure config dir: %w", err) + } + return filepath.Join(dir, metadataDirComponent), nil +} + +func metadataPath(alias string) (string, error) { + dir, err := metadataDir() + if err != nil { + return "", err + } + alias = strings.ReplaceAll(NormalizeAlias(alias), string(filepath.Separator), "_") + return filepath.Join(dir, alias+".json"), nil +} + +func clientSecretKey(alias string) string { + return fmt.Sprintf(clientSecretKeyFmt, NormalizeAlias(alias)) +} + +func accessTokenKey(alias string) string { + return fmt.Sprintf(accessTokenKeyFmt, NormalizeAlias(alias)) +}