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