From b33175dd7fa2c965d15e6c77ccfa4759ea611768 Mon Sep 17 00:00:00 2001 From: Panagiotis Moustafellos Date: Mon, 1 Jun 2026 17:54:54 +0300 Subject: [PATCH] Add Google Meet recording + transcript archival Mirror the existing Zoom->Drive capability for Google Meet. Because Meet recordings already auto-save to Google Drive, archiving is a Drive Files.Copy of artifacts already in Drive into the mapped folder rather than a download. The copy is owned by the account zat authenticates as, so it both organizes recordings into the right shared folder and preserves them if the original host later leaves the org. - New meet/ package: Meet REST API discovery over raw HTTP (mirrors zoom/, as the pinned google.golang.org/api predates the Meet client). ListConferences enumerates conference records, resolves space -> meetingCode, and lists recordings + transcripts as copyable Drive file IDs. Built from an oauth2.TokenSource, leaving a clean seam for future domain-wide delegation. - Meet is opt-in: the meetings.space.readonly scope and the Meet client are only requested when zat.yml has meet: directives, so Drive/Zoom-only users are never prompted for Meet permissions. google.Client.TokenSource (added here) resolves credentials lazily so it works across a later web login or refresh, and is guarded by a RWMutex since the background archiver reads the token while the OAuth handler may be writing it. - zat.yml gains a meet: key (meeting code); a directive may set zoom:, meet:, or both. Reuses the existing per-meeting folder naming, name-based dedupe, -t filter (recording/transcript for Meet), -since window, -min-duration skip, and Slack notify on a copied recording. - Zoom is now optional: a missing zoom config disables Zoom archival and logs a notice instead of aborting, so a Meet-only install runs on Google creds alone. - Web UI: a Meet status line (shown only when Meet is configured) and a /meet debug endpoint (503 when Meet is not configured, redirect to login when unauthenticated, JSON otherwise). - Skip unmapped/unrecorded conferences quietly (they are the common case since ListConferences returns every attended meeting) and avoid empty Drive folders. - Surface real config-load errors (the old os.IsExist guard swallowed them) while still tolerating a missing config file. Tests cover Meet discovery (happy path, pagination across all three list endpoints, start_time filter, space and not-yet-in-Drive skips), the meetCopies mapping, Meet detection/opt-in scope, Meet file naming, the lazy TokenSource, and the web UI (Meet-only path plus /meet auth, success, and error handling). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 30 +++ go.sum | 1 - google/google.go | 88 +++++++-- google/google_test.go | 80 ++++++++ main.go | 418 ++++++++++++++++++++++++++++++++++++++---- main_test.go | 216 +++++++++++++++++++++- meet/meet.go | 305 ++++++++++++++++++++++++++++++ meet/meet_test.go | 301 ++++++++++++++++++++++++++++++ meet/mock/mock.go | 67 +++++++ 9 files changed, 1449 insertions(+), 57 deletions(-) create mode 100644 meet/meet.go create mode 100644 meet/meet_test.go create mode 100644 meet/mock/mock.go diff --git a/README.md b/README.md index 2a30fac..c8c5b63 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,36 @@ Once tokens have been obtained, `zat -no-server` will perform only archival duti "oauth_redirect": "http://127.0.0.1.ip.es.io:8080/oauth/zoom" } ``` +* Google Meet support (optional) + * Meet recordings & transcripts already save to the host's Google Drive. `zat` + copies them into the mapped folder (the copy is owned by the account `zat` + logs in as, which both organizes them and preserves them if the original + host later leaves the org). + * No separate login: Meet uses the same Google OAuth credentials as Drive. + * The Meet scope is **opt-in**: `zat` requests + `https://www.googleapis.com/auth/meetings.space.readonly` only when `zat.yml` + contains at least one `meet:` directive, so Drive/Zoom-only users are never + prompted for Meet permissions. + * **When you first add a `meet:` directive**, the requested scope changes, so + delete `google.creds.json` and re-run the Google login once to grant it. + * Zoom is optional. A Meet-only install needs only the Google credentials — + if `zoom.config.json` is absent, `zat` logs that Zoom archival is disabled + and continues with Meet. + * Map a meeting in `zat.yml` with a `meet:` key holding the meeting code (the + `abc-defg-hij` part of a `meet.google.com/abc-defg-hij` link). A directive + may set `zoom:`, `meet:`, or both: + + ```yaml + - name: UI Weekly + google: DpB3XhhzV87LfEeLrM-nCopTtHDWxqVGH + meet: abc-defg-hij + slack: C0123456 + ``` + + * The `-t` filter accepts `recording` and `transcript` for Meet artifacts. + * **Scope:** `zat` only sees Meet conferences for the single signed-in account. + Cross-host (org-wide) archival via domain-wide delegation is designed for but + not yet implemented. * [Optional] Obtain Slack credentials * [Create an App](https://api.slack.com/apps?new_app=1) * Add Permissions > Scopes > Bot Token Scopes > Add An Oauth Scope granting: `channels:read`, `chat:write`, `chat:write.public` diff --git a/go.sum b/go.sum index 5ee577d..a1a52c8 100644 --- a/go.sum +++ b/go.sum @@ -114,7 +114,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191025021431-6c3a3bfe00ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e h1:9vRrk9YW2BTzLP0VCB9ZDjU4cPqkg+IDWL7XgxA1yxQ= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI= golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/google/google.go b/google/google.go index 9fa76cf..12f2bd3 100644 --- a/google/google.go +++ b/google/google.go @@ -2,11 +2,13 @@ package google import ( "context" + "errors" "io" "io/ioutil" "log" "net/http" "os" + "sync" "golang.org/x/oauth2" "golang.org/x/oauth2/google" @@ -22,11 +24,21 @@ type Client struct { logger *log.Logger httpClient *http.Client - config *oauth2.Config + config *oauth2.Config + // credsMu guards credentials, which is read by background archival + // (via TokenSource/Service) while the OAuth handler writes it. + credsMu sync.RWMutex credentials *oauth2.Token cm *credentialsManager } +// getCreds returns the current credentials under a read lock. +func (c *Client) getCreds() *oauth2.Token { + c.credsMu.RLock() + defer c.credsMu.RUnlock() + return c.credentials +} + type ClientOption func(*Client) func CustomHTTPClientOption(httpClient *http.Client) ClientOption { @@ -35,6 +47,18 @@ func CustomHTTPClientOption(httpClient *http.Client) ClientOption { } } +// MeetScope is the read-only Google Meet scope needed to discover conference +// recordings and transcripts. +const MeetScope = "https://www.googleapis.com/auth/meetings.space.readonly" + +// WithMeetScope requests the Google Meet read scope in addition to Drive. It is +// opt-in so Drive/Zoom-only users aren't prompted for Meet permissions. +func WithMeetScope() ClientOption { + return func(c *Client) { + c.config.Scopes = append(c.config.Scopes, MeetScope) + } +} + // Config is a google-defined client_credentials.json format. // Duplicated from golang.org/x/oauth2/google since it is not exported. type Config struct { @@ -61,6 +85,7 @@ func NewClientFromReader(logger *log.Logger, r io.Reader, options ...ClientOptio return nil, err } // https://developers.google.com/identity/protocols/googlescopes#drivev3 + // Drive scope for archival; the Meet scope is opt-in via WithMeetScope. config, err := google.ConfigFromJSON(b, drive.DriveScope) if err != nil { return nil, err @@ -82,8 +107,11 @@ func NewClient(logger *log.Logger, config *oauth2.Config, options ...ClientOptio } func (c *Client) updateCreds(token *oauth2.Token) { + c.credsMu.Lock() + defer c.credsMu.Unlock() c.credentials = token if token != nil && c.cm != nil { + // saveCreds reads c.credentials, which we hold the write lock over. if err := c.cm.saveCreds(c); err != nil { c.logger.Println("failed to save creds:", err) } @@ -91,25 +119,25 @@ func (c *Client) updateCreds(token *oauth2.Token) { } func (c *Client) HasCreds() bool { - if c.credentials == nil { + creds := c.getCreds() + if creds == nil { return false } - valid := c.credentials.Valid() + if creds.Valid() { + return true + } - if !valid { - c.logger.Println("Google credentials not valid, updating token") - src := c.config.TokenSource(context.TODO(), c.credentials) - newToken, err := src.Token() // this actually goes and renews the tokens - if err != nil { - c.logger.Printf("error updating google token %s", err) - return false - } - if newToken.AccessToken != c.credentials.AccessToken { - c.updateCreds(newToken) - c.credentials = newToken - c.logger.Println("Google credentials updated and saved to disk") - } + c.logger.Println("Google credentials not valid, updating token") + src := c.config.TokenSource(context.TODO(), creds) + newToken, err := src.Token() // this actually goes and renews the tokens + if err != nil { + c.logger.Printf("error updating google token %s", err) + return false + } + if newToken.AccessToken != creds.AccessToken { + c.updateCreds(newToken) + c.logger.Println("Google credentials updated and saved to disk") } return true } @@ -122,7 +150,7 @@ func (c *Client) OauthRedirect(w http.ResponseWriter, r *http.Request) { // TODO: use / validate state token func (c *Client) OauthHandler() func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - if r.FormValue("refresh") != "" || !c.credentials.Valid() { + if r.FormValue("refresh") != "" || !c.getCreds().Valid() { c.updateCreds(nil) } @@ -147,8 +175,32 @@ func (c *Client) OauthHandler() func(w http.ResponseWriter, r *http.Request) { } } +// lazyTokenSource reads the client's credentials fresh on every Token() call, +// so a source handed out before the OAuth web login still works once creds +// arrive (and after they're refreshed). It also fails cleanly - rather than +// returning a source stuck on a nil token - when creds are not yet present. +type lazyTokenSource struct { + c *Client + ctx context.Context +} + +func (l lazyTokenSource) Token() (*oauth2.Token, error) { + creds := l.c.getCreds() + if creds == nil { + return nil, errors.New("google: no credentials yet, login required") + } + return l.c.config.TokenSource(l.ctx, creds).Token() +} + +// TokenSource exposes an OAuth token source so other Google APIs (e.g. Meet) +// can authenticate with the same credentials. The source resolves credentials +// lazily, so it remains valid across a later web login or token refresh. +func (c *Client) TokenSource(ctx context.Context) oauth2.TokenSource { + return lazyTokenSource{c: c, ctx: ctx} +} + func (c *Client) Service(ctx context.Context) (*drive.Service, error) { - return drive.NewService(ctx, option.WithTokenSource(c.config.TokenSource(ctx, c.credentials))) + return drive.NewService(ctx, option.WithTokenSource(c.config.TokenSource(ctx, c.getCreds()))) } func (c *Client) ListFiles(ctx context.Context, q string, pageToken string) (*drive.FileList, error) { diff --git a/google/google_test.go b/google/google_test.go index 3f74c3d..bbd1474 100644 --- a/google/google_test.go +++ b/google/google_test.go @@ -2,9 +2,13 @@ package google import ( "bytes" + "context" "encoding/json" "log" "testing" + "time" + + "golang.org/x/oauth2" ) func TestNewClientFromReader(t *testing.T) { @@ -77,3 +81,79 @@ func TestNewClientFromReader(t *testing.T) { }) } } + +func hasScope(scopes []string, want string) bool { + for _, s := range scopes { + if s == want { + return true + } + } + return false +} + +// TestDefaultScopesExcludeMeet verifies Meet is not requested by default, so +// Drive/Zoom-only users aren't prompted for Meet permissions. +func TestDefaultScopesExcludeMeet(t *testing.T) { + cfg := []byte(`{"web":{"client_id":"id","client_secret":"secret","redirect_uris":["http://r"],"auth_uri":"http://a","token_uri":"http://t"}}`) + var clog bytes.Buffer + c, err := NewClientFromReader(log.New(&clog, "", 0), bytes.NewReader(cfg)) + if err != nil { + t.Fatal(err) + } + if hasScope(c.config.Scopes, MeetScope) { + t.Errorf("did not expect meet scope by default, got %v", c.config.Scopes) + } +} + +// TestWithMeetScope verifies the opt-in option adds the Meet scope. +func TestWithMeetScope(t *testing.T) { + cfg := []byte(`{"web":{"client_id":"id","client_secret":"secret","redirect_uris":["http://r"],"auth_uri":"http://a","token_uri":"http://t"}}`) + var clog bytes.Buffer + c, err := NewClientFromReader(log.New(&clog, "", 0), bytes.NewReader(cfg), WithMeetScope()) + if err != nil { + t.Fatal(err) + } + if !hasScope(c.config.Scopes, MeetScope) { + t.Errorf("expected meet scope with WithMeetScope(), got %v", c.config.Scopes) + } +} + +func TestTokenSource(t *testing.T) { + cfg := []byte(`{"web":{"client_id":"id","client_secret":"secret","redirect_uris":["http://r"],"auth_uri":"http://a","token_uri":"http://t"}}`) + var clog bytes.Buffer + c, err := NewClientFromReader(log.New(&clog, "", 0), bytes.NewReader(cfg)) + if err != nil { + t.Fatal(err) + } + c.credentials = &oauth2.Token{AccessToken: "x"} + if c.TokenSource(context.Background()) == nil { + t.Error("expected non-nil token source") + } +} + +// TestTokenSourceLazy verifies a source handed out before login (no creds) +// errors cleanly rather than panicking, and then resolves credentials that +// arrive afterward - the web-login-first flow. +func TestTokenSourceLazy(t *testing.T) { + cfg := []byte(`{"web":{"client_id":"id","client_secret":"secret","redirect_uris":["http://r"],"auth_uri":"http://a","token_uri":"http://t"}}`) + var clog bytes.Buffer + c, err := NewClientFromReader(log.New(&clog, "", 0), bytes.NewReader(cfg)) + if err != nil { + t.Fatal(err) + } + + ts := c.TokenSource(context.Background()) // built before any creds exist + if _, err := ts.Token(); err == nil { + t.Error("expected error from token source before credentials exist") + } + + // credentials arrive later (e.g. via the OAuth web callback) + c.credentials = &oauth2.Token{AccessToken: "live-token", Expiry: time.Now().Add(time.Hour)} + tok, err := ts.Token() + if err != nil { + t.Fatalf("expected token after creds arrive, got error: %v", err) + } + if tok.AccessToken != "live-token" { + t.Errorf("expected live-token, got %q", tok.AccessToken) + } +} diff --git a/main.go b/main.go index 0dc2063..179d8b2 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ import ( "github.com/graphaelli/zat/cmd" "github.com/graphaelli/zat/google" + "github.com/graphaelli/zat/meet" "github.com/graphaelli/zat/slack" "github.com/graphaelli/zat/zoom" ) @@ -44,6 +45,10 @@ func NewMux(zat *Config, params runParams) *http.ServeMux { logger := zat.logger googleClient := zat.googleClient zoomClient := zat.zoomClient + meetClient := zat.meetClient + + // zoomClient is nil when no zoom config is present (Meet-only install). + zoomReady := func() bool { return zoomClient != nil && zoomClient.HasCreds() } mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -65,18 +70,29 @@ func NewMux(zat *Config, params runParams) *http.ServeMux { mw.Write([]byte("OK")) } - mw.Write([]byte("
Zoom: ")) - if !zoomClient.HasCreds() { - mw.Write([]byte("login")) - //zoomClient.OauthRedirect(w, r) - //return - } else { - mw.Write([]byte("OK")) + if zoomClient != nil { + mw.Write([]byte("
Zoom: ")) + if !zoomReady() { + mw.Write([]byte("login")) + //zoomClient.OauthRedirect(w, r) + //return + } else { + mw.Write([]byte("OK")) + } + } + + if meetClient != nil { + mw.Write([]byte("
Meet: ")) + if googleClient.HasCreds() { + mw.Write([]byte("OK")) + } else { + mw.Write([]byte("login")) + } } if archIsRunning { mw.Write([]byte("
Archiving...")) - } else if googleClient.HasCreds() && zoomClient.HasCreds() { + } else if googleClient.HasCreds() && (zoomReady() || len(zat.meetCopies) > 0) { mw.Write([]byte("
Archive Now")) } else { mw.Write([]byte("
Login, to be able to archive")) @@ -87,7 +103,7 @@ func NewMux(zat *Config, params runParams) *http.ServeMux { for i := 0; i < len(archDetails); i++ { arch := archDetails[i] mw.Write([]byte(fmt.Sprintf("%s%s%d%s", - arch.zoomUrl, arch.name, arch.date, arch.fileNumber, arch.googleDriveURL, arch.status))) + arch.sourceUrl, arch.name, arch.date, arch.fileNumber, arch.googleDriveURL, arch.status))) } mw.Write([]byte("")) } @@ -139,7 +155,12 @@ func NewMux(zat *Config, params runParams) *http.ServeMux { return } - if !zoomClient.HasCreds() { + if zoomClient == nil { + http.NotFound(w, r) + return + } + + if !zoomReady() { logger.Print("no zoom credentials, redirecting") zoomClient.OauthRedirect(w, r) return @@ -160,8 +181,37 @@ func NewMux(zat *Config, params runParams) *http.ServeMux { } }) + mux.HandleFunc("/meet", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/meet" { + http.NotFound(w, r) + return + } + if meetClient == nil { + // no Meet client configured; logging in won't change that + http.Error(w, "meet not configured", http.StatusServiceUnavailable) + return + } + if !googleClient.HasCreds() { + logger.Print("no google credentials for meet, redirecting") + googleClient.OauthRedirect(w, r) + return + } + conferences, err := meetClient.ListConferences(r.Context(), time.Now().Add(-168*time.Hour)) + if err != nil { + logger.Print(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(conferences); err != nil { + logger.Print(err) + } + }) + mux.HandleFunc("/oauth/google", googleClient.OauthHandler()) - mux.HandleFunc("/oauth/zoom", zoomClient.OauthHandler()) + if zoomClient != nil { + mux.HandleFunc("/oauth/zoom", zoomClient.OauthHandler()) + } return mux } @@ -169,6 +219,7 @@ type Directive struct { Name string `json:"name"` Google string `json:"google"` Zoom string `json:"zoom"` + Meet string `json:"meet"` Slack string `json:"slack"` } @@ -179,59 +230,144 @@ var skipDirective = Directive{Name: "{skip"} type Config struct { logger *log.Logger copies map[int64]Directive + meetCopies map[string]Directive googleClient *google.Client slackClient *slackapi.Client zoomClient *zoom.Client + meetClient *meet.Client } func NewConfigFromFile(logger *log.Logger, path string, googleClient *google.Client, zoomClient *zoom.Client, - slackClient *slackapi.Client) (*Config, error) { + meetClient *meet.Client, slackClient *slackapi.Client) (*Config, error) { f, err := os.Open(path) - if err != nil && os.IsExist(err) { + // A missing config file is fine (zat runs with no directives); surface any + // other error (e.g. permissions) instead of silently using an empty config. + // os.IsExist is never true for an os.Open error, so the old guard swallowed + // real failures. + if err != nil && !os.IsNotExist(err) { return nil, err } var r io.Reader = f - // Use an empty io.Reader when the files doesn't exist on disk. if f == nil { r = bytes.NewReader(nil) } else { defer f.Close() } - return NewConfigFromReader(logger, r, googleClient, zoomClient, slackClient) + return NewConfigFromReader(logger, r, googleClient, zoomClient, meetClient, slackClient) } -func NewConfigFromReader(logger *log.Logger, r io.Reader, googleClient *google.Client, zoomClient *zoom.Client, - slackClient *slackapi.Client) (*Config, error) { +// decodeDirectives reads the zat.yml directive list from r. +func decodeDirectives(r io.Reader) ([]Directive, error) { var directives []Directive if err := yaml.NewDecoder(r).Decode(&directives); err != nil && err != io.EOF { return nil, err } + return directives, nil +} + +// loadDirectives reads directives from a file, treating a missing file as no +// directives. Used to decide which Google scopes to request before building +// the client. +func loadDirectives(path string) ([]Directive, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + return decodeDirectives(f) +} + +// meetConfigured reports whether any directive maps a Meet meeting. +func meetConfigured(directives []Directive) bool { + for _, d := range directives { + if d.Meet != "" { + return true + } + } + return false +} + +func NewConfigFromReader(logger *log.Logger, r io.Reader, googleClient *google.Client, zoomClient *zoom.Client, + meetClient *meet.Client, slackClient *slackapi.Client) (*Config, error) { + directives, err := decodeDirectives(r) + if err != nil { + return nil, err + } c := map[int64]Directive{} + mc := map[string]Directive{} for _, d := range directives { - key, err := strconv.ParseInt(strings.ReplaceAll(d.Zoom, "-", ""), 10, 64) - if err != nil { - return nil, err + if d.Zoom != "" { + key, err := strconv.ParseInt(strings.ReplaceAll(d.Zoom, "-", ""), 10, 64) + if err != nil { + return nil, err + } + if _, exists := c[key]; exists { + logger.Printf("config for zoom %d already exists, disabling any action", key) + c[key] = skipDirective + } else { + c[key] = d + } } - if _, exists := c[key]; exists { - logger.Printf("config for %d already exists, disabling any action", key) - c[key] = skipDirective - continue + if d.Meet != "" { + mkey := normalizeMeetingCode(d.Meet) + if _, exists := mc[mkey]; exists { + logger.Printf("config for meet %s already exists, disabling any action", mkey) + mc[mkey] = skipDirective + } else { + mc[mkey] = d + } } - c[key] = d } return &Config{ logger: logger, copies: c, + meetCopies: mc, googleClient: googleClient, slackClient: slackClient, zoomClient: zoomClient, + meetClient: meetClient, }, nil } +// normalizeMeetingCode lowercases and strips separators so "abc-defg-hij" +// and "ABCDEFGHIJ" map to the same directive key. +func normalizeMeetingCode(s string) string { + s = strings.ReplaceAll(s, "-", "") + s = strings.ReplaceAll(s, " ", "") + return strings.ToLower(s) +} + +// meetFolderName constructs the name of the gdrive folder for a Meet conference. +func meetFolderName(conf meet.Conference) string { + return conf.StartTime.Format("2006-01-02") +} + +// meetRecordingFileName constructs the destination file name for a Meet artifact. +// Naming uses the configured directive Name since the Meet API does not reliably +// expose a meeting title. +func meetRecordingFileName(action Directive, conf meet.Conference, artifact meet.Artifact) string { + start := artifact.StartTime + if start.IsZero() { + start = conf.StartTime + } + baseName := fmt.Sprintf("%s %s", start.Format("2006-01-02-150405"), action.Name) + var ext string + switch artifact.Kind { + case "transcript": + ext = "transcript" + default: // recording + ext = "mp4" + } + return baseName + "." + ext +} + // meetingFolderName constructs the name of the gdrive folder containing the meeting func meetingFolderName(meeting zoom.Meeting) string { // TODO: allow customization of folder name, perhaps "docker inspect --format" style @@ -304,7 +440,7 @@ func (z *Config) Archive(ctx context.Context, meeting zoom.Meeting, params runPa fileNumber: 0, status: "archiving", date: meeting.StartTime.Format("2006-01-02 15:04"), - zoomUrl: meeting.ShareURL} + sourceUrl: meeting.ShareURL} archDetails = append(archDetails, &curArchMeeting) // check what is already uploaded for this meeting @@ -478,6 +614,145 @@ func (z *Config) Archive(ctx context.Context, meeting zoom.Meeting, params runPa return nil } +func (z *Config) archiveMeetConference(ctx context.Context, conf meet.Conference, params runParams) error { + span, ctx := apm.StartSpan(ctx, "archiveMeetConference", "app") + defer span.End() + + // ListConferences returns every conference the account attended, so an + // unmapped or duplicate-disabled meeting code is the common case, not an + // error worth reporting to APM - log and skip quietly. + action := z.meetCopies[normalizeMeetingCode(conf.MeetingCode)] + if action == skipDirective { + z.logger.Printf("skipped mapping meet conference %q", conf.MeetingCode) + return nil + } + if action.Google == "" { + z.logger.Printf("no mapping found for meet conference %q, skipping", conf.MeetingCode) + return nil + } + + // nothing to copy (e.g. a mapped meeting occurred but wasn't recorded) - + // skip before creating an empty dated folder in Drive. + if len(conf.Artifacts) == 0 { + z.logger.Printf("no artifacts for meet conference %q, skipping", conf.MeetingCode) + return nil + } + + var curArchMeeting = archivedMeeting{ + name: action.Name, + fileNumber: 0, + status: "archiving", + date: conf.StartTime.Format("2006-01-02 15:04"), + sourceUrl: conf.SourceURL, + } + archDetails = append(archDetails, &curArchMeeting) + + gdrive, err := z.googleClient.Service(ctx) + if err != nil { + curArchMeeting.status = "error" + return fmt.Errorf("while creating gdrive client: %w", err) + } + + parent, err := gdrive.Files.Get(action.Google).Context(ctx).SupportsAllDrives(true).Do() + if err != nil { + curArchMeeting.status = "error" + return fmt.Errorf("while finding parent of %q: %w", action.Google, err) + } + + meetingFolder, err, created := mkdir(ctx, gdrive, parent, meetFolderName(conf)) + if err != nil { + curArchMeeting.status = "error" + return fmt.Errorf("while finding/creating meeting folder: %w", err) + } + if created { + z.logger.Printf("created folder %s: https://drive.google.com/drive/folders/%s", meetingFolder.Name, meetingFolder.Id) + } else { + z.logger.Printf("using existing folder %s: https://drive.google.com/drive/folders/%s", meetingFolder.Name, meetingFolder.Id) + } + curArchMeeting.googleDriveURL = "https://drive.google.com/drive/folders/" + meetingFolder.Id + + // list folder for this meeting to dedupe + alreadyUploaded := make(map[string]struct{}) + nextPageToken := "" + for page := 0; page < 5; page++ { + call := gdrive.Files.List(). + Context(ctx). + SupportsTeamDrives(true). + IncludeTeamDriveItems(true). + Q(fmt.Sprintf("%q in parents", meetingFolder.Id)) + if nextPageToken != "" { + call = call.PageToken(nextPageToken) + } + meetingFiles, err := call.Do() + if err != nil { + curArchMeeting.status = "error" + return fmt.Errorf("while listing meeting folder: %w", err) + } + for _, f := range meetingFiles.Files { + alreadyUploaded[f.Name] = struct{}{} + } + if meetingFiles.NextPageToken == "" { + break + } + nextPageToken = meetingFiles.NextPageToken + } + + exclude := func(string) bool { return false } + if params.uploadFilter != "" { + allowedFileTypes := map[string]bool{} + for _, uf := range strings.Split(params.uploadFilter, ",") { + allowedFileTypes[strings.ToLower(strings.TrimSpace(uf))] = true + } + exclude = func(kind string) bool { + return !allowedFileTypes[strings.ToLower(kind)] + } + } + + notifyUpload := false + for _, a := range conf.Artifacts { + name := meetRecordingFileName(action, conf, a) + if exclude(a.Kind) { + z.logger.Printf("skipping copy %s, kind %q excluded", name, a.Kind) + continue + } + if _, exists := alreadyUploaded[name]; exists { + curArchMeeting.status = "done" + curArchMeeting.fileNumber++ + z.logger.Printf("skipping copy %s to %s/%s, already exists", name, parent.Name, meetingFolder.Name) + continue + } + z.logger.Printf("copying %q to \"%s/%s\"", name, parent.Name, meetingFolder.Name) + _, err := gdrive.Files.Copy(a.DriveFileID, &drive.File{ + Name: name, + Parents: []string{meetingFolder.Id}, + }).Context(ctx).SupportsAllDrives(true).Do() + if err != nil { + curArchMeeting.status = "error" + return fmt.Errorf("while copying %s (%s): %w", name, a.DriveFileID, err) + } + curArchMeeting.fileNumber++ + z.logger.Printf("copied %q to %s/%s", name, parent.Name, meetingFolder.Name) + if a.Kind == "recording" { + notifyUpload = true + } + } + + if notifyUpload && action.Slack != "" && z.slackClient != nil { + slackSpan, ctx := apm.StartSpan(ctx, "slack", "app") + body := fmt.Sprintf("%s recording now available: https://drive.google.com/drive/folders/%s", action.Name, meetingFolder.Id) + channel, _, text, err := z.slackClient.SendMessageContext(ctx, action.Slack, slackapi.MsgOptionText(body, true)) + if err != nil { + z.logger.Printf("failed to notify slack %q: %s", action.Slack, err) + apm.CaptureError(ctx, err).Send() + } else { + z.logger.Printf("notified slack %q: %s", channel, text) + } + slackSpan.End() + } + curArchMeeting.status = "done" + return nil +} + type runParams struct { minDuration int since time.Duration @@ -490,7 +765,6 @@ func (z *Config) Run(params runParams) error { ctx := apm.ContextWithTransaction(context.Background(), tx) z.logger.Print("archiving recordings") - archDetails = []*archivedMeeting{} nextPageToken := "" for { recordings, err := z.zoomClient.ListRecordings(ctx, time.Now().Add(-1*params.since), nextPageToken) @@ -517,12 +791,40 @@ func (z *Config) Run(params runParams) error { return nil } +func (z *Config) RunMeet(params runParams) error { + tx := apm.DefaultTracer.StartTransaction("archiveMeetRecordings", "background") + defer tx.End() + ctx := apm.ContextWithTransaction(context.Background(), tx) + + z.logger.Print("archiving meet recordings") + conferences, err := z.meetClient.ListConferences(ctx, time.Now().Add(-1*params.since)) + if err != nil { + apm.CaptureError(ctx, err).Send() + return fmt.Errorf("failed to list meet conferences: %w", err) + } + for _, conf := range conferences { + if !conf.EndTime.IsZero() && !conf.StartTime.IsZero() { + duration := int(conf.EndTime.Sub(conf.StartTime).Minutes()) + if duration < params.minDuration { + z.logger.Printf("skipped %d minute meet conference at %s", duration, conf.StartTime) + continue + } + } + if err := z.archiveMeetConference(ctx, conf, params); err != nil { + z.logger.Print(err) + apm.CaptureError(ctx, err).Send() + } + } + z.logger.Print("done archiving meet recordings") + return nil +} + type archivedMeeting struct { name string fileNumber int status string date string - zoomUrl string + sourceUrl string googleDriveURL string } @@ -542,8 +844,10 @@ func doRun(zat *Config, params runParams) { zat.logger.Println("no Google creds") return } - if !zat.zoomClient.HasCreds() { - zat.logger.Println("no Zoom creds") + // zoomClient is nil on a Meet-only install (no zoom config). + zoomReady := zat.zoomClient != nil && zat.zoomClient.HasCreds() + if !zoomReady && len(zat.meetCopies) == 0 { + zat.logger.Println("no Zoom creds and no Meet directives") return } @@ -557,8 +861,19 @@ func doRun(zat *Config, params runParams) { return } - if err := zat.Run(params); err != nil { - zat.logger.Println(err) + // reset once per cycle, before any backend runs, so a Meet-only deployment + // (where Run is never called) doesn't accumulate rows across cycles. + archDetails = []*archivedMeeting{} + + if zoomReady { + if err := zat.Run(params); err != nil { + zat.logger.Println(err) + } + } + if zat.meetClient != nil && len(zat.meetCopies) > 0 && zat.googleClient.HasCreds() { + if err := zat.RunMeet(params); err != nil { + zat.logger.Println(err) + } } archIsRunningMu.Lock() @@ -573,8 +888,8 @@ func main() { minDuration := flag.Int("min-duration", 5, "minimum meeting duration in minutes to archive") since := flag.Duration("since", 168*time.Hour, "since") uploadFilter := flag.String("t", "", - "comma separated list of file types to archive (mp4, m4a, timeline, transcript, chat, cc, csv), see: "+ - "https://marketplace.zoom.us/docs/api-reference/zoom-api/cloud-recording/recordingget") + "comma separated list of file types to archive; Zoom: mp4, m4a, timeline, transcript, chat, cc, csv; "+ + "Meet: recording, transcript") flag.Parse() logger := log.New(os.Stderr, "", cmd.LogFmt) @@ -583,30 +898,59 @@ func main() { http.DefaultClient = apmhttp.WrapClient(http.DefaultClient) http.DefaultTransport = apmhttp.WrapRoundTripper(http.DefaultTransport) + zatConfigPath := path.Join(*cfgDir, cmd.ZatConfigPath) + + // Decide up front whether Meet is in use, so the Google login only requests + // the Meet scope (and a Meet client is built) when there are meet directives. + // Drive/Zoom-only users aren't prompted for Meet permissions. + directives, err := loadDirectives(zatConfigPath) + if err != nil { + logger.Println("failed to load config", err) + } + useMeet := meetConfigured(directives) + + googleOptions := []google.ClientOption{ + google.NewCredentialsManager(path.Join(*cfgDir, cmd.GoogleCredsPath)).ClientOption, + } + if useMeet { + googleOptions = append(googleOptions, google.WithMeetScope()) + } googleClient, err := google.NewClientFromFile( logger, path.Join(*cfgDir, cmd.GoogleConfigPath), - google.NewCredentialsManager(path.Join(*cfgDir, cmd.GoogleCredsPath)).ClientOption, + googleOptions..., ) if err != nil { logger.Fatal(err) } + // Zoom is optional: a missing/unreadable zoom config disables Zoom archival + // (e.g. a Meet-only install) rather than aborting startup. zoomClient, err := zoom.NewClientFromFile( logger, path.Join(*cfgDir, cmd.ZoomConfigPath), zoom.NewCredentialsManager(path.Join(*cfgDir, cmd.ZoomCredsPath)).ClientOption, ) if err != nil { - logger.Fatal(err) + logger.Printf("zoom archival disabled: %s", err) + zoomClient = nil } slackClient, _ := slack.NewClientFromEnvOrFile(logger, path.Join(*cfgDir, cmd.SlackConfigPath), slackapi.OptionHTTPClient(http.DefaultClient)) + + // Meet client only when Meet is configured; nil otherwise (handlers guard it). + var meetClient *meet.Client + if useMeet { + meetClient, err = meet.NewClient(logger, googleClient.TokenSource(context.Background())) + if err != nil { + logger.Fatal(err) + } + } rp := runParams{ minDuration: *minDuration, since: *since, uploadFilter: *uploadFilter, } - zat, err := NewConfigFromFile(logger, path.Join(*cfgDir, cmd.ZatConfigPath), googleClient, zoomClient, slackClient) + zat, err := NewConfigFromFile(logger, zatConfigPath, googleClient, zoomClient, meetClient, slackClient) if err != nil { // ok to continue without config, just can't do archival logger.Println("failed to load config", err) diff --git a/main_test.go b/main_test.go index 4031e87..12e0f84 100644 --- a/main_test.go +++ b/main_test.go @@ -2,12 +2,15 @@ package main import ( "bytes" + "encoding/json" "errors" "fmt" "log" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "testing" "time" @@ -16,6 +19,8 @@ import ( "github.com/graphaelli/zat/google" googlemock "github.com/graphaelli/zat/google/mock" + "github.com/graphaelli/zat/meet" + meetmock "github.com/graphaelli/zat/meet/mock" "github.com/graphaelli/zat/zoom" zoommock "github.com/graphaelli/zat/zoom/mock" "github.com/stretchr/testify/assert" @@ -25,6 +30,7 @@ import ( var ( nopGoogleClient = &google.Client{} nopZoomClient = &zoom.Client{} + nopMeetClient = &meet.Client{} rp = runParams{ minDuration: 5, since: 24 * time.Hour, @@ -308,7 +314,215 @@ func TestRecordingFileName(t *testing.T) { } func TestConfigFromFile(t *testing.T) { - c, err := NewConfigFromFile(nil, "does-not-exist", nopGoogleClient, nopZoomClient, nil) + c, err := NewConfigFromFile(nil, "does-not-exist", nopGoogleClient, nopZoomClient, nopMeetClient, nil) require.NoError(t, err) assert.NotNil(t, c) } + +func TestMeetRecordingFileName(t *testing.T) { + start := time.Date(2026, 5, 20, 13, 0, 0, 0, time.UTC) + conf := meet.Conference{StartTime: start} + action := Directive{Name: "UI Weekly"} + + tests := []struct { + name string + artifact meet.Artifact + want string + }{ + { + name: "recording", + artifact: meet.Artifact{Kind: "recording", StartTime: start}, + want: "2026-05-20-130000 UI Weekly.mp4", + }, + { + name: "transcript", + artifact: meet.Artifact{Kind: "transcript", StartTime: start}, + want: "2026-05-20-130000 UI Weekly.transcript", + }, + { + name: "artifact without start falls back to conference start", + artifact: meet.Artifact{Kind: "recording"}, + want: "2026-05-20-130000 UI Weekly.mp4", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := meetRecordingFileName(action, conf, tt.artifact); got != tt.want { + t.Errorf("meetRecordingFileName() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfigMeetMapping(t *testing.T) { + yml := ` +- name: UI Weekly + google: folderA + meet: abc-defg-hij +- name: Dup + google: folderB + meet: ABCDEFGHIJ +- name: Team Weekly + google: folderC + meet: zzz-yyyy-xxx +` + var clog bytes.Buffer + c, err := NewConfigFromReader(log.New(&clog, "", 0), strings.NewReader(yml), + nopGoogleClient, nopZoomClient, nopMeetClient, nil) + require.NoError(t, err) + + // "abc-defg-hij" and "ABCDEFGHIJ" normalize to the same key -> skipDirective + dup := c.meetCopies[normalizeMeetingCode("abc-defg-hij")] + assert.Equal(t, skipDirective, dup) + + // distinct code maps normally + ok := c.meetCopies[normalizeMeetingCode("zzz-yyyy-xxx")] + assert.Equal(t, "folderC", ok.Google) +} + +func TestMeetConfigured(t *testing.T) { + zoomOnly, err := decodeDirectives(strings.NewReader("- name: Z\n google: f\n zoom: 123-456-789\n")) + require.NoError(t, err) + assert.False(t, meetConfigured(zoomOnly), "zoom-only config should not enable Meet") + + withMeet, err := decodeDirectives(strings.NewReader("- name: M\n google: f\n meet: abc-defg-hij\n")) + require.NoError(t, err) + assert.True(t, meetConfigured(withMeet), "config with a meet directive should enable Meet") + + assert.False(t, meetConfigured(nil), "no directives should not enable Meet") +} + +func TestLoadDirectivesMissingFile(t *testing.T) { + directives, err := loadDirectives(filepath.Join(t.TempDir(), "nope.yml")) + require.NoError(t, err, "a missing config file should not be an error") + assert.Empty(t, directives) +} + +// TestMuxMeetOnly verifies the web UI does not panic when zoomClient is nil +// (a Meet-only install with no zoom config) and that /zoom 404s. +func TestMuxMeetOnly(t *testing.T) { + var clog bytes.Buffer + zat := &Config{ + logger: log.New(&clog, "", 0), + copies: map[int64]Directive{}, + meetCopies: map[string]Directive{"abcdefghij": {Name: "UI Weekly", Google: "folderA", Meet: "abc-defg-hij"}}, + googleClient: nopGoogleClient, + zoomClient: nil, + meetClient: nil, + } + server := httptest.NewServer(NewMux(zat, rp)) + defer server.Close() + + client := server.Client() + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + + rsp, err := client.Get(server.URL + "/") + require.NoError(t, err) + defer rsp.Body.Close() + assert.Equal(t, http.StatusOK, rsp.StatusCode, "/ should render without a zoom client") + + zrsp, err := client.Get(server.URL + "/zoom") + require.NoError(t, err) + defer zrsp.Body.Close() + assert.Equal(t, http.StatusNotFound, zrsp.StatusCode, "/zoom should 404 when zoom is disabled") +} + +// loggedInGoogleClient builds a google.Client whose HasCreds() is true by +// loading a non-expired token from a temp creds file. +func loggedInGoogleClient(t *testing.T) *google.Client { + t.Helper() + credsPath := filepath.Join(t.TempDir(), "google.creds.json") + b, err := json.Marshal(oauth2.Token{AccessToken: "test-token", Expiry: time.Now().Add(time.Hour)}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(credsPath, b, 0600)) + + var clog bytes.Buffer + gc, err := google.NewClient(log.New(&clog, "", 0), + &oauth2.Config{RedirectURL: "http://localhost:8080/oauth/google"}, + google.NewCredentialsManager(credsPath).ClientOption) + require.NoError(t, err) + require.True(t, gc.HasCreds(), "expected loaded creds to be valid") + return gc +} + +func meetClientAt(t *testing.T, baseURL string, hc *http.Client) *meet.Client { + t.Helper() + var mlog bytes.Buffer + mc, err := meet.NewClient(log.New(&mlog, "", 0), + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "x"}), + meet.CustomHTTPClientOption(hc), meet.CustomBaseURLOption(baseURL)) + require.NoError(t, err) + return mc +} + +func TestMuxMeet(t *testing.T) { + noRedirect := func(c *http.Client) { c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } } + + t.Run("503 when meet client not configured", func(t *testing.T) { + var clog bytes.Buffer + zat := &Config{logger: log.New(&clog, "", 0), googleClient: loggedInGoogleClient(t), zoomClient: nil, meetClient: nil} + srv := httptest.NewServer(NewMux(zat, rp)) + defer srv.Close() + + rsp, err := srv.Client().Get(srv.URL + "/meet") + require.NoError(t, err) + defer rsp.Body.Close() + assert.Equal(t, http.StatusServiceUnavailable, rsp.StatusCode) + }) + + t.Run("redirects to login when google creds missing", func(t *testing.T) { + var clog bytes.Buffer + gc, err := google.NewClient(log.New(&clog, "", 0), &oauth2.Config{RedirectURL: "http://localhost:8080/oauth/google"}) + require.NoError(t, err) + meetSrv := httptest.NewServer(meetmock.ApiHandler(t)) + defer meetSrv.Close() + zat := &Config{logger: log.New(&clog, "", 0), googleClient: gc, zoomClient: nil, + meetClient: meetClientAt(t, meetSrv.URL, meetSrv.Client())} + srv := httptest.NewServer(NewMux(zat, rp)) + defer srv.Close() + + client := srv.Client() + noRedirect(client) + rsp, err := client.Get(srv.URL + "/meet") + require.NoError(t, err) + defer rsp.Body.Close() + assert.Equal(t, http.StatusFound, rsp.StatusCode) + assert.Equal(t, "http://localhost:8080/oauth/google", rsp.Header.Get("Location")) + }) + + t.Run("returns JSON conferences when authed", func(t *testing.T) { + meetSrv := httptest.NewServer(meetmock.ApiHandler(t)) + defer meetSrv.Close() + var clog bytes.Buffer + zat := &Config{logger: log.New(&clog, "", 0), googleClient: loggedInGoogleClient(t), zoomClient: nil, + meetClient: meetClientAt(t, meetSrv.URL, meetSrv.Client())} + srv := httptest.NewServer(NewMux(zat, rp)) + defer srv.Close() + + rsp, err := srv.Client().Get(srv.URL + "/meet") + require.NoError(t, err) + defer rsp.Body.Close() + assert.Equal(t, http.StatusOK, rsp.StatusCode) + assert.Equal(t, "application/json", rsp.Header.Get("Content-Type")) + var confs []meet.Conference + require.NoError(t, json.NewDecoder(rsp.Body).Decode(&confs)) + assert.Len(t, confs, 1) + }) + + t.Run("500 when ListConferences errors", func(t *testing.T) { + meetSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer meetSrv.Close() + var clog bytes.Buffer + zat := &Config{logger: log.New(&clog, "", 0), googleClient: loggedInGoogleClient(t), zoomClient: nil, + meetClient: meetClientAt(t, meetSrv.URL, meetSrv.Client())} + srv := httptest.NewServer(NewMux(zat, rp)) + defer srv.Close() + + rsp, err := srv.Client().Get(srv.URL + "/meet") + require.NoError(t, err) + defer rsp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, rsp.StatusCode) + }) +} diff --git a/meet/meet.go b/meet/meet.go new file mode 100644 index 0000000..dfcc221 --- /dev/null +++ b/meet/meet.go @@ -0,0 +1,305 @@ +package meet + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "path" + "time" + + "golang.org/x/oauth2" +) + +const defaultBaseURL = "https://meet.googleapis.com/" + +// Artifact is one copyable thing already living in Drive. +type Artifact struct { + DriveFileID string // Drive file id of recording mp4 or transcript doc + Kind string // "recording" | "transcript" + StartTime time.Time +} + +// Conference is one meeting occurrence with its artifacts. +type Conference struct { + Name string // conferenceRecords/xxx + MeetingCode string // resolved from space, matches zat.yml `meet:` + StartTime time.Time + EndTime time.Time + SourceURL string // space.meetingUri + Artifacts []Artifact +} + +// Client talks to the Google Meet REST API for discovery only. +type Client struct { + logger *log.Logger + httpClient *http.Client + ts oauth2.TokenSource + baseURL *url.URL +} + +type ClientOption func(*Client) + +func CustomHTTPClientOption(httpClient *http.Client) ClientOption { + return func(c *Client) { c.httpClient = httpClient } +} + +func CustomBaseURLOption(raw string) ClientOption { + return func(c *Client) { + if u, err := url.Parse(raw); err == nil { + c.baseURL = u + } + } +} + +// NewClient builds a Meet client from any oauth2.TokenSource. +func NewClient(logger *log.Logger, ts oauth2.TokenSource, options ...ClientOption) (*Client, error) { + if ts == nil { + return nil, errors.New("meet: token source is required") + } + base, _ := url.Parse(defaultBaseURL) + c := &Client{ + logger: logger, + httpClient: http.DefaultClient, + ts: ts, + baseURL: base, + } + for _, o := range options { + o(c) + } + return c, nil +} + +func (c *Client) authHeader() (string, error) { + tok, err := c.ts.Token() + if err != nil { + return "", fmt.Errorf("meet: while obtaining token: %w", err) + } + return "Bearer " + tok.AccessToken, nil +} + +// getJSON performs a GET against the Meet API and decodes the body into dst. +func (c *Client) getJSON(ctx context.Context, relPath string, query url.Values, dst interface{}) error { + u := *c.baseURL + u.Path = path.Join(u.Path, relPath) + if query != nil { + u.RawQuery = query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return fmt.Errorf("meet: while building request %s: %w", relPath, err) + } + auth, err := c.authHeader() + if err != nil { + return err + } + req.Header.Set("Authorization", auth) + + rsp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("meet: while calling %s: %w", relPath, err) + } + defer rsp.Body.Close() + if rsp.StatusCode != http.StatusOK { + msg := fmt.Sprintf("meet: API call to %s failed: %d", u.String(), rsp.StatusCode) + if body, rerr := ioutil.ReadAll(rsp.Body); rerr == nil { + msg += ": " + string(body) + } + if c.logger != nil { + c.logger.Print(msg) + } + return errors.New(msg) + } + if err := json.NewDecoder(rsp.Body).Decode(dst); err != nil { + return fmt.Errorf("meet: while decoding %s: %w", relPath, err) + } + return nil +} + +type driveDestination struct { + File string `json:"file"` + ExportURI string `json:"exportUri"` +} + +type docsDestination struct { + Document string `json:"document"` + ExportURI string `json:"exportUri"` +} + +type apiRecording struct { + Name string `json:"name"` + State string `json:"state"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + DriveDestination driveDestination `json:"driveDestination"` +} + +type apiTranscript struct { + Name string `json:"name"` + State string `json:"state"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + DocsDestination docsDestination `json:"docsDestination"` +} + +type apiSpace struct { + Name string `json:"name"` + MeetingURI string `json:"meetingUri"` + MeetingCode string `json:"meetingCode"` +} + +type apiConferenceRecord struct { + Name string `json:"name"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + Space string `json:"space"` +} + +type listConferenceRecordsResponse struct { + ConferenceRecords []apiConferenceRecord `json:"conferenceRecords"` + NextPageToken string `json:"nextPageToken"` +} + +type listRecordingsResponse struct { + Recordings []apiRecording `json:"recordings"` + NextPageToken string `json:"nextPageToken"` +} + +type listTranscriptsResponse struct { + Transcripts []apiTranscript `json:"transcripts"` + NextPageToken string `json:"nextPageToken"` +} + +func parseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t +} + +// ListConferences enumerates conference records since `since`, resolving each +// space's meetingCode and listing its recordings + transcripts. +func (c *Client) ListConferences(ctx context.Context, since time.Time) ([]Conference, error) { + var out []Conference + pageToken := "" + for { + q := url.Values{} + q.Set("filter", fmt.Sprintf("start_time>=%q", since.Format(time.RFC3339))) + q.Set("pageSize", "100") + if pageToken != "" { + q.Set("pageToken", pageToken) + } + var resp listConferenceRecordsResponse + if err := c.getJSON(ctx, "/v2/conferenceRecords", q, &resp); err != nil { + return nil, fmt.Errorf("meet: while listing conference records: %w", err) + } + for _, cr := range resp.ConferenceRecords { + conf := Conference{ + Name: cr.Name, + StartTime: parseTime(cr.StartTime), + EndTime: parseTime(cr.EndTime), + } + + // resolve space -> meeting code; skip conference on failure + if cr.Space != "" { + var sp apiSpace + if err := c.getJSON(ctx, "/v2/"+cr.Space, nil, &sp); err != nil { + if c.logger != nil { + c.logger.Printf("meet: skipping %s, could not resolve space %s: %v", cr.Name, cr.Space, err) + } + continue + } + conf.MeetingCode = sp.MeetingCode + conf.SourceURL = sp.MeetingURI + } + + conf.Artifacts = append(conf.Artifacts, c.recordingArtifacts(ctx, cr.Name)...) + conf.Artifacts = append(conf.Artifacts, c.transcriptArtifacts(ctx, cr.Name)...) + out = append(out, conf) + } + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + return out, nil +} + +func (c *Client) recordingArtifacts(ctx context.Context, record string) []Artifact { + var arts []Artifact + pageToken := "" + for { + q := url.Values{} + q.Set("pageSize", "100") + if pageToken != "" { + q.Set("pageToken", pageToken) + } + var resp listRecordingsResponse + if err := c.getJSON(ctx, "/v2/"+record+"/recordings", q, &resp); err != nil { + if c.logger != nil { + c.logger.Printf("meet: while listing recordings for %s: %v", record, err) + } + break + } + for _, r := range resp.Recordings { + if r.DriveDestination.File == "" { + if c.logger != nil { + c.logger.Printf("meet: recording %s not yet in Drive, skipping", r.Name) + } + continue + } + arts = append(arts, Artifact{ + DriveFileID: r.DriveDestination.File, + Kind: "recording", + StartTime: parseTime(r.StartTime), + }) + } + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + return arts +} + +func (c *Client) transcriptArtifacts(ctx context.Context, record string) []Artifact { + var arts []Artifact + pageToken := "" + for { + q := url.Values{} + q.Set("pageSize", "100") + if pageToken != "" { + q.Set("pageToken", pageToken) + } + var resp listTranscriptsResponse + if err := c.getJSON(ctx, "/v2/"+record+"/transcripts", q, &resp); err != nil { + if c.logger != nil { + c.logger.Printf("meet: while listing transcripts for %s: %v", record, err) + } + break + } + for _, tr := range resp.Transcripts { + if tr.DocsDestination.Document == "" { + if c.logger != nil { + c.logger.Printf("meet: transcript %s not yet in Drive, skipping", tr.Name) + } + continue + } + arts = append(arts, Artifact{ + DriveFileID: tr.DocsDestination.Document, + Kind: "transcript", + StartTime: parseTime(tr.StartTime), + }) + } + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + return arts +} diff --git a/meet/meet_test.go b/meet/meet_test.go new file mode 100644 index 0000000..474d9bf --- /dev/null +++ b/meet/meet_test.go @@ -0,0 +1,301 @@ +package meet + +import ( + "bytes" + "context" + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "golang.org/x/oauth2" + + meetmock "github.com/graphaelli/zat/meet/mock" +) + +func staticTS() oauth2.TokenSource { + return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-access-token"}) +} + +func TestNewClient(t *testing.T) { + var clog bytes.Buffer + c, err := NewClient(log.New(&clog, "", 0), staticTS()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c == nil { + t.Fatal("expected non-nil client") + } +} + +func TestNewClientNilTokenSource(t *testing.T) { + var clog bytes.Buffer + if _, err := NewClient(log.New(&clog, "", 0), nil); err == nil { + t.Fatal("expected error from nil token source") + } +} + +func TestDoDecodesAndAuth(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]string{"hello": "world"}) + })) + defer srv.Close() + + var clog bytes.Buffer + c, err := NewClient(log.New(&clog, "", 0), staticTS(), + CustomHTTPClientOption(srv.Client()), CustomBaseURLOption(srv.URL)) + if err != nil { + t.Fatal(err) + } + var dst map[string]string + if err := c.getJSON(context.Background(), "/v2/anything", nil, &dst); err != nil { + t.Fatalf("getJSON error: %v", err) + } + if dst["hello"] != "world" { + t.Errorf("expected decoded body, got %v", dst) + } + if gotAuth != "Bearer test-access-token" { + t.Errorf("expected bearer auth header, got %q", gotAuth) + } +} + +func TestDoErrorsOnNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + var clog bytes.Buffer + c, _ := NewClient(log.New(&clog, "", 0), staticTS(), + CustomHTTPClientOption(srv.Client()), CustomBaseURLOption(srv.URL)) + var dst map[string]string + if err := c.getJSON(context.Background(), "/v2/anything", nil, &dst); err == nil { + t.Fatal("expected error on non-200 response") + } +} + +func TestListConferences(t *testing.T) { + srv := httptest.NewServer(meetmock.ApiHandler(t)) + defer srv.Close() + + var clog bytes.Buffer + c, _ := NewClient(log.New(&clog, "", 0), staticTS(), + CustomHTTPClientOption(srv.Client()), CustomBaseURLOption(srv.URL)) + + confs, err := c.ListConferences(context.Background(), time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("ListConferences error: %v", err) + } + if len(confs) != 1 { + t.Fatalf("expected 1 conference, got %d", len(confs)) + } + conf := confs[0] + if conf.MeetingCode != "abc-defg-hij" { + t.Errorf("expected meetingCode abc-defg-hij, got %q", conf.MeetingCode) + } + if conf.SourceURL != "https://meet.google.com/abc-defg-hij" { + t.Errorf("unexpected SourceURL %q", conf.SourceURL) + } + if len(conf.Artifacts) != 2 { + t.Fatalf("expected 2 artifacts, got %d", len(conf.Artifacts)) + } + var rec, tr int + for _, a := range conf.Artifacts { + switch a.Kind { + case "recording": + rec++ + if a.DriveFileID != "driveFileRec1" { + t.Errorf("unexpected recording file id %q", a.DriveFileID) + } + case "transcript": + tr++ + if a.DriveFileID != "docFileTr1" { + t.Errorf("unexpected transcript file id %q", a.DriveFileID) + } + } + } + if rec != 1 || tr != 1 { + t.Errorf("expected 1 recording and 1 transcript, got %d/%d", rec, tr) + } +} + +// TestListConferencesPaginates verifies the pagination loops across all three +// list endpoints, and that the start_time filter is sent. +func TestListConferencesPaginates(t *testing.T) { + var sawFilter string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + token := r.URL.Query().Get("pageToken") + switch r.URL.Path { + case "/v2/conferenceRecords": + sawFilter = r.URL.Query().Get("filter") + if token == "" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "conferenceRecords": []map[string]interface{}{ + {"name": "conferenceRecords/c1", "startTime": "2026-05-20T13:00:00Z", "endTime": "2026-05-20T13:30:00Z", "space": "spaces/s1"}, + }, + "nextPageToken": "page2", + }) + } else { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "conferenceRecords": []map[string]interface{}{ + {"name": "conferenceRecords/c2", "startTime": "2026-05-21T13:00:00Z", "endTime": "2026-05-21T13:30:00Z", "space": "spaces/s2"}, + }, + }) + } + case "/v2/spaces/s1": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"meetingCode": "aaa-bbbb-ccc", "meetingUri": "https://meet.google.com/aaa-bbbb-ccc"}) + case "/v2/spaces/s2": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"meetingCode": "ddd-eeee-fff", "meetingUri": "https://meet.google.com/ddd-eeee-fff"}) + case "/v2/conferenceRecords/c1/recordings": + if token == "" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "recordings": []map[string]interface{}{{"name": "r1", "driveDestination": map[string]string{"file": "rec1a"}}}, + "nextPageToken": "rpage2", + }) + } else { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "recordings": []map[string]interface{}{{"name": "r2", "driveDestination": map[string]string{"file": "rec1b"}}}, + }) + } + case "/v2/conferenceRecords/c1/transcripts": + if token == "" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "transcripts": []map[string]interface{}{{"name": "t1", "docsDestination": map[string]string{"document": "tr1a"}}}, + "nextPageToken": "tpage2", + }) + } else { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "transcripts": []map[string]interface{}{{"name": "t2", "docsDestination": map[string]string{"document": "tr1b"}}}, + }) + } + default: + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + } + })) + defer srv.Close() + + var clog bytes.Buffer + c, _ := NewClient(log.New(&clog, "", 0), staticTS(), + CustomHTTPClientOption(srv.Client()), CustomBaseURLOption(srv.URL)) + + confs, err := c.ListConferences(context.Background(), time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("ListConferences error: %v", err) + } + if len(confs) != 2 { + t.Fatalf("expected 2 conferences across pages, got %d", len(confs)) + } + if sawFilter == "" || !strings.Contains(sawFilter, "start_time>=") { + t.Errorf("expected start_time filter, got %q", sawFilter) + } + // c1 paginated recordings (rec1a, rec1b) and transcripts (tr1a, tr1b) + var recs, trs int + for _, a := range confs[0].Artifacts { + switch a.Kind { + case "recording": + recs++ + case "transcript": + trs++ + } + } + if recs != 2 { + t.Errorf("expected 2 paginated recording artifacts for c1, got %d", recs) + } + if trs != 2 { + t.Errorf("expected 2 paginated transcript artifacts for c1, got %d", trs) + } + if confs[0].MeetingCode != "aaa-bbbb-ccc" || confs[1].MeetingCode != "ddd-eeee-fff" { + t.Errorf("unexpected meeting codes %q / %q", confs[0].MeetingCode, confs[1].MeetingCode) + } +} + +// TestListConferencesSkipsOnSpaceFailure verifies a conference whose space +// cannot be resolved is skipped without failing the whole listing. +func TestListConferencesSkipsOnSpaceFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v2/conferenceRecords": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "conferenceRecords": []map[string]interface{}{ + {"name": "conferenceRecords/c1", "startTime": "2026-05-20T13:00:00Z", "space": "spaces/gone"}, + }, + }) + case "/v2/spaces/gone": + http.Error(w, "not found", http.StatusNotFound) + default: + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + } + })) + defer srv.Close() + + var clog bytes.Buffer + c, _ := NewClient(log.New(&clog, "", 0), staticTS(), + CustomHTTPClientOption(srv.Client()), CustomBaseURLOption(srv.URL)) + + confs, err := c.ListConferences(context.Background(), time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("ListConferences should not fail when a space is unresolvable: %v", err) + } + if len(confs) != 0 { + t.Errorf("expected the unresolvable conference to be skipped, got %d", len(confs)) + } +} + +// TestListConferencesSkipsArtifactsNotInDrive verifies artifacts whose Drive +// destination is empty (still processing) are skipped. +func TestListConferencesSkipsArtifactsNotInDrive(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v2/conferenceRecords": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "conferenceRecords": []map[string]interface{}{ + {"name": "conferenceRecords/c1", "startTime": "2026-05-20T13:00:00Z", "space": "spaces/s1"}, + }, + }) + case "/v2/spaces/s1": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"meetingCode": "aaa-bbbb-ccc"}) + case "/v2/conferenceRecords/c1/recordings": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "recordings": []map[string]interface{}{ + {"name": "ready", "driveDestination": map[string]string{"file": "rec1"}}, + {"name": "processing", "driveDestination": map[string]string{"file": ""}}, + }, + }) + case "/v2/conferenceRecords/c1/transcripts": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "transcripts": []map[string]interface{}{ + {"name": "tprocessing", "docsDestination": map[string]string{"document": ""}}, + }, + }) + default: + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + } + })) + defer srv.Close() + + var clog bytes.Buffer + c, _ := NewClient(log.New(&clog, "", 0), staticTS(), + CustomHTTPClientOption(srv.Client()), CustomBaseURLOption(srv.URL)) + + confs, err := c.ListConferences(context.Background(), time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("ListConferences error: %v", err) + } + if len(confs) != 1 { + t.Fatalf("expected 1 conference, got %d", len(confs)) + } + if got := len(confs[0].Artifacts); got != 1 { + t.Fatalf("expected only the ready recording, got %d artifacts", got) + } + if confs[0].Artifacts[0].DriveFileID != "rec1" || confs[0].Artifacts[0].Kind != "recording" { + t.Errorf("unexpected artifact %+v", confs[0].Artifacts[0]) + } +} diff --git a/meet/mock/mock.go b/meet/mock/mock.go new file mode 100644 index 0000000..97335ea --- /dev/null +++ b/meet/mock/mock.go @@ -0,0 +1,67 @@ +package mock + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +// ApiHandler mocks the Google Meet REST API v2 endpoints used by ListConferences. +func ApiHandler(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + t.Log("mock meet handler handling", r.URL.String()) + p := r.URL.Path + w.Header().Set("Content-Type", "application/json") + + switch { + case p == "/v2/conferenceRecords": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "conferenceRecords": []map[string]interface{}{ + { + "name": "conferenceRecords/abc", + "startTime": "2026-05-20T13:00:00Z", + "endTime": "2026-05-20T13:45:00Z", + "space": "spaces/space123", + }, + }, + }) + case p == "/v2/spaces/space123": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "name": "spaces/space123", + "meetingUri": "https://meet.google.com/abc-defg-hij", + "meetingCode": "abc-defg-hij", + }) + case p == "/v2/conferenceRecords/abc/recordings": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "recordings": []map[string]interface{}{ + { + "name": "conferenceRecords/abc/recordings/r1", + "state": "FILE_GENERATED", + "startTime": "2026-05-20T13:00:00Z", + "endTime": "2026-05-20T13:45:00Z", + "driveDestination": map[string]string{"file": "driveFileRec1"}, + }, + }, + }) + case p == "/v2/conferenceRecords/abc/transcripts": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "transcripts": []map[string]interface{}{ + { + "name": "conferenceRecords/abc/transcripts/t1", + "state": "FILE_GENERATED", + "startTime": "2026-05-20T13:00:00Z", + "endTime": "2026-05-20T13:45:00Z", + "docsDestination": map[string]string{"document": "docFileTr1"}, + }, + }, + }) + default: + if strings.HasPrefix(p, "/v2/") { + _ = json.NewEncoder(w).Encode(map[string]interface{}{}) + return + } + http.NotFound(w, r) + } + } +}