Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# 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.

## Tools
- `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.
Expand Down
32 changes: 32 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]


Expand Down
10 changes: 7 additions & 3 deletions auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
74 changes: 74 additions & 0 deletions gdrive.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 6 additions & 5 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions tests/test_drive.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading