#!/usr/bin/env python3 """Read-only Google Workspace helper for OpenClaw.""" from __future__ import annotations import argparse import json import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path TOKEN_URL = "https://oauth2.googleapis.com/token" SCOPES = [ "openid", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/contacts.readonly", "https://www.googleapis.com/auth/contacts.other.readonly", "https://www.googleapis.com/auth/calendar.readonly", "https://www.googleapis.com/auth/calendar.settings.readonly", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/documents.readonly", "https://www.googleapis.com/auth/spreadsheets.readonly", ] DEFAULT_CREDS = Path.home() / ".config" / "gog" / "credentials.json" def fail(message: str, code: int = 1) -> None: print(message, file=sys.stderr) raise SystemExit(code) def load_credentials(path: Path) -> dict: if not path.exists(): fail( f"Credentials not found at {path}. Copy an authorized-user JSON there first.", 2, ) with path.open("r", encoding="utf-8") as fh: creds = json.load(fh) required = {"client_id", "client_secret", "refresh_token"} missing = sorted(key for key in required if not creds.get(key)) if missing: fail(f"Credential file is missing: {', '.join(missing)}", 2) return creds def request_json( url: str, *, method: str = "GET", params: dict | None = None, data: dict | None = None, headers: dict | None = None, ) -> dict: if params: query = urllib.parse.urlencode(params, doseq=True) url = f"{url}?{query}" body = None merged_headers = {"Accept": "application/json"} if headers: merged_headers.update(headers) if data is not None: body = urllib.parse.urlencode(data, doseq=True).encode("utf-8") merged_headers["Content-Type"] = "application/x-www-form-urlencoded" req = urllib.request.Request(url, data=body, method=method, headers=merged_headers) try: with urllib.request.urlopen(req, timeout=30) as resp: return json.load(resp) except urllib.error.HTTPError as exc: details = exc.read().decode("utf-8", errors="replace") fail(f"{method} {url} failed: {exc.code} {details}", 3) except urllib.error.URLError as exc: fail(f"{method} {url} failed: {exc.reason}", 3) def get_access_token(creds: dict) -> str: token = request_json( TOKEN_URL, method="POST", data={ "client_id": creds["client_id"], "client_secret": creds["client_secret"], "refresh_token": creds["refresh_token"], "grant_type": "refresh_token", }, ) access_token = token.get("access_token") if not access_token: fail("Token refresh succeeded without an access token.", 3) return access_token def api_get(access_token: str, url: str, params: dict | None = None) -> dict: return request_json( url, params=params, headers={"Authorization": f"Bearer {access_token}"}, ) def print_json(payload: object) -> None: json.dump(payload, sys.stdout, indent=2, ensure_ascii=True) sys.stdout.write("\n") def emit(args: argparse.Namespace, payload: object, *, results_key: str | None = None) -> None: if getattr(args, "results_only", False) and results_key and isinstance(payload, dict): print_json(payload.get(results_key, [])) return print_json(payload) def add_common_flags(parser: argparse.ArgumentParser) -> None: parser.add_argument("--json", action="store_true", help="Emit JSON output.") parser.add_argument("--results-only", action="store_true", help="Emit only the primary result collection.") parser.add_argument("--no-input", action="store_true", help="Ignored compatibility flag for non-interactive use.") def gmail_header(message: dict, name: str) -> str | None: for header in message.get("payload", {}).get("headers", []): if header.get("name", "").lower() == name.lower(): return header.get("value") return None def cmd_auth_list(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) profile = api_get(access_token, "https://gmail.googleapis.com/gmail/v1/users/me/profile") emit( args, { "configured": True, "credential_path": str(args.creds), "emailAddress": profile.get("emailAddress"), "historyId": profile.get("historyId"), "scopes": creds.get("scopes", SCOPES), }, ) def cmd_auth_add(args: argparse.Namespace) -> None: fail( "Manual re-auth is not implemented in the server CLI. Re-run the local desktop OAuth flow and copy the authorized-user JSON to ~/.config/gog/credentials.json.", 4, ) def cmd_gmail_search(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) listing = api_get( access_token, "https://gmail.googleapis.com/gmail/v1/users/me/messages", {"q": args.query, "maxResults": args.max}, ) results = [] for item in listing.get("messages", []): message = api_get( access_token, f"https://gmail.googleapis.com/gmail/v1/users/me/messages/{item['id']}", { "format": "metadata", "metadataHeaders": ["From", "To", "Subject", "Date"], }, ) results.append( { "id": message.get("id"), "threadId": message.get("threadId"), "labelIds": message.get("labelIds", []), "snippet": message.get("snippet"), "subject": gmail_header(message, "Subject"), "from": gmail_header(message, "From"), "to": gmail_header(message, "To"), "date": gmail_header(message, "Date"), "internalDate": message.get("internalDate"), } ) emit(args, {"query": args.query, "count": len(results), "messages": results}, results_key="messages") def cmd_gmail_get(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) message = api_get( access_token, f"https://gmail.googleapis.com/gmail/v1/users/me/messages/{args.message_id}", {"format": args.format}, ) emit(args, message) def cmd_calendar_list(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) data = api_get( access_token, "https://www.googleapis.com/calendar/v3/users/me/calendarList", {"maxResults": args.max}, ) items = [ { "id": item.get("id"), "summary": item.get("summary"), "timeZone": item.get("timeZone"), "primary": item.get("primary", False), "accessRole": item.get("accessRole"), } for item in data.get("items", []) ] emit(args, {"count": len(items), "calendars": items}, results_key="calendars") def cmd_calendar_events(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) params = {"maxResults": args.max, "singleEvents": "true", "orderBy": "startTime"} if args.start: params["timeMin"] = f"{args.start}T00:00:00Z" if args.end: params["timeMax"] = f"{args.end}T23:59:59Z" data = api_get( access_token, f"https://www.googleapis.com/calendar/v3/calendars/{urllib.parse.quote(args.calendar, safe='')}/events", params, ) events = [ { "id": item.get("id"), "status": item.get("status"), "summary": item.get("summary"), "description": item.get("description"), "htmlLink": item.get("htmlLink"), "start": item.get("start"), "end": item.get("end"), "location": item.get("location"), "organizer": item.get("organizer"), } for item in data.get("items", []) ] emit(args, {"calendar": args.calendar, "count": len(events), "events": events}, results_key="events") def cmd_people_search(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) contact_read_mask = "names,emailAddresses,phoneNumbers,organizations" other_contact_read_mask = "names,emailAddresses,phoneNumbers" contacts = api_get( access_token, "https://people.googleapis.com/v1/people:searchContacts", {"query": args.query, "pageSize": args.max, "readMask": contact_read_mask}, ).get("results", []) others = api_get( access_token, "https://people.googleapis.com/v1/otherContacts:search", {"query": args.query, "pageSize": args.max, "readMask": other_contact_read_mask}, ).get("results", []) people = contacts + others emit(args, {"query": args.query, "count": len(people), "results": people}, results_key="results") def drive_search_query(term: str) -> str: escaped = term.replace("\\", "\\\\").replace("'", "\\'") return f"trashed = false and (name contains '{escaped}' or fullText contains '{escaped}')" def cmd_drive_search(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) data = api_get( access_token, "https://www.googleapis.com/drive/v3/files", { "q": drive_search_query(args.query), "pageSize": args.max, "fields": "files(id,name,mimeType,modifiedTime,owners/displayName,webViewLink,driveId,parents)", }, ) emit( args, {"query": args.query, "count": len(data.get("files", [])), "files": data.get("files", [])}, results_key="files", ) def cmd_drive_get(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) data = api_get( access_token, f"https://www.googleapis.com/drive/v3/files/{urllib.parse.quote(args.file_id, safe='')}", { "fields": "id,name,mimeType,modifiedTime,createdTime,description,owners,parents,webViewLink,size,md5Checksum,trashed", }, ) emit(args, data) def cmd_docs_get(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) data = api_get( access_token, f"https://docs.googleapis.com/v1/documents/{urllib.parse.quote(args.document_id, safe='')}", ) emit(args, data) def cmd_sheets_values(args: argparse.Namespace) -> None: creds = load_credentials(args.creds) access_token = get_access_token(creds) data = api_get( access_token, "https://sheets.googleapis.com/v4/spreadsheets/" f"{urllib.parse.quote(args.spreadsheet_id, safe='')}/values/" f"{urllib.parse.quote(args.value_range, safe='')}", { "majorDimension": args.major_dimension, "valueRenderOption": args.value_render_option, }, ) emit(args, data) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="gog", description="Read-only Google Workspace helper.") parser.add_argument("--creds", type=Path, default=DEFAULT_CREDS, help="Authorized-user JSON path.") subparsers = parser.add_subparsers(dest="command", required=True) auth = subparsers.add_parser("auth") auth_sub = auth.add_subparsers(dest="auth_command", required=True) auth_list = auth_sub.add_parser("list") add_common_flags(auth_list) auth_list.set_defaults(func=cmd_auth_list) auth_status = auth_sub.add_parser("status") add_common_flags(auth_status) auth_status.set_defaults(func=cmd_auth_list) auth_add = auth_sub.add_parser("add") auth_add.add_argument("account") auth_add.add_argument("--services", default="") auth_add.add_argument("--readonly", action="store_true") auth_add.add_argument("--manual", action="store_true") add_common_flags(auth_add) auth_add.set_defaults(func=cmd_auth_add) gmail = subparsers.add_parser("gmail") gmail_sub = gmail.add_subparsers(dest="gmail_command", required=True) gmail_search = gmail_sub.add_parser("search") gmail_search.add_argument("query") gmail_search.add_argument("--max", type=int, default=10) add_common_flags(gmail_search) gmail_search.set_defaults(func=cmd_gmail_search) gmail_get = gmail_sub.add_parser("get") gmail_get.add_argument("message_id") gmail_get.add_argument("--format", default="full", choices=["full", "metadata", "minimal", "raw"]) add_common_flags(gmail_get) gmail_get.set_defaults(func=cmd_gmail_get) calendar = subparsers.add_parser("calendar") calendar_sub = calendar.add_subparsers(dest="calendar_command", required=True) calendar_list = calendar_sub.add_parser("list") calendar_list.add_argument("--max", type=int, default=50) add_common_flags(calendar_list) calendar_list.set_defaults(func=cmd_calendar_list) calendar_events = calendar_sub.add_parser("events") calendar_events.add_argument("calendar") calendar_events.add_argument("--from", dest="start") calendar_events.add_argument("--to", dest="end") calendar_events.add_argument("--max", type=int, default=50) add_common_flags(calendar_events) calendar_events.set_defaults(func=cmd_calendar_events) people = subparsers.add_parser("people") people_sub = people.add_subparsers(dest="people_command", required=True) people_search = people_sub.add_parser("search") people_search.add_argument("query") people_search.add_argument("--max", type=int, default=10) add_common_flags(people_search) people_search.set_defaults(func=cmd_people_search) drive = subparsers.add_parser("drive") drive_sub = drive.add_subparsers(dest="drive_command", required=True) drive_search = drive_sub.add_parser("search") drive_search.add_argument("query") drive_search.add_argument("--max", type=int, default=20) add_common_flags(drive_search) drive_search.set_defaults(func=cmd_drive_search) drive_get = drive_sub.add_parser("get") drive_get.add_argument("file_id") add_common_flags(drive_get) drive_get.set_defaults(func=cmd_drive_get) docs = subparsers.add_parser("docs") docs_sub = docs.add_subparsers(dest="docs_command", required=True) docs_get = docs_sub.add_parser("get") docs_get.add_argument("document_id") add_common_flags(docs_get) docs_get.set_defaults(func=cmd_docs_get) sheets = subparsers.add_parser("sheets") sheets_sub = sheets.add_subparsers(dest="sheets_command", required=True) sheets_values = sheets_sub.add_parser("values") sheets_values.add_argument("spreadsheet_id") sheets_values.add_argument("value_range") sheets_values.add_argument("--major-dimension", default="ROWS", choices=["ROWS", "COLUMNS"]) sheets_values.add_argument( "--value-render-option", default="FORMATTED_VALUE", choices=["FORMATTED_VALUE", "UNFORMATTED_VALUE", "FORMULA"], ) add_common_flags(sheets_values) sheets_values.set_defaults(func=cmd_sheets_values) return parser def main() -> None: parser = build_parser() args = parser.parse_args() args.func(args) if __name__ == "__main__": main()