replica-omnisciente/scripts/gws/gws.py
Raphael Cautus (Maestro) 2f26f2d836 feat(scripts): onboarding, GWS, lifestream, muscriptor, music, data-sharing
- onboard-client.py: client replica scaffolding CLI
- gws/: Google Workspace sync (Gmail, Calendar, Drive)
- lifestream/: life event stream collector
- muscriptor-mcp/: audio → MIDI MCP server
- music-mcp/: music library MCP server
- data_sharing/: consent-gated data sharing (Python + TS)
- sync-mirrors.py: GitHub → Forgejo mirror engine
- brain-to-gbrain.py, vault-sync.py, test-all.sh
- shared/: TS data-sharing library + index
- dirac: provider registry update
- .gitignore: exclude Rust build artifacts

Co-authored-by: Álvaro de Campos <campos@portugalfuturista.org>
2026-07-31 14:58:02 +01:00

406 lines
14 KiB
Python

#!/usr/bin/env python3
"""Google Workspace ingestion tool for Aurelio research studies.
Supports two auth modes:
1. OAuth2 user consent (for individual accounts)
2. Service account with domain-wide delegation (preferred for Workspace-wide studies)
Usage:
# Service account (place key at ~/.aurelio/gws/service_account.json)
python3 scripts/gws/gws.py fetch-emails --emails fabio@savearth.io,claudio@savearth.io,joao@savearth.io --out-dir ./gws-output
# OAuth2 (fallback)
python3 scripts/gws/gws.py auth --email fabio@savearth.io
python3 scripts/gws/gws.py auth-code --email fabio@savearth.io --code '<code>'
python3 scripts/gws/gws.py fetch-emails --emails ... --out-dir ./gws-output
"""
import argparse
import base64
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google.oauth2.service_account import Credentials as ServiceAccountCredentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
# Read-only scopes for research study
SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/contacts.readonly",
]
DEFAULT_CLIENT_SECRET = os.environ.get(
"GWS_CLIENT_SECRET",
str(Path.home() / ".aurelio" / "gws" / "client_secret.json"),
)
SERVICE_ACCOUNT_FILE = os.environ.get(
"GWS_SERVICE_ACCOUNT",
str(Path.home() / ".aurelio" / "gws" / "service_account.json"),
)
TOKEN_DIR = Path(os.environ.get("GWS_TOKEN_DIR", Path.home() / ".aurelio" / "gws" / "tokens"))
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _client_secret_path() -> Path:
p = Path(DEFAULT_CLIENT_SECRET)
if not p.exists():
raise SystemExit(
f"OAuth client secret not found at {p}. "
"Set GWS_CLIENT_SECRET or place it at ~/.aurelio/gws/client_secret.json"
)
return p
def _service_account_path() -> Path | None:
p = Path(SERVICE_ACCOUNT_FILE)
return p if p.exists() else None
def _token_path(email: str) -> Path:
TOKEN_DIR.mkdir(parents=True, exist_ok=True)
return TOKEN_DIR / f"{email}.json"
def _load_creds(email: str) -> Credentials | None:
tp = _token_path(email)
if tp.exists():
return Credentials.from_authorized_user_file(str(tp), SCOPES)
return None
def _save_creds(email: str, creds: Credentials) -> None:
tp = _token_path(email)
tp.write_text(creds.to_json())
os.chmod(tp, 0o600)
AnyCredentials = Credentials | ServiceAccountCredentials
def _ensure_creds(email: str) -> AnyCredentials:
sa_path = _service_account_path()
if sa_path:
creds = ServiceAccountCredentials.from_service_account_file(
str(sa_path), scopes=SCOPES, subject=email
)
creds.refresh(Request())
return creds
creds = _load_creds(email)
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
_save_creds(email, creds)
return creds
if creds and creds.valid:
return creds
flow = InstalledAppFlow.from_client_secrets_file(str(_client_secret_path()), SCOPES)
creds = flow.run_local_server(port=8080)
_save_creds(email, creds)
return creds
def cmd_auth(args: argparse.Namespace) -> int:
email = args.email
if _service_account_path():
print(f"Service account mode active; no OAuth needed for {email}")
return 0
creds = _load_creds(email)
if creds and creds.valid:
print(f"Already authenticated for {email}")
return 0
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
_save_creds(email, creds)
print(f"Refreshed token for {email}")
return 0
flow = InstalledAppFlow.from_client_secrets_file(str(_client_secret_path()), SCOPES)
auth_url, _ = flow.authorization_url(access_type="offline", prompt="consent")
print(f"Authorize {email} here:\n{auth_url}\n")
print("Then run:")
print(f" python3 scripts/gws/gws.py auth-code --email {email} --code '<code>'")
return 0
def cmd_auth_code(args: argparse.Namespace) -> int:
email = args.email
code = args.code
flow = InstalledAppFlow.from_client_secrets_file(str(_client_secret_path()), SCOPES)
flow.fetch_token(code=code)
creds = flow.credentials
_save_creds(email, creds)
print(f"Authenticated {email}; token saved to {_token_path(email)}")
return 0
def _build(service_name: str, version: str, creds: AnyCredentials) -> Any:
return build(service_name, version, credentials=creds, cache_discovery=False)
def _query_for_addresses(addresses: Iterable[str]) -> str:
parts = []
for a in addresses:
parts.append(f"from:{a} OR to:{a} OR cc:{a}")
return " OR ".join(f"({p})" for p in parts)
def cmd_fetch_emails(args: argparse.Namespace) -> int:
emails = args.emails.split(",")
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
actor = args.actor or emails[0]
creds = _ensure_creds(actor)
service = _build("gmail", "v1", creds)
query = _query_for_addresses(emails)
if args.since:
query += f" after:{args.since}"
if args.before:
query += f" before:{args.before}"
results = []
page_token = None
while True:
resp = (
service.users()
.messages()
.list(userId="me", q=query, pageToken=page_token, maxResults=min(args.max_results, 500))
.execute()
)
messages = resp.get("messages", [])
for m in messages:
msg = (
service.users()
.messages()
.get(userId="me", id=m["id"], format="full")
.execute()
)
payload = msg.get("payload", {})
headers = {h["name"].lower(): h["value"] for h in payload.get("headers", [])}
body = ""
parts = payload.get("parts", [])
for part in parts:
if part.get("mimeType") == "text/plain" and "data" in part.get("body", {}):
body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace")
break
results.append(
{
"id": msg["id"],
"thread_id": msg["threadId"],
"internal_date": msg.get("internalDate"),
"subject": headers.get("subject", ""),
"from": headers.get("from", ""),
"to": headers.get("to", ""),
"cc": headers.get("cc", ""),
"body": body,
}
)
if len(results) >= args.max_results:
break
page_token = resp.get("nextPageToken")
if not page_token or len(results) >= args.max_results:
break
out_file = out_dir / f"emails-{actor}-{_iso_now()}.jsonl"
with open(out_file, "w", encoding="utf-8") as f:
for r in results:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"Wrote {len(results)} emails to {out_file}")
return 0
def cmd_fetch_calendar(args: argparse.Namespace) -> int:
emails = args.emails.split(",")
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
actor = args.actor or emails[0]
creds = _ensure_creds(actor)
service = _build("calendar", "v3", creds)
time_min = args.since or "2020-01-01T00:00:00Z"
time_max = args.before or "2030-01-01T00:00:00Z"
results = []
page_token = None
while True:
resp = (
service.events()
.list(
calendarId="primary",
timeMin=time_min,
timeMax=time_max,
pageToken=page_token,
maxResults=min(args.max_results, 2500),
showDeleted=False,
singleEvents=True,
orderBy="startTime",
)
.execute()
)
for item in resp.get("items", []):
attendees = [a.get("email", "") for a in item.get("attendees", [])]
if args.require_match and not any(e in attendees for e in emails):
continue
results.append(
{
"id": item["id"],
"summary": item.get("summary", ""),
"description": item.get("description", ""),
"start": item.get("start", {}),
"end": item.get("end", {}),
"creator": item.get("creator", {}),
"attendees": attendees,
"link": item.get("htmlLink", ""),
}
)
if len(results) >= args.max_results:
break
page_token = resp.get("nextPageToken")
if not page_token or len(results) >= args.max_results:
break
out_file = out_dir / f"calendar-{actor}-{_iso_now()}.jsonl"
with open(out_file, "w", encoding="utf-8") as f:
for r in results:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"Wrote {len(results)} calendar events to {out_file}")
return 0
def cmd_fetch_documents(args: argparse.Namespace) -> int:
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
actor = args.actor
creds = _ensure_creds(actor)
service = _build("drive", "v3", creds)
results = []
page_token = None
query = "trashed = false"
if args.mime_type:
query += f" and mimeType = '{args.mime_type}'"
while True:
resp = (
service.files()
.list(
q=query,
pageToken=page_token,
pageSize=min(args.max_results, 1000),
fields="nextPageToken, files(id, name, mimeType, createdTime, modifiedTime, owners, webViewLink, size)",
)
.execute()
)
for item in resp.get("files", []):
results.append(
{
"id": item["id"],
"name": item.get("name", ""),
"mime_type": item.get("mimeType", ""),
"created_time": item.get("createdTime", ""),
"modified_time": item.get("modifiedTime", ""),
"owners": [o.get("emailAddress", "") for o in item.get("owners", [])],
"link": item.get("webViewLink", ""),
"size": item.get("size", ""),
}
)
if len(results) >= args.max_results:
break
page_token = resp.get("nextPageToken")
if not page_token or len(results) >= args.max_results:
break
out_file = out_dir / f"documents-{actor}-{_iso_now()}.jsonl"
with open(out_file, "w", encoding="utf-8") as f:
for r in results:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"Wrote {len(results)} documents to {out_file}")
return 0
def cmd_to_brain(args: argparse.Namespace) -> int:
"""Normalize gws output into brain pages."""
out_dir = Path(args.out_dir)
study_dir = out_dir / "brain"
study_dir.mkdir(parents=True, exist_ok=True)
for f in sorted(out_dir.glob("emails-*.jsonl")):
with open(f, encoding="utf-8") as fh:
emails = [json.loads(line) for line in fh]
actor = f.stem.split("-", 2)[1]
page = {
"title": f"Savearth communication study — {actor} emails",
"source": "gws",
"count": len(emails),
"emails": emails[:50],
}
(study_dir / f"emails-{actor}.json").write_text(json.dumps(page, ensure_ascii=False, indent=2))
print(f"Normalized brain pages to {study_dir}")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Google Workspace ingestion for Aurelio")
sub = p.add_subparsers(dest="cmd", required=True)
a = sub.add_parser("auth", help="Print authorization URL for an email")
a.add_argument("--email", required=True)
a.set_defaults(func=cmd_auth)
ac = sub.add_parser("auth-code", help="Exchange authorization code for token")
ac.add_argument("--email", required=True)
ac.add_argument("--code", required=True)
ac.set_defaults(func=cmd_auth_code)
fe = sub.add_parser("fetch-emails", help="Fetch Gmail messages involving addresses")
fe.add_argument("--emails", required=True, help="Comma-separated addresses")
fe.add_argument("--actor", default=None, help="Workspace account to authenticate as")
fe.add_argument("--since", default=None, help="YYYY/MM/DD")
fe.add_argument("--before", default=None, help="YYYY/MM/DD")
fe.add_argument("--max-results", type=int, default=500)
fe.add_argument("--out-dir", default="./gws-output")
fe.set_defaults(func=cmd_fetch_emails)
fc = sub.add_parser("fetch-calendar", help="Fetch Calendar events")
fc.add_argument("--emails", required=True)
fc.add_argument("--actor", default=None)
fc.add_argument("--since", default=None)
fc.add_argument("--before", default=None)
fc.add_argument("--max-results", type=int, default=500)
fc.add_argument("--out-dir", default="./gws-output")
fc.add_argument("--require-match", action="store_true", default=True)
fc.set_defaults(func=cmd_fetch_calendar)
fd = sub.add_parser("fetch-documents", help="Fetch Drive metadata")
fd.add_argument("--actor", required=True)
fd.add_argument("--mime-type", default=None, help="e.g. application/vnd.google-apps.document")
fd.add_argument("--max-results", type=int, default=500)
fd.add_argument("--out-dir", default="./gws-output")
fd.set_defaults(func=cmd_fetch_documents)
tb = sub.add_parser("to-brain", help="Convert gws output to brain pages")
tb.add_argument("--out-dir", default="./gws-output")
tb.set_defaults(func=cmd_to_brain)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())