From 92d96ebe5c2f1eecdd1dfdca7812c26f3d030841 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Fri, 3 Jul 2026 01:46:17 -0700 Subject: [PATCH] =?UTF-8?q?feat(drive):=20drive=5Fsearch=20+=20drive=5Frea?= =?UTF-8?q?d=20=E2=80=94=20first=20additive=20service=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gdrive.py on the shared auth core: full-text search (escaped fullText query, trashed excluded), read-as-text (Docs -> text/plain, Sheets -> text/csv, Slides -> text/plain via export; text/* + JSON/XML/YAML via alt=media); binary files return metadata + a readable refusal, never mojibake - auth.request grows raw=True for non-JSON bodies (exports/media) — 401 retry and error surfacing apply uniformly - tools drive_search / drive_read (read-only, truncation with a truncated flag) - default OAuth scopes already include drive.readonly; manifest/README updated Co-Authored-By: Claude Fable 5 --- README.md | 5 +-- __init__.py | 32 +++++++++++++++++ auth.py | 10 ++++-- gdrive.py | 74 ++++++++++++++++++++++++++++++++++++++ protoagent.plugin.yaml | 11 +++--- tests/test_drive.py | 80 ++++++++++++++++++++++++++++++++++++++++++ tests/test_plugin.py | 2 ++ 7 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 gdrive.py create mode 100644 tests/test_drive.py diff --git a/README.md b/README.md index 5ddb4af..91ed0a6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # google-plugin -**Google Workspace** for a [protoAgent](https://github.com/protoLabsAI/protoAgent) agent — built to grow. Ships **Gmail (read + draft)** and **Calendar (read)** today over a service-agnostic OAuth/REST core, so Drive / Docs / Sheets are additive modules, not a rewrite. +**Google Workspace** for a [protoAgent](https://github.com/protoLabsAI/protoAgent) agent — built to grow. Ships **Gmail (read + draft)**, **Calendar (read)**, and **Drive (read)** today over a service-agnostic OAuth/REST core, so Docs / Sheets / further services are additive modules, not a rewrite. Pull-mode posture: the agent lists, searches, reads, and **drafts** — it never sends or auto-replies. A human reviews drafts in the Drafts folder and sends them. @@ -8,9 +8,10 @@ Pull-mode posture: the agent lists, searches, reads, and **drafts** — it never - `gmail_list_unread(label, max)` · `gmail_search(query, max)` · `gmail_get_thread(thread_id)` — read. - `gmail_create_draft(body, thread_id | to+subject, …)` — **draft only, never sends**. - `calendar_list_upcoming(days, calendar_id)` · `calendar_event_detail(event_id, calendar_id)` — read. +- `drive_search(query, max)` · `drive_read(file_id, max_chars)` — read; Docs export as text, Sheets as CSV, Slides as text. ## Architecture -`auth.py` is a service-agnostic OAuth-refresh + REST core; one module per service (`gmail.py`, `calendar.py`). Adding Drive/Docs/Sheets is a new module + tools on the same core. +`auth.py` is a service-agnostic OAuth-refresh + REST core; one module per service (`gmail.py`, `gcal.py`, `gdrive.py`). Adding Docs/Sheets is a new module + tools on the same core. ## Connect (one-click OAuth) Set `google.client_id` + `client_secret` in **Settings ▸ Plugins ▸ Google**, open the **Google** panel, hit **Connect Google**, approve on Google's consent screen — done. The plugin runs the authorization-code flow itself (public callback at `/plugins/google/oauth/callback`, gated by a single-use state nonce) and writes the refresh token into the untracked `secrets.yaml`; it takes effect immediately, no restart. diff --git a/__init__.py b/__init__.py index f2303fe..c23aad2 100644 --- a/__init__.py +++ b/__init__.py @@ -160,10 +160,42 @@ def calendar_event_detail(event_id: str, calendar_id: str = "primary") -> str: return out if isinstance(out, str) else json.dumps(out, indent=2) +# ── Drive (read-only) ───────────────────────────────────────────────────────── + +@tool +def drive_search(query: str, max: int = 20) -> str: + """Search Google Drive by content and title (full-text). Read-only. + + Args: + query: free-text search (matches file names and contents). + max: max files (default 20, cap 50). + """ + from . import gdrive + + out = _run(gdrive.search, _creds(), query, max) + return out if isinstance(out, str) else json.dumps({"query": query, "count": len(out), "files": out}, indent=2) + + +@tool +def drive_read(file_id: str, max_chars: int = 20000) -> str: + """Read one Drive file as text (Docs → text, Sheets → CSV, Slides → text; plain + text files raw; binary files return metadata + link only). Read-only. + + Args: + file_id: the Drive file id (from drive_search). + max_chars: truncate the content to this many characters (default 20000). + """ + from . import gdrive + + out = _run(gdrive.read, _creds(), file_id, max_chars) + return out if isinstance(out, str) else json.dumps(out, indent=2) + + # Registered tools, grouped by service. Append new service tools here as they land. TOOLS = [ gmail_list_unread, gmail_search, gmail_get_thread, gmail_create_draft, calendar_list_upcoming, calendar_event_detail, + drive_search, drive_read, ] diff --git a/auth.py b/auth.py index 542b3a0..7f38a24 100644 --- a/auth.py +++ b/auth.py @@ -104,13 +104,15 @@ def get_access_token(creds: Creds, *, client: httpx.Client | None = None) -> str def request(creds: Creds, method: str, url: str, *, params: dict | None = None, - json: dict | None = None, client: httpx.Client | None = None) -> dict: + json: dict | None = None, client: httpx.Client | None = None, + raw: bool = False) -> dict | str: """Authenticated Google REST call. Returns parsed JSON (or {} on 204). Any service module builds on this — pass a full endpoint URL + params/json. Pass ``client`` (e.g. an httpx.MockTransport client) to unit-test offline. - A 401 retries once with a freshly minted token; other errors raise GoogleError - with Google's message from the response body. + ``raw=True`` returns the response body as text (Drive exports / media, which + aren't JSON). A 401 retries once with a freshly minted token; other errors + raise GoogleError with Google's message from the response body. """ owns = client is None c = client or httpx.Client(timeout=30) @@ -129,6 +131,8 @@ def request(creds: Creds, method: str, url: str, *, params: dict | None = None, if resp.status_code >= 400: detail = _error_detail(resp) or "no detail" raise GoogleError(f"{method} {resp.url.path} -> {resp.status_code}: {detail}") + if raw: + return resp.text if resp.status_code == 204 or not resp.content: return {} return resp.json() diff --git a/gdrive.py b/gdrive.py new file mode 100644 index 0000000..b1a3d97 --- /dev/null +++ b/gdrive.py @@ -0,0 +1,74 @@ +"""Drive service — read-only search + fetch-as-text. Built on the shared auth core. + +The additive-module promise made good: this file + two tool wrappers is the whole +cost of a new Workspace service. Google-native files export as text (Docs → +text/plain, Sheets → text/csv, Slides → text/plain); regular text files fetch raw +media. Binary files return their metadata with a readable refusal, never mojibake. +""" + +from __future__ import annotations + +from .auth import Creds, request + +BASE = "https://www.googleapis.com/drive/v3/files" + +# Google-native types and the text shape they export as. +_EXPORT = { + "application/vnd.google-apps.document": "text/plain", + "application/vnd.google-apps.spreadsheet": "text/csv", + "application/vnd.google-apps.presentation": "text/plain", +} +_TEXTY_PREFIXES = ("text/",) +_TEXTY_EXACT = { + "application/json", "application/xml", "application/javascript", + "application/x-yaml", "application/yaml", "application/csv", +} +_FIELDS = "id,name,mimeType,modifiedTime,size,owners(emailAddress),webViewLink" + + +def _summary(f: dict) -> dict: + return { + "id": f.get("id", ""), + "name": f.get("name", ""), + "mimeType": f.get("mimeType", ""), + "modified": f.get("modifiedTime", ""), + "size": f.get("size", ""), + "owners": [o.get("emailAddress", "") for o in (f.get("owners") or [])], + "link": f.get("webViewLink", ""), + } + + +def _is_texty(mime: str) -> bool: + return mime.startswith(_TEXTY_PREFIXES) or mime in _TEXTY_EXACT + + +def search(creds: Creds, query: str, max_results: int = 20, *, client=None) -> list[dict]: + """Full-text search (covers title + content). ``query`` is free text, escaped + into Drive's ``fullText contains`` syntax. No orderBy — Drive rejects sorting + on fullText queries.""" + escaped = query.replace("\\", "\\\\").replace("'", "\\'") + data = request(creds, "GET", BASE, params={ + "q": f"fullText contains '{escaped}' and trashed = false", + "pageSize": min(int(max_results), 50), + "fields": f"files({_FIELDS})", + }, client=client) + return [_summary(f) for f in (data.get("files") or [])] + + +def read(creds: Creds, file_id: str, max_chars: int = 20000, *, client=None) -> dict: + """Fetch one file as text. Docs/Sheets/Slides export; text files download raw; + binary files return metadata + a readable refusal in ``error``.""" + meta = request(creds, "GET", f"{BASE}/{file_id}", params={"fields": _FIELDS}, client=client) + out = _summary(meta) + mime = out["mimeType"] + if mime in _EXPORT: + text = request(creds, "GET", f"{BASE}/{file_id}/export", + params={"mimeType": _EXPORT[mime]}, client=client, raw=True) + elif _is_texty(mime): + text = request(creds, "GET", f"{BASE}/{file_id}", params={"alt": "media"}, client=client, raw=True) + else: + out["error"] = f"binary file ({mime}) — not readable as text; open it via the link instead" + return out + out["truncated"] = len(text) > int(max_chars) + out["content"] = text[: int(max_chars)] + return out diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 09fcda0..d2d757f 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -2,11 +2,12 @@ id: google name: Google Workspace version: 0.1.0 description: >- - Google Workspace for the agent, built to grow. Ships Gmail (read + draft) and - Calendar (read) today — gmail_list_unread, gmail_search, gmail_get_thread, - gmail_create_draft (DRAFT ONLY — never sends), calendar_list_upcoming, - calendar_event_detail — over a service-agnostic OAuth/REST core (no SDK) so Drive, - Docs, and Sheets are additive modules, not a rewrite. Pull-mode: the agent reads + Google Workspace for the agent, built to grow. Ships Gmail (read + draft), + Calendar (read), and Drive (read) today — gmail_list_unread, gmail_search, + gmail_get_thread, gmail_create_draft (DRAFT ONLY — never sends), + calendar_list_upcoming, calendar_event_detail, drive_search, drive_read — over a + service-agnostic OAuth/REST core (no SDK) so Docs and Sheets are additive + modules, not a rewrite. Pull-mode: the agent reads and drafts; a human reviews and sends. Needs a Google OAuth client + refresh token; mint it with the full scope set you intend to grow into (e.g. gmail.modify, calendar, drive, documents, spreadsheets). One-click connect: paste the OAuth diff --git a/tests/test_drive.py b/tests/test_drive.py new file mode 100644 index 0000000..55821c7 --- /dev/null +++ b/tests/test_drive.py @@ -0,0 +1,80 @@ +"""Drive service — search escaping, export-vs-media routing, binary refusal. No network.""" + +from __future__ import annotations + +import httpx +import pytest + +from google_plugin import auth, gdrive +from google_plugin.auth import Creds + +CREDS = Creds("cid", "csecret", "rtok") + +DOC = { + "id": "f-doc", + "name": "Plan", + "mimeType": "application/vnd.google-apps.document", + "modifiedTime": "2026-07-01T00:00:00Z", + "owners": [{"emailAddress": "josh@x.com"}], + "webViewLink": "http://drive/f-doc", +} +TXT = {"id": "f-txt", "name": "notes.txt", "mimeType": "text/plain", "size": "9"} +PNG = {"id": "f-png", "name": "logo.png", "mimeType": "image/png", "size": "512"} + + +def _handler(request: httpx.Request) -> httpx.Response: + path, params = request.url.path, dict(request.url.params) + if request.url.host == "oauth2.googleapis.com": + return httpx.Response(200, json={"access_token": "at", "expires_in": 3600}) + if path.endswith("/files"): + assert params["q"] == "fullText contains 'q3 plan \\'draft\\'' and trashed = false" + assert params["pageSize"] == "5" + return httpx.Response(200, json={"files": [DOC, TXT]}) + if path.endswith("/export"): + assert params["mimeType"] == "text/plain" + return httpx.Response(200, text="DOC TEXT " * 10) + if path.endswith("/f-doc"): + return httpx.Response(200, json=DOC) + if path.endswith("/f-txt"): + if params.get("alt") == "media": + return httpx.Response(200, text="RAW NOTES") + return httpx.Response(200, json=TXT) + if path.endswith("/f-png"): + return httpx.Response(200, json=PNG) + return httpx.Response(404, json={"error": {"message": f"not found: {path}"}}) + + +@pytest.fixture() +def client(): + auth._TOKEN_CACHE.clear() + c = httpx.Client(transport=httpx.MockTransport(_handler)) + yield c + c.close() + + +def test_search_escapes_query_and_summarizes(client): + files = gdrive.search(CREDS, "q3 plan 'draft'", 5, client=client) + assert [f["id"] for f in files] == ["f-doc", "f-txt"] + assert files[0]["owners"] == ["josh@x.com"] and files[0]["link"] == "http://drive/f-doc" + + +def test_read_exports_google_doc_as_text(client): + out = gdrive.read(CREDS, "f-doc", client=client) + assert out["content"].startswith("DOC TEXT") and out["truncated"] is False + assert out["name"] == "Plan" + + +def test_read_fetches_plain_text_via_media(client): + out = gdrive.read(CREDS, "f-txt", client=client) + assert out["content"] == "RAW NOTES" + + +def test_read_truncates_to_max_chars(client): + out = gdrive.read(CREDS, "f-doc", max_chars=4, client=client) + assert out["content"] == "DOC " and out["truncated"] is True + + +def test_read_refuses_binary_with_metadata(client): + out = gdrive.read(CREDS, "f-png", client=client) + assert "binary file (image/png)" in out["error"] + assert "content" not in out and out["name"] == "logo.png" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index b519b86..b44b39d 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -69,6 +69,8 @@ def test_register_wires_tools_and_routers(registry): "gmail_create_draft", "calendar_list_upcoming", "calendar_event_detail", + "drive_search", + "drive_read", } assert [p for _, p in registry.routers] == [None, "/api/plugins/google"] assert plugin._creds().configured()