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) + } + } +}