app
Adom Google
Public Made by Adomby adom
Drive your whole Google Workspace from the terminal, run by your AI. Read Gmail, write Google Docs, build Google Sheets and Google Slides, search Google Drive, manage Google Calendar and Google Tasks, and post to Google Chat, plus any Google API and a zero-setup personal Gmail.
← Commit history
v0.8.27: youtube upload (resumable) + honest privacy reporting
2 files changed
+286−8
bin/adom-google+282−5@@ -23,7 +23,7 @@ See `adom-google help`. """ import sys, os, json, base64, argparse, urllib.parse, ssl, socket, hashlib, secrets, struct, time, subprocess, shutil -ADOM_GOOGLE_VERSION = "0.8.26"+ADOM_GOOGLE_VERSION = "0.8.27" if sys.argv[1:2] == ["--version"]: print(f"adom-google {ADOM_GOOGLE_VERSION}") sys.exit(0)@@ -188,10 +188,14 @@ ONBOARD_APIS = [ ("calendar-json.googleapis.com", "Google Calendar API"), ("tasks.googleapis.com", "Google Tasks API"), ("chat.googleapis.com", "Google Chat API"),+ # YouTube is enabled up front even though youtube scopes are NOT in the default set: enabling an+ # API costs nothing, and the alternative is what bit us (issue #11) — the first upload fails with+ # SERVICE_DISABLED long after onboarding, when nobody remembers where the project lives.+ ("youtube.googleapis.com", "YouTube Data API v3 (only needed for `youtube upload`)"), ] def _enable_apis_url(project=None):- # One URL that enables ALL nine APIs in one click (the GCP "enable multiple APIs" flow).+ # One URL that enables ALL of them in one click (the GCP "enable multiple APIs" flow). u = "https://console.cloud.google.com/flows/enableapi?apiid=" + ",".join(a for a, _ in ONBOARD_APIS) return u + ("&project=" + project if project else "") @@ -233,7 +237,7 @@ Hint: fallback) and AUTO-FILLS the Cloud Console fields. Get the exact values to Hint: adom-google onboard --json # redirect_uri, the 9 APIs, scopes, consent settings Hint: Steps the skill performs (screenshotting + pausing for the user's approval clicks): Hint: 1. Create/select a Google Cloud project.-Hint: 2. Enable all nine APIs in one click (the `enable_apis_url`).+Hint: 2. Enable all the APIs in one click (the `enable_apis_url`; includes YouTube Data API v3). Hint: 3. Configure the OAuth consent screen (User type = Internal; app name from config). Hint: 4. Create an OAuth client (type = Web application); AUTO-FILL Authorized redirect URI Hint: with the gateway `redirect_uri` (the error-prone field — never make the user type it).@@ -256,6 +260,12 @@ SHEETS_API = "https://sheets.googleapis.com/v4" DOCS_API = "https://docs.googleapis.com/v1" SLIDES_API = "https://slides.googleapis.com/v1" TASKS_API = "https://tasks.googleapis.com/tasks/v1"+YT_API = "https://www.googleapis.com/youtube/v3"+YT_UPLOAD = "https://www.googleapis.com/upload/youtube/v3/videos"+# YouTube is deliberately NOT in safe/full/personal: most users never need it, and youtube.upload is+# a publishing capability. Opt in with `scopes --require youtube.upload youtube --auto-widen`.+SCOPES_YOUTUBE = ["https://www.googleapis.com/auth/youtube.upload",+ "https://www.googleapis.com/auth/youtube"] # --- OAuth PROVIDER (gateway URL + client) — deliberately NOT hardcoded ----------------------- # This open-source CLI ships with ZERO provider baked in. The provider (which OAuth gateway to@@ -523,6 +533,17 @@ def _request(cfg, method, url, **kw): return r.json() if r.content else {} +def _request_raw(cfg, method, url, **kw):+ """Like _request but returns the RESPONSE and never exits.++ Needed wherever the caller must read status/headers itself or handle a non-2xx on purpose: the+ YouTube resumable upload lives on 308 + the Range/Location headers, which _request throws away."""+ headers = kw.pop("headers", {})+ headers["Authorization"] = "Bearer " + _access_token(cfg)+ kw.setdefault("timeout", 120)+ return requests.request(method, url, headers=headers, **kw)++ def _gmail(cfg, path): return _request(cfg, "GET", f"{GMAIL_API}/{path}") @@ -926,6 +947,7 @@ _EXCLUDED_HOSTS = { } # Best-effort host → the scope tail most likely missing, for the actionable hint. _HOST_SCOPE = {+ "www.googleapis.com/youtube": "youtube.upload youtube", "gmail.googleapis.com": "gmail.modify", "chat.googleapis.com": "chat.messages", "sheets.googleapis.com": "spreadsheets", "docs.googleapis.com": "documents", "slides.googleapis.com": "presentations", "www.googleapis.com": "drive",@@ -968,6 +990,23 @@ def _scope_wall_hint(url, resp): "that project must enable the API once:\n") if proj: e.write(f" https://console.developers.google.com/apis/api/{host}/overview?project={proj}\n")+ # WHOSE project is it? The fix lands on a different person depending on the answer, and+ # guessing wrong sends someone to a console they cannot open (issue #11).+ pr = _provider()+ src = ""+ try:+ src = json.load(open(PROVIDER_PATH)).get("_source", "") if os.path.exists(PROVIDER_PATH) else ""+ except Exception:+ pass+ if pr.get("app") == DEFAULT_APP and not src.startswith("org-settings:"):+ e.write("This is ADOM'S SHARED OAuth client, so an Adom admin enables it ONCE and every\n"+ "adom-google user is fixed. Ask in #kel rather than making your own client.\n")+ elif src.startswith("org-settings:"):+ org = src.split(":", 1)[1]+ e.write(f"This is {org}'s OWN OAuth client (from {org}/org-settings), so {org}'s Google Cloud\n"+ f"admin enables it once and every {org} member is fixed.\n")+ else:+ e.write("This is your own OAuth client, so whoever owns that Cloud project enables it.\n") e.write("Then wait a minute for it to propagate and retry.\n") e.write("────────────────────────────────────────────────────────────\n") return@@ -1668,7 +1707,8 @@ def cmd_onboard(a): R = cfg["redirect_uri"] print("┌─ adom-google · onboard your org to Google Workspace ─────────────────────────┐") print("│ One-time setup: your team gets its OWN Google OAuth client so the AI can │")- print("│ read mail, post Chat on your behalf, build Slides/Sheets/Docs, search Drive. │")+ print("│ read mail, post Chat on your behalf, build Slides/Sheets/Docs, search Drive, │")+ print("│ and upload to YouTube. │") print("│ Done ONCE by one person: Adom's gateway verifies you are in the org, checks │") print("│ the client with Google and holds the secret, so every member just signs in. │") print("└──────────────────────────────────────────────────────────────────────────────┘\n")@@ -1676,7 +1716,7 @@ def cmd_onboard(a): print("fields (recommended) — or you can do it by hand. Either way, the exact values:\n") print("1) Create / pick a Google Cloud project:") print(" " + cfg["console"]["project_create"])- print("\n2) Enable all nine APIs in ONE click:")+ print("\n2) Enable all the APIs in ONE click:") print(" " + cfg["enable_apis_url"]) for ap in cfg["apis"]: print(f" • {ap['label']}")@@ -2056,6 +2096,11 @@ AUTH (one time; one consent → long-lived refresh token, stored 0600): adom-google auth-code '<code>' finish the --manual flow adom-google init <id> <secret> set an explicit desktop OAuth client (for --manual) adom-google auth --json tooling: guidance on stderr, ONE JSON line on stdout when authorized+ adom-google youtube upload <file> [--privacy unlisted|private|public] [--playlist id]+ resumable upload; prints the watch URL + the privacy YouTube really+ applied. Needs an opt-in widen:+ adom-google scopes --require youtube.upload youtube --auto-widen+ adom-google youtube channels | list | delete <id> --yes adom-google status [--json] show access + what was REQUESTED at auth (never prints secrets). Exit 1 when NOT authorized; --json gives authorized:true|false. adom-google scopes [--json] authoritative GRANTED scopes (live tokeninfo) + drift vs requested@@ -2259,6 +2304,224 @@ def cmd_tasks_add(a): sys.stderr.write(f"→ added task '{a.title}'\n") ++# ---------------------------------------------------------------- youtube+def _yt_privacy_warn(requested, actual, vid):+ """Say plainly when YouTube overrode the requested privacy.++ An API project that has not passed YouTube's API compliance audit has videos.insert LOCKED TO+ PRIVATE: the request succeeds, the response says private, and nothing explains why. Silently+ returning "uploaded" there is how a video meant to be shared sits invisible (issue #11)."""+ if actual == requested:+ return+ sys.stderr.write(+ f"\n⚠ YouTube applied privacyStatus={actual}, NOT the {requested} you asked for.\n"+ " Almost always this project has not passed YouTube's API compliance audit: uploads from an\n"+ " unaudited project are forced to private, whatever you send. The upload itself is fine.\n"+ " Fix it either way:\n"+ f" • flip it by hand now: https://studio.youtube.com/video/{vid}/edit\n"+ " • or submit the project for the YouTube API audit (one time, then the API honours it):\n"+ " https://support.google.com/youtube/contact/yt_api_form\n")+++def cmd_youtube_upload(a):+ path = os.path.expanduser(a.file)+ if not os.path.isfile(path):+ sys.exit(f"ERROR: no such file: {path}")+ size = os.path.getsize(path)+ if size == 0:+ sys.exit(f"ERROR: {path} is empty")+ cfg = _load()+ title = a.title or os.path.splitext(os.path.basename(path))[0]+ body = {"snippet": {"title": title, "description": a.description or "",+ "categoryId": a.category or "22"},+ "status": {"privacyStatus": a.privacy, "selfDeclaredMadeForKids": False}}+ if a.tags:+ body["snippet"]["tags"] = [t.strip() for t in a.tags.split(",") if t.strip()]+ mime = "video/mp4"+ ext = os.path.splitext(path)[1].lower()+ if ext in (".mov", ".qt"): mime = "video/quicktime"+ elif ext == ".webm": mime = "video/webm"+ elif ext in (".mkv",): mime = "video/x-matroska"+ elif ext in (".avi",): mime = "video/x-msvideo"++ # 1. Start a RESUMABLE session. The video bytes cannot ride the JSON passthrough: YouTube wants+ # a resumable (or multipart) upload, and resumable is the only sane option for large files+ # because a dropped connection can be continued instead of restarted.+ r = _request_raw(cfg, "POST", YT_UPLOAD, params=[("uploadType", "resumable"), ("part", "snippet,status")],+ json=body, headers={"X-Upload-Content-Length": str(size),+ "X-Upload-Content-Type": mime})+ if r.status_code not in (200, 201):+ _scope_wall_hint(YT_UPLOAD, r)+ sys.exit(f"ERROR: could not start the upload: HTTP {r.status_code} {r.text[:400]}")+ session = r.headers.get("Location")+ if not session:+ sys.exit("ERROR: YouTube did not return a resumable session URL.")++ # 2. Send the bytes in chunks, resuming on a dropped connection rather than starting over.+ chunk = max(256 * 1024, (a.chunk_mb or 8) * 1024 * 1024)+ chunk -= chunk % (256 * 1024) # YouTube requires a multiple of 256 KiB+ tok = _access_token(cfg)+ sent, tries, video = 0, 0, None+ t0 = time.time()+ with open(path, "rb") as f:+ while sent < size:+ f.seek(sent)+ data = f.read(chunk)+ end = sent + len(data) - 1+ try:+ pr = requests.put(session, data=data, timeout=600, headers={+ "Authorization": "Bearer " + tok, "Content-Type": mime,+ "Content-Length": str(len(data)),+ "Content-Range": f"bytes {sent}-{end}/{size}"})+ except requests.RequestException as e:+ tries += 1+ if tries > 5:+ sys.exit(f"ERROR: upload failed after {tries} attempts: {e}\n"+ f"Hint: rerun the same command; YouTube keeps the session briefly and it resumes.")+ time.sleep(min(30, 2 ** tries))+ sent = _yt_resume_offset(session, tok, size, sent)+ continue+ if pr.status_code in (200, 201):+ video = pr.json()+ sent = size+ break+ if pr.status_code == 308: # chunk accepted, keep going+ tries = 0+ rng = pr.headers.get("Range") # e.g. bytes=0-8388607+ sent = int(rng.split("-")[-1]) + 1 if rng else sent + len(data)+ elif pr.status_code in (500, 502, 503, 504):+ tries += 1+ if tries > 5:+ sys.exit(f"ERROR: YouTube kept failing ({pr.status_code}). Rerun to resume.")+ time.sleep(min(30, 2 ** tries))+ sent = _yt_resume_offset(session, tok, size, sent)+ else:+ _scope_wall_hint(YT_UPLOAD, pr)+ sys.exit(f"ERROR: upload rejected: HTTP {pr.status_code} {pr.text[:400]}")+ if not a.quiet:+ pct = 100.0 * sent / size+ sys.stderr.write(f"\r uploading {pct:5.1f}% ({sent // 1048576}/{size // 1048576} MiB)")+ sys.stderr.flush()+ if not a.quiet:+ sys.stderr.write(f"\r uploaded {size // 1048576} MiB in {int(time.time() - t0)}s\n")+ if not video:+ sys.exit("ERROR: upload finished but YouTube returned no video resource.")++ vid = video["id"]+ # 3. Read the privacy YouTube ACTUALLY applied, from a fresh GET rather than trusting the insert+ # response, and say so.+ actual = (video.get("status") or {}).get("privacyStatus")+ g = _request_raw(cfg, "GET", f"{YT_API}/videos", params=[("part", "status,snippet,processingDetails"), ("id", vid)])+ if g.status_code == 200 and (g.json().get("items") or []):+ it = g.json()["items"][0]+ actual = (it.get("status") or {}).get("privacyStatus", actual)+ watch = f"https://www.youtube.com/watch?v={vid}"+ if a.thumbnail:+ tp = os.path.expanduser(a.thumbnail)+ if os.path.isfile(tp):+ with open(tp, "rb") as tf:+ tr = _request_raw(cfg, "POST", "https://www.googleapis.com/upload/youtube/v3/thumbnails/set",+ params=[("videoId", vid)], data=tf.read(),+ headers={"Content-Type": "image/png" if tp.lower().endswith(".png") else "image/jpeg"})+ print(f" thumbnail: {'set' if tr.status_code in (200, 201) else 'FAILED ' + str(tr.status_code)}")+ else:+ print(f" thumbnail: SKIPPED (no such file: {tp})", file=sys.stderr)+ if a.playlist:+ pr2 = _request_raw(cfg, "POST", f"{YT_API}/playlistItems", params=[("part", "snippet")],+ json={"snippet": {"playlistId": a.playlist,+ "resourceId": {"kind": "youtube#video", "videoId": vid}}})+ print(f" playlist: {'added' if pr2.status_code in (200, 201) else 'FAILED ' + str(pr2.status_code) + ' ' + pr2.text[:120]}")+ if a.json:+ print(json.dumps({"id": vid, "watch_url": watch, "title": title,+ "privacy_requested": a.privacy, "privacy_applied": actual,+ "privacy_honored": actual == a.privacy, "bytes": size,+ "studio_url": f"https://studio.youtube.com/video/{vid}/edit"}))+ else:+ print(f"✅ uploaded: {title}")+ print(f" video id: {vid}")+ print(f" watch: {watch}")+ print(f" privacy: {actual}" + ("" if actual == a.privacy else f" (you asked for {a.privacy})"))+ print(f" studio: https://studio.youtube.com/video/{vid}/edit")+ _yt_privacy_warn(a.privacy, actual, vid)+++def _yt_resume_offset(session, tok, size, fallback):+ """Ask YouTube how much it already has, so a retry continues instead of re-sending."""+ try:+ q = requests.put(session, timeout=60, headers={+ "Authorization": "Bearer " + tok, "Content-Length": "0",+ "Content-Range": f"bytes */{size}"})+ if q.status_code == 308:+ rng = q.headers.get("Range")+ return int(rng.split("-")[-1]) + 1 if rng else 0+ if q.status_code in (200, 201):+ return size+ except requests.RequestException:+ pass+ return fallback+++def cmd_youtube_channels(a):+ cfg = _load()+ r = _request_raw(cfg, "GET", f"{YT_API}/channels", params=[("part", "snippet,contentDetails,statistics"), ("mine", "true")])+ if r.status_code != 200:+ _scope_wall_hint(f"{YT_API}/channels", r)+ sys.exit(f"ERROR: HTTP {r.status_code} {r.text[:300]}")+ items = r.json().get("items") or []+ if a.json:+ print(json.dumps(items)); return+ if not items:+ print("No YouTube channel on this Google account."); return+ for it in items:+ up = (it.get("contentDetails") or {}).get("relatedPlaylists", {}).get("uploads")+ st = it.get("statistics") or {}+ print(f"{it['snippet']['title']} · {it['id']}")+ print(f" uploads playlist: {up} videos: {st.get('videoCount', '?')} subscribers: {st.get('subscriberCount', '?')}")+++def cmd_youtube_list(a):+ cfg = _load()+ r = _request_raw(cfg, "GET", f"{YT_API}/search", params=[("part", "snippet"), ("forMine", "true"),+ ("type", "video"), ("order", "date"),+ ("maxResults", str(a.max))])+ if r.status_code != 200:+ _scope_wall_hint(f"{YT_API}/search", r)+ sys.exit(f"ERROR: HTTP {r.status_code} {r.text[:300]}")+ items = r.json().get("items") or []+ ids = [i["id"]["videoId"] for i in items]+ priv = {}+ if ids:+ g = _request_raw(cfg, "GET", f"{YT_API}/videos", params=[("part", "status"), ("id", ",".join(ids))])+ if g.status_code == 200:+ priv = {i["id"]: (i.get("status") or {}).get("privacyStatus") for i in g.json().get("items", [])}+ if a.json:+ print(json.dumps([{"id": i["id"]["videoId"], "title": i["snippet"]["title"],+ "published": i["snippet"]["publishedAt"],+ "privacy": priv.get(i["id"]["videoId"])} for i in items])); return+ for i in items:+ v = i["id"]["videoId"]+ print(f"{i['snippet']['publishedAt'][:10]} {priv.get(v, '?'):8} {v} {i['snippet']['title'][:60]}")+++def cmd_youtube_delete(a):+ """Delete ONE video by id. Deliberately single-id only: no query, no bulk."""+ cfg = _load()+ g = _request_raw(cfg, "GET", f"{YT_API}/videos", params=[("part", "snippet"), ("id", a.video_id)])+ title = "(unknown)"+ if g.status_code == 200 and (g.json().get("items") or []):+ title = g.json()["items"][0]["snippet"]["title"]+ elif g.status_code == 200:+ sys.exit(f"ERROR: no video with id {a.video_id} on this account.")+ if not a.yes:+ sys.exit(f"Refusing to delete {a.video_id} ({title}) without --yes.")+ r = _request_raw(cfg, "DELETE", f"{YT_API}/videos", params=[("id", a.video_id)])+ if r.status_code not in (200, 204):+ _scope_wall_hint(f"{YT_API}/videos", r)+ sys.exit(f"ERROR: HTTP {r.status_code} {r.text[:300]}")+ print(f"Deleted {a.video_id} ({title}).")++ def main(): p = argparse.ArgumentParser(add_help=False) sub = p.add_subparsers(dest="cmd")@@ -2299,6 +2562,20 @@ def main(): y.add_argument("--relay", action="store_true", help="old single-box mode: keep the secret 0600 on THIS box only (teammates cannot use it)") y.set_defaults(fn=cmd_onboard_finish) + yt = sub.add_parser("youtube", help="YouTube: upload a video (resumable), list, channels")+ yts = yt.add_subparsers(dest="sub")+ u = yts.add_parser("upload", help="resumable upload; prints the watch URL and the privacy YouTube actually applied")+ u.add_argument("file"); u.add_argument("--title"); u.add_argument("--description", default="")+ u.add_argument("--privacy", choices=["private", "unlisted", "public"], default="unlisted")+ u.add_argument("--tags", help="comma separated"); u.add_argument("--category", help="YouTube categoryId (default 22, People & Blogs)")+ u.add_argument("--playlist", metavar="PLAYLIST_ID"); u.add_argument("--thumbnail", metavar="PATH")+ u.add_argument("--chunk-mb", dest="chunk_mb", type=int, default=8)+ u.add_argument("--quiet", action="store_true", help="no progress line"); u.add_argument("--json", action="store_true")+ u.set_defaults(fn=cmd_youtube_upload)+ u = yts.add_parser("channels"); u.add_argument("--json", action="store_true"); u.set_defaults(fn=cmd_youtube_channels)+ u = yts.add_parser("list"); u.add_argument("--max", type=int, default=10); u.add_argument("--json", action="store_true"); u.set_defaults(fn=cmd_youtube_list)+ u = yts.add_parser("delete"); u.add_argument("video_id"); u.add_argument("--yes", action="store_true"); u.set_defaults(fn=cmd_youtube_delete)+ og = sub.add_parser("org", help="set adom-google up ONCE for a whole wiki org (gateway holds the secret; members auto-discover)") ogs = og.add_subparsers(dest="sub") z = ogs.add_parser("register", help="hand your org's Google OAuth client to the gateway + publish the non-secret half to <org>/org-settings")
page.json+4−3@@ -94,7 +94,7 @@ "skills": [], "hero_path": "screenshots/hero-google-v5.png", "hero_type": "image",- "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Connect a personal @gmail (or another org's Google account) with ZERO setup via Adom's hosted Connector (`adom-google connect-personal`), or bring your own token-broker gateway (`provider set`): Adom staff use the private adom-google-adom provider. One-click OAuth, no client secret on your machine.",+ "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Open source, ships with NO OAuth provider baked in: point it at your own token-broker gateway (`provider set`), buy a managed one, or: Adom staff: install the private adom-google-adom provider. One-click OAuth, no client secret on your machine. First run asks safe mode (no delete) vs full mode (incl. delete).", "keywords": [ "adom-google", "google-workspace",@@ -118,6 +118,8 @@ "uninstall": "./uninstall.sh" }, "files": [+ "bin/adom-google",+ "bin/adom-gmail", "SKILL.md", "skills/adom-google-onboarding/SKILL.md", "README.md",@@ -126,8 +128,7 @@ "provider.example.json", "install.sh", "uninstall.sh",- "package.json",- "VERSION"+ "package.json" ], "org": "adom" }