scripts/google_api.py
scripts/google_api.pyBrowse 7 files
11,122 tokens
47,061 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Google Workspace API CLI for Hermes Agent.3 4Uses the Google Workspace CLI (`gws`) when available, but preserves the5existing Hermes-facing JSON contract and falls back to the Python client6libraries if `gws` is not installed.7 8Usage:9 python google_api.py gmail search "is:unread" [--max 10]10 python google_api.py gmail get MESSAGE_ID11 python google_api.py gmail send --to user@example.com --subject "Hi" --body "Hello"12 python google_api.py gmail reply MESSAGE_ID --body "Thanks"13 python google_api.py calendar list [--from DATE] [--to DATE] [--calendar primary]14 python google_api.py calendar create --summary "Meeting" --start DATETIME --end DATETIME15 python google_api.py drive search "budget report" [--max 10]16 python google_api.py contacts list [--max 20]17 python google_api.py sheets get SHEET_ID RANGE18 python google_api.py sheets update SHEET_ID RANGE --values '[[...]]'19 python google_api.py sheets append SHEET_ID RANGE --values '[[...]]'20 python google_api.py docs get DOC_ID21"""22 23import argparse24import base6425import json26import os27import shutil28import subprocess29import sys30from datetime import datetime, timedelta, timezone31from email.mime.text import MIMEText32from pathlib import Path33 34# Ensure sibling modules (_hermes_home) are importable when run standalone.35_SCRIPTS_DIR = str(Path(__file__).resolve().parent)36if _SCRIPTS_DIR not in sys.path:37 sys.path.insert(0, _SCRIPTS_DIR)38 39from _hermes_home import get_hermes_home40 41HERMES_HOME = get_hermes_home()42TOKEN_PATH = HERMES_HOME / "google_token.json"43CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json"44 45SCOPES = [46 "https://www.googleapis.com/auth/gmail.readonly",47 "https://www.googleapis.com/auth/gmail.send",48 "https://www.googleapis.com/auth/gmail.modify",49 "https://www.googleapis.com/auth/calendar",50 "https://www.googleapis.com/auth/drive",51 "https://www.googleapis.com/auth/contacts.readonly",52 "https://www.googleapis.com/auth/spreadsheets",53 "https://www.googleapis.com/auth/documents",54]55 56 57def _normalize_authorized_user_payload(payload: dict) -> dict:58 normalized = dict(payload)59 if not normalized.get("type"):60 normalized["type"] = "authorized_user"61 return normalized62 63 64def _ensure_authenticated():65 if not TOKEN_PATH.exists():66 print("Not authenticated. Run the setup script first:", file=sys.stderr)67 print(f" python {Path(__file__).parent / 'setup.py'}", file=sys.stderr)68 sys.exit(1)69 70 71def _stored_token_scopes() -> list[str]:72 try:73 data = json.loads(TOKEN_PATH.read_text(encoding="utf-8"))74 except Exception:75 return list(SCOPES)76 scopes = data.get("scopes")77 if isinstance(scopes, list) and scopes:78 return scopes79 return list(SCOPES)80 81 82def _gws_binary() -> str | None:83 override = os.getenv("HERMES_GWS_BIN")84 if override:85 return override86 return shutil.which("gws")87 88 89def _gws_env() -> dict[str, str]:90 env = os.environ.copy()91 env["GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"] = str(TOKEN_PATH)92 return env93 94 95def _run_gws(parts: list[str], *, params: dict | None = None, body: dict | None = None):96 binary = _gws_binary()97 if not binary:98 raise RuntimeError("gws not installed")99 100 _ensure_authenticated()101 102 cmd = [binary, *parts]103 if params is not None:104 cmd.extend(["--params", json.dumps(params)])105 if body is not None:106 cmd.extend(["--json", json.dumps(body)])107 108 result = subprocess.run(109 cmd,110 capture_output=True,111 text=True, encoding='utf-8', errors='replace',112 env=_gws_env(),113 )114 if result.returncode != 0:115 err = result.stderr.strip() or result.stdout.strip() or "Unknown gws error"116 print(err, file=sys.stderr)117 sys.exit(result.returncode or 1)118 119 stdout = result.stdout.strip()120 if not stdout:121 return {}122 123 try:124 return json.loads(stdout)125 except json.JSONDecodeError:126 print("ERROR: Unexpected non-JSON output from gws:", file=sys.stderr)127 print(stdout, file=sys.stderr)128 sys.exit(1)129 130 131def _headers_dict(msg: dict) -> dict[str, str]:132 return {133 h["name"].lower(): h["value"]134 for h in msg.get("payload", {}).get("headers", [])135 if h.get("name")136 }137 138 139def _extract_message_body(msg: dict) -> str:140 body = ""141 payload = msg.get("payload", {})142 if payload.get("body", {}).get("data"):143 body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="replace")144 elif payload.get("parts"):145 for part in payload["parts"]:146 if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"):147 body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace")148 break149 if not body:150 for part in payload["parts"]:151 if part.get("mimeType") == "text/html" and part.get("body", {}).get("data"):152 body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace")153 break154 return body155 156 157def _extract_body_text(body: dict) -> str:158 text_parts = []159 for element in body.get("content", []):160 paragraph = element.get("paragraph", {})161 for pe in paragraph.get("elements", []):162 text_run = pe.get("textRun", {})163 if text_run.get("content"):164 text_parts.append(text_run["content"])165 return "".join(text_parts)166 167 168def _extract_doc_text(doc: dict) -> str:169 return _extract_body_text(doc.get("body", {}))170 171 172def _flatten_doc_tabs(doc: dict) -> list[dict]:173 """Flatten Google's recursive ``tabs``/``childTabs`` tree (preorder).174 175 A tabbed Doc keeps each tab's content in its own body with an independent176 index space; the legacy top-level ``body`` only carries the first tab, so177 reads and writes that ignore ``tabs`` silently drop or mistarget content.178 Returns [] for the legacy single-body response shape (no ``tabs`` field).179 """180 flat: list[dict] = []181 182 def visit(tabs, level):183 for tab in tabs or []:184 props = tab.get("tabProperties") or {}185 doc_tab = tab.get("documentTab") or {}186 flat.append({187 "tabId": props.get("tabId", ""),188 "title": props.get("title", ""),189 "level": level,190 "body": doc_tab.get("body") or {},191 })192 visit(tab.get("childTabs"), level + 1)193 194 visit(doc.get("tabs"), 0)195 return flat196 197 198def _resolve_write_tab(doc: dict, tab_arg: str | None) -> tuple[str | None, dict]:199 """Pick exactly one tab body for a write; never merge index spaces.200 201 Legacy docs (no ``tabs``) return (None, body) — the write carries no tabId.202 A multi-tab doc requires an explicit --tab; an unknown ID errors instead of203 quietly falling back to the first tab.204 """205 tabs = _flatten_doc_tabs(doc)206 if not tabs:207 return None, doc.get("body", {})208 if tab_arg:209 for tab in tabs:210 if tab["tabId"] == tab_arg:211 return tab["tabId"], tab["body"]212 print(213 json.dumps({214 "error": f"unknown tab ID {tab_arg!r}",215 "tabs": [{"tabId": t["tabId"], "title": t["title"]} for t in tabs],216 }, indent=2, ensure_ascii=False),217 file=sys.stderr,218 )219 sys.exit(1)220 if len(tabs) == 1:221 return tabs[0]["tabId"], tabs[0]["body"]222 print(223 json.dumps({224 "error": f"document has {len(tabs)} tabs; pass --tab <tabId> to pick one",225 "tabs": [{"tabId": t["tabId"], "title": t["title"]} for t in tabs],226 }, indent=2, ensure_ascii=False),227 file=sys.stderr,228 )229 sys.exit(1)230 231 232def _datetime_with_timezone(value: str) -> str:233 if not value:234 return value235 if "T" not in value:236 return value237 if value.endswith("Z"):238 return value239 tail = value[10:]240 if "+" in tail or "-" in tail:241 return value242 return value + "Z"243 244 245def get_credentials():246 """Load and refresh credentials from token file."""247 _ensure_authenticated()248 249 from google.oauth2.credentials import Credentials250 from google.auth.transport.requests import Request251 252 creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), _stored_token_scopes())253 if creds.expired and creds.refresh_token:254 creds.refresh(Request())255 TOKEN_PATH.write_text(256 json.dumps(257 _normalize_authorized_user_payload(json.loads(creds.to_json())),258 indent=2,259 ), encoding="utf-8"260 )261 if not creds.valid:262 print("Token is invalid. Re-run setup.", file=sys.stderr)263 sys.exit(1)264 return creds265 266 267def build_service(api, version):268 from googleapiclient.discovery import build269 270 return build(api, version, credentials=get_credentials())271 272 273# =========================================================================274# Gmail275# =========================================================================276 277 278def gmail_search(args):279 if _gws_binary():280 results = _run_gws(281 ["gmail", "users", "messages", "list"],282 params={"userId": "me", "q": args.query, "maxResults": args.max},283 )284 messages = results.get("messages", [])285 output = []286 for msg_meta in messages:287 msg = _run_gws(288 ["gmail", "users", "messages", "get"],289 params={290 "userId": "me",291 "id": msg_meta["id"],292 "format": "metadata",293 "metadataHeaders": ["From", "To", "Subject", "Date"],294 },295 )296 headers = _headers_dict(msg)297 output.append(298 {299 "id": msg["id"],300 "threadId": msg["threadId"],301 "from": headers.get("from", ""),302 "to": headers.get("to", ""),303 "subject": headers.get("subject", ""),304 "date": headers.get("date", ""),305 "snippet": msg.get("snippet", ""),306 "labels": msg.get("labelIds", []),307 }308 )309 print(json.dumps(output, indent=2, ensure_ascii=False))310 return311 312 service = build_service("gmail", "v1")313 results = service.users().messages().list(314 userId="me", q=args.query, maxResults=args.max315 ).execute()316 messages = results.get("messages", [])317 if not messages:318 print("No messages found.")319 return320 321 output = []322 for msg_meta in messages:323 msg = service.users().messages().get(324 userId="me", id=msg_meta["id"], format="metadata",325 metadataHeaders=["From", "To", "Subject", "Date"],326 ).execute()327 headers = _headers_dict(msg)328 output.append({329 "id": msg["id"],330 "threadId": msg["threadId"],331 "from": headers.get("from", ""),332 "to": headers.get("to", ""),333 "subject": headers.get("subject", ""),334 "date": headers.get("date", ""),335 "snippet": msg.get("snippet", ""),336 "labels": msg.get("labelIds", []),337 })338 print(json.dumps(output, indent=2, ensure_ascii=False))339 340 341 342def gmail_get(args):343 if _gws_binary():344 msg = _run_gws(345 ["gmail", "users", "messages", "get"],346 params={"userId": "me", "id": args.message_id, "format": "full"},347 )348 headers = _headers_dict(msg)349 result = {350 "id": msg["id"],351 "threadId": msg["threadId"],352 "from": headers.get("from", ""),353 "to": headers.get("to", ""),354 "subject": headers.get("subject", ""),355 "date": headers.get("date", ""),356 "labels": msg.get("labelIds", []),357 "body": _extract_message_body(msg),358 }359 print(json.dumps(result, indent=2, ensure_ascii=False))360 return361 362 service = build_service("gmail", "v1")363 msg = service.users().messages().get(364 userId="me", id=args.message_id, format="full"365 ).execute()366 367 headers = _headers_dict(msg)368 result = {369 "id": msg["id"],370 "threadId": msg["threadId"],371 "from": headers.get("from", ""),372 "to": headers.get("to", ""),373 "subject": headers.get("subject", ""),374 "date": headers.get("date", ""),375 "labels": msg.get("labelIds", []),376 "body": _extract_message_body(msg),377 }378 print(json.dumps(result, indent=2, ensure_ascii=False))379 380 381 382def gmail_send(args):383 if _gws_binary():384 message = MIMEText(args.body, "html" if args.html else "plain")385 message["To"] = args.to386 message["Subject"] = args.subject387 if args.cc:388 message["Cc"] = args.cc389 if args.from_header:390 message["From"] = args.from_header391 392 raw = base64.urlsafe_b64encode(message.as_bytes()).decode()393 body = {"raw": raw}394 if args.thread_id:395 body["threadId"] = args.thread_id396 397 result = _run_gws(398 ["gmail", "users", "messages", "send"],399 params={"userId": "me"},400 body=body,401 )402 print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2))403 return404 405 service = build_service("gmail", "v1")406 message = MIMEText(args.body, "html" if args.html else "plain")407 message["To"] = args.to408 message["Subject"] = args.subject409 if args.cc:410 message["Cc"] = args.cc411 if args.from_header:412 message["From"] = args.from_header413 414 raw = base64.urlsafe_b64encode(message.as_bytes()).decode()415 body = {"raw": raw}416 417 if args.thread_id:418 body["threadId"] = args.thread_id419 420 result = service.users().messages().send(userId="me", body=body).execute()421 print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2))422 423 424 425def gmail_reply(args):426 if _gws_binary():427 original = _run_gws(428 ["gmail", "users", "messages", "get"],429 params={430 "userId": "me",431 "id": args.message_id,432 "format": "metadata",433 "metadataHeaders": ["From", "Subject", "Message-ID"],434 },435 )436 headers = _headers_dict(original)437 438 subject = headers.get("subject", "")439 if not subject.startswith("Re:"):440 subject = f"Re: {subject}"441 442 message = MIMEText(args.body)443 message["To"] = headers.get("from", "")444 message["Subject"] = subject445 if args.from_header:446 message["From"] = args.from_header447 if headers.get("message-id"):448 message["In-Reply-To"] = headers["message-id"]449 message["References"] = headers["message-id"]450 451 raw = base64.urlsafe_b64encode(message.as_bytes()).decode()452 result = _run_gws(453 ["gmail", "users", "messages", "send"],454 params={"userId": "me"},455 body={"raw": raw, "threadId": original["threadId"]},456 )457 print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2))458 return459 460 service = build_service("gmail", "v1")461 original = service.users().messages().get(462 userId="me", id=args.message_id, format="metadata",463 metadataHeaders=["From", "Subject", "Message-ID"],464 ).execute()465 headers = _headers_dict(original)466 467 subject = headers.get("subject", "")468 if not subject.startswith("Re:"):469 subject = f"Re: {subject}"470 471 message = MIMEText(args.body)472 message["To"] = headers.get("from", "")473 message["Subject"] = subject474 if args.from_header:475 message["From"] = args.from_header476 if headers.get("message-id"):477 message["In-Reply-To"] = headers["message-id"]478 message["References"] = headers["message-id"]479 480 raw = base64.urlsafe_b64encode(message.as_bytes()).decode()481 body = {"raw": raw, "threadId": original["threadId"]}482 483 result = service.users().messages().send(userId="me", body=body).execute()484 print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2))485 486 487 488def gmail_labels(args):489 if _gws_binary():490 results = _run_gws(["gmail", "users", "labels", "list"], params={"userId": "me"})491 labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])]492 print(json.dumps(labels, indent=2))493 return494 495 service = build_service("gmail", "v1")496 results = service.users().labels().list(userId="me").execute()497 labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])]498 print(json.dumps(labels, indent=2))499 500 501 502def gmail_modify(args):503 body = {}504 if args.add_labels:505 body["addLabelIds"] = args.add_labels.split(",")506 if args.remove_labels:507 body["removeLabelIds"] = args.remove_labels.split(",")508 509 if _gws_binary():510 result = _run_gws(511 ["gmail", "users", "messages", "modify"],512 params={"userId": "me", "id": args.message_id},513 body=body,514 )515 print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2))516 return517 518 service = build_service("gmail", "v1")519 result = service.users().messages().modify(userId="me", id=args.message_id, body=body).execute()520 print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2))521 522 523# =========================================================================524# Calendar525# =========================================================================526 527 528def calendar_list(args):529 now = datetime.now(timezone.utc)530 time_min = _datetime_with_timezone(args.start or now.isoformat())531 time_max = _datetime_with_timezone(args.end or (now + timedelta(days=7)).isoformat())532 533 if _gws_binary():534 results = _run_gws(535 ["calendar", "events", "list"],536 params={537 "calendarId": args.calendar,538 "timeMin": time_min,539 "timeMax": time_max,540 "maxResults": args.max,541 "singleEvents": True,542 "orderBy": "startTime",543 },544 )545 events = []546 for e in results.get("items", []):547 events.append({548 "id": e["id"],549 "summary": e.get("summary", "(no title)"),550 "start": e.get("start", {}).get("dateTime", e.get("start", {}).get("date", "")),551 "end": e.get("end", {}).get("dateTime", e.get("end", {}).get("date", "")),552 "location": e.get("location", ""),553 "description": e.get("description", ""),554 "status": e.get("status", ""),555 "htmlLink": e.get("htmlLink", ""),556 })557 print(json.dumps(events, indent=2, ensure_ascii=False))558 return559 560 service = build_service("calendar", "v3")561 results = service.events().list(562 calendarId=args.calendar, timeMin=time_min, timeMax=time_max,563 maxResults=args.max, singleEvents=True, orderBy="startTime",564 ).execute()565 566 events = []567 for e in results.get("items", []):568 events.append({569 "id": e["id"],570 "summary": e.get("summary", "(no title)"),571 "start": e.get("start", {}).get("dateTime", e.get("start", {}).get("date", "")),572 "end": e.get("end", {}).get("dateTime", e.get("end", {}).get("date", "")),573 "location": e.get("location", ""),574 "description": e.get("description", ""),575 "status": e.get("status", ""),576 "htmlLink": e.get("htmlLink", ""),577 })578 print(json.dumps(events, indent=2, ensure_ascii=False))579 580 581 582def calendar_create(args):583 event = {584 "summary": args.summary,585 "start": {"dateTime": args.start},586 "end": {"dateTime": args.end},587 }588 if args.location:589 event["location"] = args.location590 if args.description:591 event["description"] = args.description592 if args.attendees:593 event["attendees"] = [{"email": e.strip()} for e in args.attendees.split(",") if e.strip()]594 595 if _gws_binary():596 result = _run_gws(597 ["calendar", "events", "insert"],598 params={"calendarId": args.calendar},599 body=event,600 )601 print(json.dumps({602 "status": "created",603 "id": result["id"],604 "summary": result.get("summary", ""),605 "htmlLink": result.get("htmlLink", ""),606 }, indent=2))607 return608 609 service = build_service("calendar", "v3")610 result = service.events().insert(calendarId=args.calendar, body=event).execute()611 print(json.dumps({612 "status": "created",613 "id": result["id"],614 "summary": result.get("summary", ""),615 "htmlLink": result.get("htmlLink", ""),616 }, indent=2))617 618 619 620def calendar_delete(args):621 if _gws_binary():622 _run_gws(["calendar", "events", "delete"], params={"calendarId": args.calendar, "eventId": args.event_id})623 print(json.dumps({"status": "deleted", "eventId": args.event_id}))624 return625 626 service = build_service("calendar", "v3")627 service.events().delete(calendarId=args.calendar, eventId=args.event_id).execute()628 print(json.dumps({"status": "deleted", "eventId": args.event_id}))629 630 631# =========================================================================632# Drive633# =========================================================================634 635 636def drive_search(args):637 query = args.query if args.raw_query else f"fullText contains '{args.query}'"638 if _gws_binary():639 results = _run_gws(640 ["drive", "files", "list"],641 params={642 "q": query,643 "pageSize": args.max,644 "fields": "files(id, name, mimeType, modifiedTime, webViewLink)",645 },646 )647 print(json.dumps(results.get("files", []), indent=2, ensure_ascii=False))648 return649 650 service = build_service("drive", "v3")651 results = service.files().list(652 q=query, pageSize=args.max, fields="files(id, name, mimeType, modifiedTime, webViewLink)",653 ).execute()654 files = results.get("files", [])655 print(json.dumps(files, indent=2, ensure_ascii=False))656 657 658def drive_get(args):659 """Get metadata for a single Drive file by ID."""660 fields = "id, name, mimeType, modifiedTime, size, webViewLink, parents, owners(emailAddress)"661 if _gws_binary():662 result = _run_gws(663 ["drive", "files", "get"],664 params={"fileId": args.file_id, "fields": fields},665 )666 print(json.dumps(result, indent=2, ensure_ascii=False))667 return668 669 service = build_service("drive", "v3")670 result = service.files().get(fileId=args.file_id, fields=fields).execute()671 print(json.dumps(result, indent=2, ensure_ascii=False))672 673 674def drive_upload(args):675 """Upload a local file to Drive. Falls through to Python client even when gws676 is installed, because gws doesn't do multipart uploads."""677 import mimetypes678 from googleapiclient.http import MediaFileUpload679 680 local_path = Path(args.path).expanduser()681 if not local_path.exists():682 print(f"ERROR: file not found: {local_path}", file=sys.stderr)683 sys.exit(1)684 685 mime = args.mime_type or mimetypes.guess_type(str(local_path))[0] or "application/octet-stream"686 metadata = {"name": args.name or local_path.name}687 if args.parent:688 metadata["parents"] = [args.parent]689 690 service = build_service("drive", "v3")691 media = MediaFileUpload(str(local_path), mimetype=mime, resumable=True)692 result = service.files().create(693 body=metadata,694 media_body=media,695 fields="id, name, mimeType, webViewLink",696 ).execute()697 print(json.dumps({698 "status": "uploaded",699 "id": result["id"],700 "name": result.get("name", ""),701 "mimeType": result.get("mimeType", ""),702 "webViewLink": result.get("webViewLink", ""),703 }, indent=2, ensure_ascii=False))704 705 706def drive_download(args):707 """Download a Drive file to a local path. Google-native files (Docs/Sheets/Slides)708 must be exported; binary files are downloaded as-is."""709 import io710 from googleapiclient.http import MediaIoBaseDownload711 712 service = build_service("drive", "v3")713 714 # Look up the file to decide download vs export.715 meta = service.files().get(fileId=args.file_id, fields="id, name, mimeType").execute()716 mime = meta.get("mimeType", "")717 name = meta.get("name", args.file_id)718 719 # Map Google-native MIME types to a sensible export default.720 native_export_map = {721 "application/vnd.google-apps.document": ("application/pdf", ".pdf"),722 "application/vnd.google-apps.spreadsheet": ("text/csv", ".csv"),723 "application/vnd.google-apps.presentation": ("application/pdf", ".pdf"),724 "application/vnd.google-apps.drawing": ("image/png", ".png"),725 }726 727 out_path = Path(args.output).expanduser() if args.output else Path.cwd() / name728 729 if mime in native_export_map:730 export_mime = args.export_mime or native_export_map[mime][0]731 default_ext = native_export_map[mime][1]732 if not args.output and not out_path.suffix:733 out_path = out_path.with_suffix(default_ext)734 request = service.files().export_media(fileId=args.file_id, mimeType=export_mime)735 else:736 request = service.files().get_media(fileId=args.file_id)737 738 out_path.parent.mkdir(parents=True, exist_ok=True)739 fh = io.FileIO(str(out_path), "wb")740 downloader = MediaIoBaseDownload(fh, request)741 done = False742 while not done:743 _, done = downloader.next_chunk()744 fh.close()745 746 print(json.dumps({747 "status": "downloaded",748 "id": args.file_id,749 "name": name,750 "path": str(out_path),751 "mimeType": mime,752 }, indent=2, ensure_ascii=False))753 754 755def drive_create_folder(args):756 body = {757 "name": args.name,758 "mimeType": "application/vnd.google-apps.folder",759 }760 if args.parent:761 body["parents"] = [args.parent]762 763 if _gws_binary():764 result = _run_gws(765 ["drive", "files", "create"],766 params={"fields": "id, name, webViewLink"},767 body=body,768 )769 print(json.dumps({770 "status": "created",771 "id": result["id"],772 "name": result.get("name", ""),773 "webViewLink": result.get("webViewLink", ""),774 }, indent=2, ensure_ascii=False))775 return776 777 service = build_service("drive", "v3")778 result = service.files().create(body=body, fields="id, name, webViewLink").execute()779 print(json.dumps({780 "status": "created",781 "id": result["id"],782 "name": result.get("name", ""),783 "webViewLink": result.get("webViewLink", ""),784 }, indent=2, ensure_ascii=False))785 786 787def drive_share(args):788 permission = {789 "type": args.type,790 "role": args.role,791 }792 if args.type in {"user", "group"}:793 if not args.email:794 print("ERROR: --email is required for type=user or type=group", file=sys.stderr)795 sys.exit(1)796 permission["emailAddress"] = args.email797 elif args.type == "domain":798 if not args.domain:799 print("ERROR: --domain is required for type=domain", file=sys.stderr)800 sys.exit(1)801 permission["domain"] = args.domain802 803 if _gws_binary():804 result = _run_gws(805 ["drive", "permissions", "create"],806 params={807 "fileId": args.file_id,808 "sendNotificationEmail": args.notify,809 },810 body=permission,811 )812 print(json.dumps({813 "status": "shared",814 "permissionId": result.get("id", ""),815 "fileId": args.file_id,816 "role": permission["role"],817 "type": permission["type"],818 }, indent=2, ensure_ascii=False))819 return820 821 service = build_service("drive", "v3")822 result = service.permissions().create(823 fileId=args.file_id,824 body=permission,825 sendNotificationEmail=args.notify,826 fields="id",827 ).execute()828 print(json.dumps({829 "status": "shared",830 "permissionId": result.get("id", ""),831 "fileId": args.file_id,832 "role": permission["role"],833 "type": permission["type"],834 }, indent=2, ensure_ascii=False))835 836 837def drive_delete(args):838 """Trash or permanently delete a Drive file. Defaults to trash (reversible)."""839 if args.permanent:840 if _gws_binary():841 _run_gws(["drive", "files", "delete"], params={"fileId": args.file_id})842 print(json.dumps({"status": "deleted", "fileId": args.file_id, "permanent": True}))843 return844 service = build_service("drive", "v3")845 service.files().delete(fileId=args.file_id).execute()846 print(json.dumps({"status": "deleted", "fileId": args.file_id, "permanent": True}))847 return848 849 # Trash (reversible). Use files.update with trashed=True.850 body = {"trashed": True}851 if _gws_binary():852 _run_gws(853 ["drive", "files", "update"],854 params={"fileId": args.file_id},855 body=body,856 )857 print(json.dumps({"status": "trashed", "fileId": args.file_id, "permanent": False}))858 return859 860 service = build_service("drive", "v3")861 service.files().update(fileId=args.file_id, body=body).execute()862 print(json.dumps({"status": "trashed", "fileId": args.file_id, "permanent": False}))863 864 865# =========================================================================866# Contacts867# =========================================================================868 869 870def contacts_list(args):871 if _gws_binary():872 results = _run_gws(873 ["people", "people", "connections", "list"],874 params={875 "resourceName": "people/me",876 "pageSize": args.max,877 "personFields": "names,emailAddresses,phoneNumbers",878 },879 )880 contacts = []881 for person in results.get("connections", []):882 names = person.get("names", [{}])883 emails = person.get("emailAddresses", [])884 phones = person.get("phoneNumbers", [])885 contacts.append({886 "name": names[0].get("displayName", "") if names else "",887 "emails": [e.get("value", "") for e in emails],888 "phones": [p.get("value", "") for p in phones],889 })890 print(json.dumps(contacts, indent=2, ensure_ascii=False))891 return892 893 service = build_service("people", "v1")894 results = service.people().connections().list(895 resourceName="people/me",896 pageSize=args.max,897 personFields="names,emailAddresses,phoneNumbers",898 ).execute()899 contacts = []900 for person in results.get("connections", []):901 names = person.get("names", [{}])902 emails = person.get("emailAddresses", [])903 phones = person.get("phoneNumbers", [])904 contacts.append({905 "name": names[0].get("displayName", "") if names else "",906 "emails": [e.get("value", "") for e in emails],907 "phones": [p.get("value", "") for p in phones],908 })909 print(json.dumps(contacts, indent=2, ensure_ascii=False))910 911 912# =========================================================================913# Sheets914# =========================================================================915 916 917def sheets_get(args):918 if _gws_binary():919 result = _run_gws(920 ["sheets", "spreadsheets", "values", "get"],921 params={"spreadsheetId": args.sheet_id, "range": args.range},922 )923 print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False))924 return925 926 service = build_service("sheets", "v4")927 result = service.spreadsheets().values().get(928 spreadsheetId=args.sheet_id, range=args.range,929 ).execute()930 print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False))931 932 933 934def sheets_update(args):935 values = json.loads(args.values)936 body = {"values": values}937 938 if _gws_binary():939 result = _run_gws(940 ["sheets", "spreadsheets", "values", "update"],941 params={942 "spreadsheetId": args.sheet_id,943 "range": args.range,944 "valueInputOption": "USER_ENTERED",945 },946 body=body,947 )948 print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2))949 return950 951 service = build_service("sheets", "v4")952 result = service.spreadsheets().values().update(953 spreadsheetId=args.sheet_id, range=args.range,954 valueInputOption="USER_ENTERED", body=body,955 ).execute()956 print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2))957 958 959 960def sheets_append(args):961 values = json.loads(args.values)962 body = {"values": values}963 964 if _gws_binary():965 result = _run_gws(966 ["sheets", "spreadsheets", "values", "append"],967 params={968 "spreadsheetId": args.sheet_id,969 "range": args.range,970 "valueInputOption": "USER_ENTERED",971 "insertDataOption": "INSERT_ROWS",972 },973 body=body,974 )975 print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2))976 return977 978 service = build_service("sheets", "v4")979 result = service.spreadsheets().values().append(980 spreadsheetId=args.sheet_id, range=args.range,981 valueInputOption="USER_ENTERED", insertDataOption="INSERT_ROWS", body=body,982 ).execute()983 print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2))984 985 986def sheets_create(args):987 """Create a new spreadsheet. Returns the new spreadsheet ID and URL."""988 body = {"properties": {"title": args.title}}989 if args.sheet_name:990 body["sheets"] = [{"properties": {"title": args.sheet_name}}]991 992 if _gws_binary():993 result = _run_gws(["sheets", "spreadsheets", "create"], body=body)994 print(json.dumps({995 "status": "created",996 "spreadsheetId": result.get("spreadsheetId", ""),997 "title": result.get("properties", {}).get("title", ""),998 "spreadsheetUrl": result.get("spreadsheetUrl", ""),999 }, indent=2, ensure_ascii=False))1000 return1001 1002 service = build_service("sheets", "v4")1003 result = service.spreadsheets().create(1004 body=body, fields="spreadsheetId,properties,spreadsheetUrl",1005 ).execute()1006 print(json.dumps({1007 "status": "created",1008 "spreadsheetId": result.get("spreadsheetId", ""),1009 "title": result.get("properties", {}).get("title", ""),1010 "spreadsheetUrl": result.get("spreadsheetUrl", ""),1011 }, indent=2, ensure_ascii=False))1012 1013 1014# =========================================================================1015# Docs1016# =========================================================================1017 1018 1019def docs_get(args):1020 tab_arg = getattr(args, "tab", None)1021 params = {"documentId": args.doc_id, "includeTabsContent": True}1022 if _gws_binary():1023 doc = _run_gws(["docs", "documents", "get"], params=params)1024 else:1025 service = build_service("docs", "v1")1026 doc = service.documents().get(1027 documentId=args.doc_id, includeTabsContent=True,1028 ).execute()1029 1030 result = {1031 "title": doc.get("title", ""),1032 "documentId": doc.get("documentId", ""),1033 }1034 tabs = _flatten_doc_tabs(doc)1035 if not tabs:1036 # Legacy single-body response shape.1037 result["body"] = _extract_doc_text(doc)1038 elif tab_arg:1039 _, body = _resolve_write_tab(doc, tab_arg)1040 result["tab"] = tab_arg1041 result["body"] = _extract_body_text(body)1042 else:1043 result["tabs"] = [1044 {1045 "tabId": t["tabId"],1046 "title": t["title"],1047 "level": t["level"],1048 "body": _extract_body_text(t["body"]),1049 }1050 for t in tabs1051 ]1052 # Keep "body" populated for single-tab docs so existing callers work.1053 if len(tabs) == 1:1054 result["body"] = result["tabs"][0]["body"]1055 print(json.dumps(result, indent=2, ensure_ascii=False))1056 1057 1058def docs_create(args):1059 """Create a new Doc. Optionally seed it with initial body text."""1060 body = {"title": args.title}1061 1062 if _gws_binary():1063 doc = _run_gws(["docs", "documents", "create"], body=body)1064 else:1065 service = build_service("docs", "v1")1066 doc = service.documents().create(body=body).execute()1067 1068 doc_id = doc.get("documentId", "")1069 1070 if args.body and doc_id:1071 _docs_insert_text(doc_id, args.body, index=1)1072 1073 print(json.dumps({1074 "status": "created",1075 "documentId": doc_id,1076 "title": doc.get("title", ""),1077 "url": f"https://docs.google.com/document/d/{doc_id}/edit" if doc_id else "",1078 }, indent=2, ensure_ascii=False))1079 1080 1081def docs_append(args):1082 """Append text to the end of an existing Doc (one tab of it, if tabbed)."""1083 if _gws_binary():1084 doc = _run_gws(1085 ["docs", "documents", "get"],1086 params={"documentId": args.doc_id, "includeTabsContent": True},1087 )1088 else:1089 service = build_service("docs", "v1")1090 doc = service.documents().get(1091 documentId=args.doc_id, includeTabsContent=True,1092 ).execute()1093 1094 tab_id, body = _resolve_write_tab(doc, getattr(args, "tab", None))1095 1096 # The end-of-body index is one less than the segment endIndex of the body1097 # (trailing newline is always at length-1). Docs indexes are 1-based; use1098 # endIndex - 1 to insert before the final newline. Each tab has its own1099 # index space, so the write location must carry the tab ID.1100 content = body.get("content", [])1101 end_index = 11102 for element in content:1103 ei = element.get("endIndex")1104 if isinstance(ei, int) and ei > end_index:1105 end_index = ei1106 insert_index = max(end_index - 1, 1)1107 1108 text = args.text if args.text.endswith("\n") else args.text + "\n"1109 _docs_insert_text(args.doc_id, text, index=insert_index, tab_id=tab_id)1110 1111 result = {1112 "status": "appended",1113 "documentId": args.doc_id,1114 "inserted_at": insert_index,1115 "characters": len(text),1116 }1117 if tab_id:1118 result["tab"] = tab_id1119 print(json.dumps(result, indent=2, ensure_ascii=False))1120 1121 1122def _docs_insert_text(doc_id: str, text: str, index: int, tab_id: str | None = None) -> None:1123 """Send a batchUpdate with a single insertText request."""1124 location: dict = {"index": index}1125 if tab_id:1126 location["tabId"] = tab_id1127 requests = [{1128 "insertText": {1129 "location": location,1130 "text": text,1131 }1132 }]1133 if _gws_binary():1134 _run_gws(1135 ["docs", "documents", "batchUpdate"],1136 params={"documentId": doc_id},1137 body={"requests": requests},1138 )1139 return1140 1141 service = build_service("docs", "v1")1142 service.documents().batchUpdate(documentId=doc_id, body={"requests": requests}).execute()1143 1144 1145# =========================================================================1146# CLI parser1147# =========================================================================1148 1149 1150def main():1151 parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent")1152 sub = parser.add_subparsers(dest="service", required=True)1153 1154 # --- Gmail ---1155 gmail = sub.add_parser("gmail")1156 gmail_sub = gmail.add_subparsers(dest="action", required=True)1157 1158 p = gmail_sub.add_parser("search")1159 p.add_argument("query", help="Gmail search query (e.g. 'is:unread')")1160 p.add_argument("--max", type=int, default=10)1161 p.set_defaults(func=gmail_search)1162 1163 p = gmail_sub.add_parser("get")1164 p.add_argument("message_id")1165 p.set_defaults(func=gmail_get)1166 1167 p = gmail_sub.add_parser("send")1168 p.add_argument("--to", required=True)1169 p.add_argument("--subject", required=True)1170 p.add_argument("--body", required=True)1171 p.add_argument("--cc", default="")1172 p.add_argument("--from", dest="from_header", default="", help="Custom From header (e.g. '\"Agent Name\" <user@example.com>')")1173 p.add_argument("--html", action="store_true", help="Send body as HTML")1174 p.add_argument("--thread-id", default="", help="Thread ID for threading")1175 p.set_defaults(func=gmail_send)1176 1177 p = gmail_sub.add_parser("reply")1178 p.add_argument("message_id", help="Message ID to reply to")1179 p.add_argument("--body", required=True)1180 p.add_argument("--from", dest="from_header", default="", help="Custom From header (e.g. '\"Agent Name\" <user@example.com>')")1181 p.set_defaults(func=gmail_reply)1182 1183 p = gmail_sub.add_parser("labels")1184 p.set_defaults(func=gmail_labels)1185 1186 p = gmail_sub.add_parser("modify")1187 p.add_argument("message_id")1188 p.add_argument("--add-labels", default="", help="Comma-separated label IDs to add")1189 p.add_argument("--remove-labels", default="", help="Comma-separated label IDs to remove")1190 p.set_defaults(func=gmail_modify)1191 1192 # --- Calendar ---1193 cal = sub.add_parser("calendar")1194 cal_sub = cal.add_subparsers(dest="action", required=True)1195 1196 p = cal_sub.add_parser("list")1197 p.add_argument("--start", default="", help="Start time (ISO 8601)")1198 p.add_argument("--end", default="", help="End time (ISO 8601)")1199 p.add_argument("--max", type=int, default=25)1200 p.add_argument("--calendar", default="primary")1201 p.set_defaults(func=calendar_list)1202 1203 p = cal_sub.add_parser("create")1204 p.add_argument("--summary", required=True)1205 p.add_argument("--start", required=True, help="Start (ISO 8601 with timezone)")1206 p.add_argument("--end", required=True, help="End (ISO 8601 with timezone)")1207 p.add_argument("--location", default="")1208 p.add_argument("--description", default="")1209 p.add_argument("--attendees", default="", help="Comma-separated email addresses")1210 p.add_argument("--calendar", default="primary")1211 p.set_defaults(func=calendar_create)1212 1213 p = cal_sub.add_parser("delete")1214 p.add_argument("event_id")1215 p.add_argument("--calendar", default="primary")1216 p.set_defaults(func=calendar_delete)1217 1218 # --- Drive ---1219 drv = sub.add_parser("drive")1220 drv_sub = drv.add_subparsers(dest="action", required=True)1221 1222 p = drv_sub.add_parser("search")1223 p.add_argument("query")1224 p.add_argument("--max", type=int, default=10)1225 p.add_argument("--raw-query", action="store_true", help="Use query as raw Drive API query")1226 p.set_defaults(func=drive_search)1227 1228 p = drv_sub.add_parser("get")1229 p.add_argument("file_id")1230 p.set_defaults(func=drive_get)1231 1232 p = drv_sub.add_parser("upload")1233 p.add_argument("path", help="Local file path to upload")1234 p.add_argument("--name", default="", help="Override file name in Drive (defaults to local filename)")1235 p.add_argument("--parent", default="", help="Parent folder ID")1236 p.add_argument("--mime-type", default="", help="Override MIME type (auto-detected if omitted)")1237 p.set_defaults(func=drive_upload)1238 1239 p = drv_sub.add_parser("download")1240 p.add_argument("file_id")1241 p.add_argument("--output", default="", help="Local output path (defaults to ./<name> in cwd)")1242 p.add_argument("--export-mime", default="", help="Export MIME for Google-native files (overrides defaults: pdf for Docs/Slides, csv for Sheets, png for Drawings)")1243 p.set_defaults(func=drive_download)1244 1245 p = drv_sub.add_parser("create-folder")1246 p.add_argument("name")1247 p.add_argument("--parent", default="", help="Parent folder ID (defaults to root)")1248 p.set_defaults(func=drive_create_folder)1249 1250 p = drv_sub.add_parser("share")1251 p.add_argument("file_id")1252 p.add_argument("--role", default="reader", choices=["reader", "commenter", "writer", "fileOrganizer", "organizer", "owner"])1253 p.add_argument("--type", default="user", choices=["user", "group", "domain", "anyone"])1254 p.add_argument("--email", default="", help="Email address (required for type=user or type=group)")1255 p.add_argument("--domain", default="", help="Domain (required for type=domain)")1256 p.add_argument("--notify", action="store_true", help="Send notification email")1257 p.set_defaults(func=drive_share)1258 1259 p = drv_sub.add_parser("delete")1260 p.add_argument("file_id")1261 p.add_argument("--permanent", action="store_true", help="Permanently delete (default is trash, which is reversible)")1262 p.set_defaults(func=drive_delete)1263 1264 # --- Contacts ---1265 con = sub.add_parser("contacts")1266 con_sub = con.add_subparsers(dest="action", required=True)1267 1268 p = con_sub.add_parser("list")1269 p.add_argument("--max", type=int, default=50)1270 p.set_defaults(func=contacts_list)1271 1272 # --- Sheets ---1273 sh = sub.add_parser("sheets")1274 sh_sub = sh.add_subparsers(dest="action", required=True)1275 1276 p = sh_sub.add_parser("get")1277 p.add_argument("sheet_id")1278 p.add_argument("range")1279 p.set_defaults(func=sheets_get)1280 1281 p = sh_sub.add_parser("update")1282 p.add_argument("sheet_id")1283 p.add_argument("range")1284 p.add_argument("--values", required=True, help="JSON array of arrays")1285 p.set_defaults(func=sheets_update)1286 1287 p = sh_sub.add_parser("append")1288 p.add_argument("sheet_id")1289 p.add_argument("range")1290 p.add_argument("--values", required=True, help="JSON array of arrays")1291 p.set_defaults(func=sheets_append)1292 1293 p = sh_sub.add_parser("create")1294 p.add_argument("--title", required=True, help="Spreadsheet title")1295 p.add_argument("--sheet-name", default="", help="Name of the first tab (defaults to 'Sheet1')")1296 p.set_defaults(func=sheets_create)1297 1298 # --- Docs ---1299 docs = sub.add_parser("docs")1300 docs_sub = docs.add_subparsers(dest="action", required=True)1301 1302 p = docs_sub.add_parser("get")1303 p.add_argument("doc_id")1304 p.add_argument("--tab", default=None, help="Tab ID to read (tabbed Docs)")1305 p.set_defaults(func=docs_get)1306 1307 p = docs_sub.add_parser("create")1308 p.add_argument("--title", required=True, help="Document title")1309 p.add_argument("--body", default="", help="Initial body text (optional)")1310 p.set_defaults(func=docs_create)1311 1312 p = docs_sub.add_parser("append")1313 p.add_argument("doc_id")1314 p.add_argument("--text", required=True, help="Text to append to the end of the document")1315 p.add_argument("--tab", default=None, help="Tab ID to append to (required for multi-tab Docs)")1316 p.set_defaults(func=docs_append)1317 1318 args = parser.parse_args()1319 args.func(args)1320 1321 1322if __name__ == "__main__":1323 main()1324