From 90bbc14d2ab0932fcf695783a44035f36a6db1a9 Mon Sep 17 00:00:00 2001 From: Dan Wager Date: Fri, 20 Mar 2026 14:28:12 -0700 Subject: [PATCH] feat(meet): add Google Meet support --- README.md | 36 +++- internal/cmd/execute_meet_test.go | 304 ++++++++++++++++++++++++++++ internal/cmd/help_snapshot_test.go | 12 ++ internal/cmd/meet.go | 213 +++++++++++++++++++ internal/cmd/meet_helpers.go | 57 ++++++ internal/cmd/meet_history.go | 127 ++++++++++++ internal/cmd/meet_participants.go | 164 +++++++++++++++ internal/cmd/meet_update_end.go | 139 +++++++++++++ internal/cmd/root.go | 5 +- internal/cmd/service_helpers.go | 5 + internal/googleapi/meet.go | 20 ++ internal/googleauth/service.go | 17 ++ internal/googleauth/service_test.go | 9 +- scripts/live-test.sh | 2 + scripts/live-tests/meet.sh | 20 ++ 15 files changed, 1122 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/execute_meet_test.go create mode 100644 internal/cmd/meet.go create mode 100644 internal/cmd/meet_helpers.go create mode 100644 internal/cmd/meet_history.go create mode 100644 internal/cmd/meet_participants.go create mode 100644 internal/cmd/meet_update_end.go create mode 100644 internal/googleapi/meet.go create mode 100755 scripts/live-tests/meet.sh diff --git a/README.md b/README.md index 13fd0f56..12d7e4b9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![GitHub Repo Banner](https://ghrb.waren.build/banner?header=gogcli%F0%9F%A7%AD&subheader=Google+in+your+terminal&bg=f3f4f6&color=1f2937&support=true) -Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Apps Script, Contacts, Tasks, People, Admin, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and flexible auth built in. +Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Meet, Apps Script, Contacts, Tasks, People, Admin, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and flexible auth built in. ## Features @@ -17,6 +17,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, plus repeat schedule materialization with RRULE aliases - **Sheets** - read/write/update spreadsheets, insert rows/cols, manage tabs and named ranges, format/merge/freeze/resize cells, read/write notes, inspect formats, find/replace text, list links, and create/export sheets - **Forms** - create/update forms, manage questions, inspect responses, and manage watches +- **Meet** - create/get/update meeting spaces, end active calls, view call history and participants - **Apps Script** - create/get/bind projects, inspect content, and run functions - **Docs/Slides** - create/copy/export docs/slides, edit Docs by tab, import Markdown, do richer find-replace, export Docs as Markdown/HTML, and generate Slides from Markdown or templates - **People** - profile lookup and directory search helpers @@ -88,6 +89,7 @@ Before adding an account, create OAuth2 credentials from Google Cloud Console: - Google Drive API: https://console.cloud.google.com/apis/api/drive.googleapis.com - Google Classroom API: https://console.cloud.google.com/apis/api/classroom.googleapis.com - Google Keep API: https://console.cloud.google.com/apis/api/keep.googleapis.com + - Google Meet API: https://console.cloud.google.com/apis/api/meet.googleapis.com - People API (Contacts): https://console.cloud.google.com/apis/api/people.googleapis.com - Google Tasks API: https://console.cloud.google.com/apis/api/tasks.googleapis.com - Google Sheets API: https://console.cloud.google.com/apis/api/sheets.googleapis.com @@ -394,9 +396,11 @@ Service scope matrix (auto-generated; run `go run scripts/gen-auth-services-md.g | sheets | yes | Sheets API, Drive API | `https://www.googleapis.com/auth/drive`
`https://www.googleapis.com/auth/spreadsheets` | Export via Drive | | people | yes | People API | `profile` | OIDC profile scope | | forms | yes | Forms API | `https://www.googleapis.com/auth/forms.body`
`https://www.googleapis.com/auth/forms.responses.readonly` | | +| meet | yes | Meet REST API | `https://www.googleapis.com/auth/meetings.space.created`
`https://www.googleapis.com/auth/meetings.space.readonly`
`https://www.googleapis.com/auth/meetings.space.settings` | | | appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`
`https://www.googleapis.com/auth/script.deployments`
`https://www.googleapis.com/auth/script.processes` | | | groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only | | keep | no | Keep API | `https://www.googleapis.com/auth/keep` | Workspace only; service account (domain-wide delegation) | +| admin | no | Admin SDK Directory API | `https://www.googleapis.com/auth/admin.directory.user`
`https://www.googleapis.com/auth/admin.directory.group`
`https://www.googleapis.com/auth/admin.directory.group.member` | Workspace only; service account with domain-wide delegation required | ### Service Accounts (Workspace only) @@ -408,7 +412,7 @@ In `gog`, service accounts are an **optional auth method** that can be configure #### 1) Create a Service Account (Google Cloud) 1. Create (or pick) a Google Cloud project. -2. Enable the APIs you’ll use (e.g. Gmail, Calendar, Drive, Sheets, Docs, People, Tasks, Cloud Identity). +2. Enable the APIs you’ll use (e.g. Gmail, Calendar, Drive, Sheets, Docs, People, Tasks, Meet, Cloud Identity). 3. Go to **IAM & Admin → Service Accounts** and create a service account. 4. In the service account details, enable **Domain-wide delegation**. 5. Create a key (**Keys → Add key → Create new key → JSON**) and download the JSON key file. @@ -1125,6 +1129,34 @@ gog forms watch renew gog forms watch delete ``` +### Meet + +```bash +# Create a meeting space (default: trusted access) +gog meet create +gog meet create --open # create and open in browser +gog meet create --access open # anyone can join +gog meet create --access restricted # invite-only +gog meet create --json # JSON output with full config + +# Get meeting details +gog meet get + +# Update meeting settings +gog meet update --access open +gog meet update --access restricted + +# End an active call +gog meet end + +# List past calls in a meeting +gog meet history + +# Show who was in the most recent call +gog meet participants +gog meet participants --conference # specific past call +``` + ### Apps Script ```bash diff --git a/internal/cmd/execute_meet_test.go b/internal/cmd/execute_meet_test.go new file mode 100644 index 00000000..6fc224ff --- /dev/null +++ b/internal/cmd/execute_meet_test.go @@ -0,0 +1,304 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/meet/v2" + "google.golang.org/api/option" +) + +func newTestMeetService(t *testing.T, handler http.Handler) { + t.Helper() + + origNew := newMeetService + t.Cleanup(func() { newMeetService = origNew }) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + svc, err := meet.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + newMeetService = func(context.Context, string) (*meet.Service, error) { return svc, nil } +} + +func meetSpaceResponse() map[string]any { + return map[string]any{ + "name": "spaces/abc123", + "meetingUri": "https://meet.google.com/abc-defg-hij", + "meetingCode": "abc-defg-hij", + "config": map[string]any{ + "accessType": "TRUSTED", + "entryPointAccess": "ALL", + }, + } +} + +func TestExecute_MeetCreate_JSON(t *testing.T) { + newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.URL.Path == "/v2/spaces" && r.Method == http.MethodPost) { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meetSpaceResponse()) + })) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "meet", "create"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Created bool `json:"created"` + MeetingURI string `json:"meeting_uri"` + MeetingCode string `json:"meeting_code"` + } + + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + + if !parsed.Created { + t.Fatal("expected created=true") + } + + if parsed.MeetingCode != "abc-defg-hij" { + t.Fatalf("unexpected meeting_code: %q", parsed.MeetingCode) + } + + if parsed.MeetingURI != "https://meet.google.com/abc-defg-hij" { + t.Fatalf("unexpected meeting_uri: %q", parsed.MeetingURI) + } +} + +func TestExecute_MeetCreate_Text(t *testing.T) { + newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.URL.Path == "/v2/spaces" && r.Method == http.MethodPost) { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meetSpaceResponse()) + })) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--plain", "--account", "a@b.com", "meet", "create"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "meeting_code\tabc-defg-hij") { + t.Fatalf("expected meeting_code in output, got: %q", out) + } + + if !strings.Contains(out, "meeting_uri\thttps://meet.google.com/abc-defg-hij") { + t.Fatalf("expected meeting_uri in output, got: %q", out) + } + + if !strings.Contains(out, "access\ttrusted") { + t.Fatalf("expected access in output, got: %q", out) + } +} + +func TestExecute_MeetCreate_DryRun(t *testing.T) { + newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("should not call API in dry-run mode") + http.Error(w, "unexpected", http.StatusInternalServerError) + })) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + err := Execute([]string{"--json", "--dry-run", "--account", "a@b.com", "meet", "create"}) + if err != nil && ExitCode(err) != 0 { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + DryRun bool `json:"dry_run"` + Op string `json:"op"` + } + + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + + if !parsed.DryRun { + t.Fatal("expected dry_run=true") + } + + if parsed.Op != "meet.spaces.create" { + t.Fatalf("unexpected op: %q", parsed.Op) + } +} + +func TestExecute_MeetGet_JSON(t *testing.T) { + newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.URL.Path == "/v2/spaces/abc-defg-hij" && r.Method == http.MethodGet) { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meetSpaceResponse()) + })) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "meet", "get", "abc-defg-hij"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + MeetingCode string `json:"meeting_code"` + } + + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + + if parsed.MeetingCode != "abc-defg-hij" { + t.Fatalf("unexpected meeting_code: %q", parsed.MeetingCode) + } +} + +func TestExecute_MeetHistory_JSON(t *testing.T) { + newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v2/spaces/abc-defg-hij" && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meetSpaceResponse()) + case r.URL.Path == "/v2/conferenceRecords" && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "conferenceRecords": []map[string]any{ + { + "name": "conferenceRecords/rec1", + "space": "spaces/abc123", + "startTime": "2026-03-20T10:00:00Z", + "endTime": "2026-03-20T11:00:00Z", + }, + }, + }) + default: + http.NotFound(w, r) + } + })) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "meet", "history", "abc-defg-hij"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + MeetingCode string `json:"meeting_code"` + Conferences []struct { + Name string `json:"name"` + } `json:"conferences"` + } + + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + + if parsed.MeetingCode != "abc-defg-hij" { + t.Fatalf("unexpected meeting_code: %q", parsed.MeetingCode) + } + + if len(parsed.Conferences) != 1 || parsed.Conferences[0].Name != "conferenceRecords/rec1" { + t.Fatalf("unexpected conferences: %#v", parsed.Conferences) + } +} + +func TestExecute_MeetParticipants_JSON(t *testing.T) { + newTestMeetService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v2/spaces/abc-defg-hij" && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meetSpaceResponse()) + case r.URL.Path == "/v2/conferenceRecords" && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "conferenceRecords": []map[string]any{ + { + "name": "conferenceRecords/rec1", + "space": "spaces/abc123", + "startTime": "2026-03-20T10:00:00Z", + "endTime": "2026-03-20T11:00:00Z", + }, + }, + }) + case r.URL.Path == "/v2/conferenceRecords/rec1/participants" && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "participants": []map[string]any{ + { + "name": "conferenceRecords/rec1/participants/p1", + "earliestStartTime": "2026-03-20T10:00:00Z", + "latestEndTime": "2026-03-20T11:00:00Z", + "signedinUser": map[string]any{ + "displayName": "Dan Wager", + "user": "users/123", + }, + }, + }, + }) + default: + http.NotFound(w, r) + } + })) + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "meet", "participants", "abc-defg-hij"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + MeetingCode string `json:"meeting_code"` + Participants []struct { + SignedinUser struct { + DisplayName string `json:"displayName"` + } `json:"signedinUser"` + } `json:"participants"` + } + + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + + if parsed.MeetingCode != "abc-defg-hij" { + t.Fatalf("unexpected meeting_code: %q", parsed.MeetingCode) + } + + if len(parsed.Participants) != 1 || parsed.Participants[0].SignedinUser.DisplayName != "Dan Wager" { + t.Fatalf("unexpected participants: %#v", parsed.Participants) + } +} diff --git a/internal/cmd/help_snapshot_test.go b/internal/cmd/help_snapshot_test.go index 55d8fb19..c57de7b4 100644 --- a/internal/cmd/help_snapshot_test.go +++ b/internal/cmd/help_snapshot_test.go @@ -66,6 +66,18 @@ func TestHelpSnapshot_Forms(t *testing.T) { ) } +func TestHelpSnapshot_Meet(t *testing.T) { + out := captureHelpOutput(t, "meet", "--help") + requireHelpContains(t, out, + "\n create", + "\n get", + "\n update", + "\n end", + "\n history", + "\n participants", + ) +} + func TestHelpSnapshot_Admin(t *testing.T) { out := captureHelpOutput(t, "admin", "--help") requireHelpContains(t, out, diff --git a/internal/cmd/meet.go b/internal/cmd/meet.go new file mode 100644 index 00000000..e948ab75 --- /dev/null +++ b/internal/cmd/meet.go @@ -0,0 +1,213 @@ +package cmd + +import ( + "context" + "os" + "os/exec" + "runtime" + "strings" + + "google.golang.org/api/meet/v2" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// openMeetBrowser opens the meeting URL in the default browser. +var openMeetBrowser = func(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) //nolint:gosec // executable is fixed; arg is meeting URL + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) //nolint:gosec // executable is fixed; arg is meeting URL + default: + cmd = exec.Command("xdg-open", url) //nolint:gosec // executable is fixed; arg is meeting URL + } + + return cmd.Start() +} + +var newMeetService = googleapi.NewMeet + +type MeetCmd struct { + Create MeetCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a meeting space"` + Get MeetGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a meeting space"` + Update MeetUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update space config"` + End MeetEndCmd `cmd:"" name:"end" aliases:"stop" help:"End active conference"` + History MeetHistoryCmd `cmd:"" name:"history" aliases:"calls,past" help:"List past calls in a meeting"` + Participants MeetParticipantsCmd `cmd:"" name:"participants" aliases:"people,attendees,who" help:"List participants from the latest call"` +} + +// MeetCreateCmd creates a new meeting space. +type MeetCreateCmd struct { + Access string `name:"access" aliases:"access-type" help:"Access type: open, trusted, or restricted" default:"trusted"` + EntryPoint string `name:"entry-point" aliases:"entry-point-access" help:"Entry point access: all or creator-only" default:"all" hidden:""` + Open bool `name:"open" aliases:"browser" help:"Open the meeting in a browser after creation"` +} + +func (c *MeetCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + accessType, err := parseMeetAccessType(c.Access) + if err != nil { + return err + } + + entryPointAccess, err := parseMeetEntryPointAccess(c.EntryPoint) + if err != nil { + return err + } + + if dryRunErr := dryRunExit(ctx, flags, "meet.spaces.create", map[string]any{ + "access_type": accessType, + "entry_point_access": entryPointAccess, + }); dryRunErr != nil { + return dryRunErr + } + + _, svc, err := requireMeetService(ctx, flags) + if err != nil { + return wrapMeetError(err) + } + + space := &meet.Space{ + Config: &meet.SpaceConfig{ + AccessType: accessType, + EntryPointAccess: entryPointAccess, + }, + } + + created, err := svc.Spaces.Create(space).Context(ctx).Do() + if err != nil { + return wrapMeetError(err) + } + + if outfmt.IsJSON(ctx) { + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "created": true, + "name": created.Name, + "meeting_uri": created.MeetingUri, + "meeting_code": created.MeetingCode, + "config": created.Config, + }); err != nil { + return err + } + + return openMeetingIfRequested(c.Open, created.MeetingUri) + } + + u := ui.FromContext(ctx) + printMeetSpace(u, created) + + return openMeetingIfRequested(c.Open, created.MeetingUri) +} + +// MeetGetCmd gets a meeting space by meeting code. +type MeetGetCmd struct { + MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"` +} + +func (c *MeetGetCmd) Run(ctx context.Context, flags *RootFlags) error { + spaceName := normalizeMeetSpaceName(c.MeetingCode) + if spaceName == "" { + return usage("empty meeting code") + } + + _, svc, err := requireMeetService(ctx, flags) + if err != nil { + return wrapMeetError(err) + } + + space, err := svc.Spaces.Get(spaceName).Context(ctx).Do() + if err != nil { + return wrapMeetError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "name": space.Name, + "meeting_uri": space.MeetingUri, + "meeting_code": space.MeetingCode, + "config": space.Config, + }) + } + + u := ui.FromContext(ctx) + printMeetSpace(u, space) + + return nil +} + +func printMeetSpace(u *ui.UI, space *meet.Space) { + if u == nil || space == nil { + return + } + + u.Out().Printf("meeting_code\t%s", space.MeetingCode) + u.Out().Printf("meeting_uri\t%s", space.MeetingUri) + + if space.Config != nil { + u.Out().Printf("access\t%s", strings.ToLower(space.Config.AccessType)) + } + + if space.ActiveConference != nil && space.ActiveConference.ConferenceRecord != "" { + u.Out().Printf("active_conference\t%s", space.ActiveConference.ConferenceRecord) + } +} + +// normalizeMeetSpaceName accepts either a full resource name ("spaces/xxx") +// or a bare meeting code ("xxx-yyyy-zzz") and returns a value suitable for +// the spaces.get API, which accepts both formats. +func normalizeMeetSpaceName(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return "" + } + + if strings.HasPrefix(input, "spaces/") { + return input + } + + return "spaces/" + input +} + +// resolveMeetSpace resolves a meeting code to the full Space resource. +// This is needed because some API methods (patch, endActiveConference) +// require the canonical resource name (e.g. "spaces/KP0uKCifZgYB"), +// which differs from the meeting code (e.g. "abc-defg-hij"). +func resolveMeetSpace(ctx context.Context, svc *meet.Service, input string) (*meet.Space, error) { + return svc.Spaces.Get(normalizeMeetSpaceName(input)).Context(ctx).Do() +} + +func openMeetingIfRequested(shouldOpen bool, meetingURI string) error { + if !shouldOpen || strings.TrimSpace(meetingURI) == "" { + return nil + } + + return openMeetBrowser(meetingURI) +} + +func parseMeetAccessType(s string) (string, error) { + switch strings.TrimSpace(strings.ToLower(s)) { + case "trusted", "": + return "TRUSTED", nil + case "open": + return "OPEN", nil + case "restricted": + return "RESTRICTED", nil + default: + return "", usagef("invalid --access %q (expected open, trusted, or restricted)", s) + } +} + +func parseMeetEntryPointAccess(s string) (string, error) { + switch strings.TrimSpace(strings.ToLower(s)) { + case literalAll, "": + return "ALL", nil + case "creator-only", "creator_only", "creatoronly": + return "CREATOR_APP_ONLY", nil + default: + return "", usagef("invalid --entry-point %q (expected all or creator-only)", s) + } +} diff --git a/internal/cmd/meet_helpers.go b/internal/cmd/meet_helpers.go new file mode 100644 index 00000000..a13ce6d2 --- /dev/null +++ b/internal/cmd/meet_helpers.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "strings" + + "google.golang.org/api/meet/v2" + + "github.com/steipete/gogcli/internal/errfmt" +) + +// wrapMeetError provides helpful error messages for common Meet API issues. +func wrapMeetError(err error) error { + if err == nil { + return nil + } + + errStr := err.Error() + + if strings.Contains(errStr, "accessNotConfigured") || + strings.Contains(errStr, "Meet REST API has not been used") { + return errfmt.NewUserFacingError( + "Meet REST API is not enabled; enable it at: https://console.developers.google.com/apis/api/meet.googleapis.com/overview", + err, + ) + } + + if strings.Contains(errStr, "insufficientPermissions") || + strings.Contains(errStr, "insufficient authentication scopes") { + return errfmt.NewUserFacingError( + "Insufficient permissions for Meet API; re-authenticate with: gog auth add --services meet", + err, + ) + } + + return err +} + +// participantDisplayName extracts a human-readable name from a participant. +func participantDisplayName(p *meet.Participant) string { + if p == nil { + return "" + } + + if p.SignedinUser != nil && p.SignedinUser.DisplayName != "" { + return p.SignedinUser.DisplayName + } + + if p.AnonymousUser != nil && p.AnonymousUser.DisplayName != "" { + return p.AnonymousUser.DisplayName + } + + if p.PhoneUser != nil && p.PhoneUser.DisplayName != "" { + return p.PhoneUser.DisplayName + } + + return p.Name +} diff --git a/internal/cmd/meet_history.go b/internal/cmd/meet_history.go new file mode 100644 index 00000000..37c44d1d --- /dev/null +++ b/internal/cmd/meet_history.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/meet/v2" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// MeetHistoryCmd lists past conferences (calls) for a meeting. +type MeetHistoryCmd struct { + MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"` + Max int `name:"max" aliases:"limit" help:"Max results" default:"20"` + Page string `name:"page" aliases:"cursor" help:"Page token"` + All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` +} + +func (c *MeetHistoryCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + if strings.TrimSpace(c.MeetingCode) == "" { + return usage("empty meeting code") + } + + if c.Max <= 0 { + return usage("--max must be > 0") + } + + _, svc, err := requireMeetService(ctx, flags) + if err != nil { + return wrapMeetError(err) + } + + // Resolve the meeting code to the canonical space name for filtering. + space, err := resolveMeetSpace(ctx, svc, c.MeetingCode) + if err != nil { + return wrapMeetError(err) + } + + filter := fmt.Sprintf("space.name=%s", space.Name) + + fetch := func(pageToken string) ([]*meet.ConferenceRecord, string, error) { + call := svc.ConferenceRecords.List(). + PageSize(int64(c.Max)). + Filter(filter). + Context(ctx) + if strings.TrimSpace(pageToken) != "" { + call = call.PageToken(pageToken) + } + + resp, err := call.Do() + if err != nil { + return nil, "", wrapMeetError(err) + } + + return resp.ConferenceRecords, resp.NextPageToken, nil + } + + var records []*meet.ConferenceRecord + + nextPageToken := "" + + if c.All { + all, err := collectAllPages(c.Page, fetch) + if err != nil { + return err + } + + records = all + } else { + var err error + + records, nextPageToken, err = fetch(c.Page) + if err != nil { + return err + } + } + + if outfmt.IsJSON(ctx) { + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "meeting_code": c.MeetingCode, + "conferences": records, + "nextPageToken": nextPageToken, + }); err != nil { + return err + } + + if len(records) == 0 { + return failEmptyExit(c.FailEmpty) + } + + return nil + } + + if len(records) == 0 { + u.Err().Printf("No past calls found for %s", c.MeetingCode) + + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + + fmt.Fprintln(w, "START\tEND\tCONFERENCE") + + for _, r := range records { + if r == nil { + continue + } + + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(r.StartTime), + sanitizeTab(r.EndTime), + sanitizeTab(strings.TrimPrefix(r.Name, "conferenceRecords/")), + ) + } + + printNextPageHint(u, nextPageToken) + + return nil +} diff --git a/internal/cmd/meet_participants.go b/internal/cmd/meet_participants.go new file mode 100644 index 00000000..dbc23167 --- /dev/null +++ b/internal/cmd/meet_participants.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/meet/v2" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// MeetParticipantsCmd lists participants from the most recent call in a meeting, +// or from a specific conference if --conference is provided. +type MeetParticipantsCmd struct { + MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"` + Conference string `name:"conference" help:"Specific conference ID (default: most recent call)"` + Max int `name:"max" aliases:"limit" help:"Max results" default:"50"` + Page string `name:"page" aliases:"cursor" help:"Page token"` + All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` +} + +func (c *MeetParticipantsCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + if strings.TrimSpace(c.MeetingCode) == "" { + return usage("empty meeting code") + } + + if c.Max <= 0 { + return usage("--max must be > 0") + } + + _, svc, err := requireMeetService(ctx, flags) + if err != nil { + return wrapMeetError(err) + } + + // Determine which conference to list participants for. + conferenceName, err := resolveConference(ctx, svc, c.MeetingCode, c.Conference) + if err != nil { + return err + } + + fetch := func(pageToken string) ([]*meet.Participant, string, error) { + call := svc.ConferenceRecords.Participants.List(conferenceName). + PageSize(int64(c.Max)). + Context(ctx) + if strings.TrimSpace(pageToken) != "" { + call = call.PageToken(pageToken) + } + + resp, err := call.Do() + if err != nil { + return nil, "", wrapMeetError(err) + } + + return resp.Participants, resp.NextPageToken, nil + } + + var participants []*meet.Participant + + nextPageToken := "" + + if c.All { + all, err := collectAllPages(c.Page, fetch) + if err != nil { + return err + } + + participants = all + } else { + var err error + + participants, nextPageToken, err = fetch(c.Page) + if err != nil { + return err + } + } + + if outfmt.IsJSON(ctx) { + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "meeting_code": c.MeetingCode, + "conference": conferenceName, + "participants": participants, + "nextPageToken": nextPageToken, + }); err != nil { + return err + } + + if len(participants) == 0 { + return failEmptyExit(c.FailEmpty) + } + + return nil + } + + if len(participants) == 0 { + u.Err().Printf("No participants found for %s", c.MeetingCode) + + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + + fmt.Fprintln(w, "DISPLAY_NAME\tJOINED\tLEFT") + + for _, p := range participants { + if p == nil { + continue + } + + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(participantDisplayName(p)), + sanitizeTab(p.EarliestStartTime), + sanitizeTab(p.LatestEndTime), + ) + } + + printNextPageHint(u, nextPageToken) + + return nil +} + +// resolveConference determines the conference record name to use. +// If an explicit conference ID is provided, it is used directly. +// Otherwise, the most recent conference for the meeting is looked up. +func resolveConference(ctx context.Context, svc *meet.Service, meetingCode, conferenceOverride string) (string, error) { + if conferenceOverride != "" { + name := strings.TrimSpace(conferenceOverride) + if !strings.HasPrefix(name, "conferenceRecords/") { + name = "conferenceRecords/" + name + } + + return name, nil + } + + // Look up the most recent conference for this meeting. + space, err := resolveMeetSpace(ctx, svc, meetingCode) + if err != nil { + return "", wrapMeetError(err) + } + + filter := fmt.Sprintf("space.name=%s", space.Name) + + resp, err := svc.ConferenceRecords.List(). + Filter(filter). + PageSize(1). + Context(ctx). + Do() + if err != nil { + return "", wrapMeetError(err) + } + + if len(resp.ConferenceRecords) == 0 { + return "", usagef("no past calls found for %s", meetingCode) + } + + return resp.ConferenceRecords[0].Name, nil +} diff --git a/internal/cmd/meet_update_end.go b/internal/cmd/meet_update_end.go new file mode 100644 index 00000000..069130d5 --- /dev/null +++ b/internal/cmd/meet_update_end.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "context" + "os" + "strings" + + "google.golang.org/api/meet/v2" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// MeetUpdateCmd updates the configuration of a meeting space. +type MeetUpdateCmd struct { + MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"` + Access string `name:"access" aliases:"access-type" help:"Access type: open, trusted, or restricted"` + EntryPoint string `name:"entry-point" aliases:"entry-point-access" help:"Entry point access: all or creator-only" hidden:""` +} + +func (c *MeetUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + if strings.TrimSpace(c.MeetingCode) == "" { + return usage("empty meeting code") + } + + var updateMask []string + + patch := &meet.Space{Config: &meet.SpaceConfig{}} + + if c.Access != "" { + accessType, err := parseMeetAccessType(c.Access) + if err != nil { + return err + } + + patch.Config.AccessType = accessType + updateMask = append(updateMask, "config.accessType") + } + + if c.EntryPoint != "" { + entryPointAccess, err := parseMeetEntryPointAccess(c.EntryPoint) + if err != nil { + return err + } + + patch.Config.EntryPointAccess = entryPointAccess + updateMask = append(updateMask, "config.entryPointAccess") + } + + if len(updateMask) == 0 { + return usage("at least one of --access or --entry-point is required") + } + + _, svc, err := requireMeetService(ctx, flags) + if err != nil { + return wrapMeetError(err) + } + + space, err := resolveMeetSpace(ctx, svc, c.MeetingCode) + if err != nil { + return wrapMeetError(err) + } + + if dryRunErr := dryRunExit(ctx, flags, "meet.spaces.patch", map[string]any{ + "meeting_code": c.MeetingCode, + "update_mask": strings.Join(updateMask, ","), + "config": patch.Config, + }); dryRunErr != nil { + return dryRunErr + } + + updated, err := svc.Spaces.Patch(space.Name, patch). + UpdateMask(strings.Join(updateMask, ",")). + Context(ctx). + Do() + if err != nil { + return wrapMeetError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "updated": true, + "name": updated.Name, + "meeting_uri": updated.MeetingUri, + "meeting_code": updated.MeetingCode, + "config": updated.Config, + }) + } + + u := ui.FromContext(ctx) + printMeetSpace(u, updated) + + return nil +} + +// MeetEndCmd ends an active conference in a meeting space. +type MeetEndCmd struct { + MeetingCode string `arg:"" name:"meeting-code" help:"Meeting code (e.g. abc-defg-hij)"` +} + +func (c *MeetEndCmd) Run(ctx context.Context, flags *RootFlags) error { + if strings.TrimSpace(c.MeetingCode) == "" { + return usage("empty meeting code") + } + + _, svc, err := requireMeetService(ctx, flags) + if err != nil { + return wrapMeetError(err) + } + + space, err := resolveMeetSpace(ctx, svc, c.MeetingCode) + if err != nil { + return wrapMeetError(err) + } + + if confirmErr := confirmDestructive(ctx, flags, "end active conference in "+c.MeetingCode); confirmErr != nil { + return confirmErr + } + + req := &meet.EndActiveConferenceRequest{} + + _, err = svc.Spaces.EndActiveConference(space.Name, req).Context(ctx).Do() + if err != nil { + return wrapMeetError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "ended": true, + "meeting_code": c.MeetingCode, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("ended\ttrue") + u.Out().Printf("meeting_code\t%s", c.MeetingCode) + + return nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c898444b..c94ab509 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -29,7 +29,7 @@ const ( type RootFlags struct { Color string `help:"Color output: auto|always|never" default:"${color}"` - Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript)" aliases:"acct" short:"a"` + Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/meet/appscript)" aliases:"acct" short:"a"` Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"` AccessToken string `help:"Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h)" env:"GOG_ACCESS_TOKEN"` //nolint:gosec // CLI/env input, not an embedded secret EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"` @@ -78,6 +78,7 @@ type CLI struct { Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"` Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"` Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"` + Meet MeetCmd `cmd:"" aliases:"meeting" help:"Google Meet"` AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"` Config ConfigCmd `cmd:"" help:"Manage configuration"` ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"` @@ -333,7 +334,7 @@ func newParser(description string) (*kong.Kong, *CLI, error) { } func baseDescription() string { - return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/App Script" + return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/Meet/App Script" } func helpDescription() string { diff --git a/internal/cmd/service_helpers.go b/internal/cmd/service_helpers.go index 811445d2..75c65c96 100644 --- a/internal/cmd/service_helpers.go +++ b/internal/cmd/service_helpers.go @@ -8,6 +8,7 @@ import ( "google.golang.org/api/docs/v1" "google.golang.org/api/drive/v3" "google.golang.org/api/gmail/v1" + "google.golang.org/api/meet/v2" "google.golang.org/api/sheets/v4" ) @@ -35,6 +36,10 @@ func requireClassroomService(ctx context.Context, flags *RootFlags) (string, *cl return requireGoogleService(ctx, flags, newClassroomService) } +func requireMeetService(ctx context.Context, flags *RootFlags) (string, *meet.Service, error) { + return requireGoogleService(ctx, flags, newMeetService) +} + func requireSheetsService(ctx context.Context, flags *RootFlags) (string, *sheets.Service, error) { return requireGoogleService(ctx, flags, newSheetsService) } diff --git a/internal/googleapi/meet.go b/internal/googleapi/meet.go new file mode 100644 index 00000000..01ac60fc --- /dev/null +++ b/internal/googleapi/meet.go @@ -0,0 +1,20 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/meet/v2" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewMeet(ctx context.Context, email string) (*meet.Service, error) { + if opts, err := optionsForAccount(ctx, googleauth.ServiceMeet, email); err != nil { + return nil, fmt.Errorf("meet options: %w", err) + } else if svc, err := meet.NewService(ctx, opts...); err != nil { + return nil, fmt.Errorf("create meet service: %w", err) + } else { + return svc, nil + } +} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index 92e001ca..8b42abf0 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -22,6 +22,7 @@ const ( ServicePeople Service = "people" ServiceSheets Service = "sheets" ServiceForms Service = "forms" + ServiceMeet Service = "meet" ServiceAppScript Service = "appscript" ServiceGroups Service = "groups" ServiceKeep Service = "keep" @@ -82,6 +83,7 @@ var serviceOrder = []Service{ ServiceSheets, ServicePeople, ServiceForms, + ServiceMeet, ServiceAppScript, ServiceGroups, ServiceKeep, @@ -194,6 +196,15 @@ var serviceInfoByService = map[Service]serviceInfo{ user: true, apis: []string{"Forms API"}, }, + ServiceMeet: { + scopes: []string{ + "https://www.googleapis.com/auth/meetings.space.created", + "https://www.googleapis.com/auth/meetings.space.readonly", + "https://www.googleapis.com/auth/meetings.space.settings", + }, + user: true, + apis: []string{"Meet REST API"}, + }, ServiceAppScript: { scopes: []string{ "https://www.googleapis.com/auth/script.projects", @@ -543,6 +554,12 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string, formBodyScope, "https://www.googleapis.com/auth/forms.responses.readonly", }, nil + case ServiceMeet: + if opts.Readonly { + return []string{"https://www.googleapis.com/auth/meetings.space.readonly"}, nil + } + + return Scopes(service) case ServiceAppScript: if opts.Readonly { return []string{ diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index 3a664f98..26b027ae 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -20,6 +20,7 @@ func TestParseService(t *testing.T) { {"people", ServicePeople}, {"sheets", ServiceSheets}, {"forms", ServiceForms}, + {"meet", ServiceMeet}, {"appscript", ServiceAppScript}, {"groups", ServiceGroups}, {"keep", ServiceKeep}, @@ -65,7 +66,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 16 { + if len(svcs) != 17 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -74,7 +75,7 @@ func TestAllServices(t *testing.T) { seen[s] = true } - for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceAppScript, ServiceGroups, ServiceKeep, ServiceAdmin} { + for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceMeet, ServiceAppScript, ServiceGroups, ServiceKeep, ServiceAdmin} { if !seen[want] { t.Fatalf("missing %q", want) } @@ -83,7 +84,7 @@ func TestAllServices(t *testing.T) { func TestUserServices(t *testing.T) { svcs := UserServices() - if len(svcs) != 13 { + if len(svcs) != 14 { t.Fatalf("unexpected: %v", svcs) } @@ -113,7 +114,7 @@ func TestUserServices(t *testing.T) { } func TestUserServiceCSV(t *testing.T) { - want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript" + want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,meet,appscript" if got := UserServiceCSV(); got != want { t.Fatalf("unexpected user services csv: %q", got) } diff --git a/scripts/live-test.sh b/scripts/live-test.sh index 9b35b0dd..60b61f94 100755 --- a/scripts/live-test.sh +++ b/scripts/live-test.sh @@ -158,6 +158,7 @@ source "$ROOT_DIR/scripts/live-tests/contacts.sh" source "$ROOT_DIR/scripts/live-tests/people.sh" source "$ROOT_DIR/scripts/live-tests/workspace.sh" source "$ROOT_DIR/scripts/live-tests/classroom.sh" +source "$ROOT_DIR/scripts/live-tests/meet.sh" ensure_test_account @@ -186,5 +187,6 @@ run_contacts_tests run_people_tests run_workspace_tests run_classroom_tests +run_meet_tests echo "Live tests complete." diff --git a/scripts/live-tests/meet.sh b/scripts/live-tests/meet.sh new file mode 100755 index 00000000..0e462408 --- /dev/null +++ b/scripts/live-tests/meet.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_meet_tests() { + if skip "meet"; then + echo "==> meet (skipped)" + return 0 + fi + + local space_json meeting_code + echo "==> meet create" + space_json=$(gog meet create --json) + meeting_code=$(echo "$space_json" | "$PY" -c "import sys,json; print(json.load(sys.stdin)['meeting_code'])") + [ -n "$meeting_code" ] || { echo "Failed to parse meeting code" >&2; exit 1; } + + run_required "meet" "meet get" gog meet get "$meeting_code" --json >/dev/null + run_required "meet" "meet update" gog meet update "$meeting_code" --access open --json >/dev/null + run_required "meet" "meet history" gog meet history "$meeting_code" --json --max 1 >/dev/null +}