From 03cf95167484a3451687935fe9c9a6cc095e3c35 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Fri, 3 Jul 2026 11:33:50 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v0.5.0=20=E2=80=94=20contacts=5Fsearch,?= =?UTF-8?q?=20docs=5Fcreate,=20calendar=5Fcreate=5Fevent=20(no=20attendees?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gpeople.py: People API search across saved contacts AND auto-collected 'other contacts', merged + deduped; warmup request once per token (Google-recommended) - gdocs.py: CREATE-only Docs — a new doc is private until shared (the Docs analog of draft-only email); existing docs are never edited - gcal.create_event: own-calendar events, timed or all-day; the tool surface takes NO attendees param at all — inviting people emails them, crossing the never-send line (pinned by a schema test) - connect DEFAULT_SCOPES += contacts.readonly, contacts.other.readonly, documents; manifest network += people/docs.googleapis.com; pre-v0.5.0 tokens get readable insufficient-scope errors until a Reconnect Co-Authored-By: Claude Fable 5 --- README.md | 7 +- __init__.py | 61 ++++++++++++++++ gcal.py | 26 +++++++ gdocs.py | 23 ++++++ gpeople.py | 53 ++++++++++++++ oauth.py | 8 ++- protoagent.plugin.yaml | 10 +-- pyproject.toml | 2 +- tests/test_plugin.py | 3 + tests/test_v050.py | 157 +++++++++++++++++++++++++++++++++++++++++ 10 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 gdocs.py create mode 100644 gpeople.py create mode 100644 tests/test_v050.py diff --git a/README.md b/README.md index 3f20c38..35b4873 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)**, **Calendar (read)**, and **Drive (read)** today over a service-agnostic OAuth/REST core, so Docs / Sheets / further services are additive modules, not a rewrite. +**Google Workspace** for a [protoAgent](https://github.com/protoLabsAI/protoAgent) agent — built to grow. Ships **Gmail (read + draft + hygiene)**, **Calendar (read + free/busy + own-calendar events)**, **Contacts (search)**, **Docs (create)**, and **Drive (read)** over a service-agnostic OAuth/REST core, so further services are additive modules, not a rewrite. Pull-mode posture: the agent lists, searches, reads, **drafts**, and does **mailbox hygiene** (mark read, label, archive) — it never sends, deletes, or auto-replies. A human reviews drafts in the Drafts folder and sends them. @@ -13,6 +13,9 @@ Pull-mode posture: the agent lists, searches, reads, **drafts**, and does **mail - `gmail_list_drafts(max)` · `gmail_update_draft(draft_id, body, …)` — revise drafts; **still never sends**. - `calendar_list_upcoming(days, calendar_id)` · `calendar_event_detail(event_id, calendar_id)` — read. - `calendar_availability(days)` — free/busy blocks. · `calendar_search(query, days_back, days_ahead)` — text search. +- `calendar_create_event(title, start, end, …)` — own calendar only; **takes no attendees** (inviting emails people). +- `contacts_search(query)` — names → email addresses (saved + auto-collected contacts). Read-only. +- `docs_create(title, text)` — a NEW private Google Doc; existing docs are never edited. - `drive_search(query, max)` · `drive_read(file_id, max_chars)` — read; Docs export as text, Sheets as CSV, Slides as text. ## Architecture @@ -27,7 +30,7 @@ One-time Google Cloud setup (5 minutes): 3. **Credentials ▸ Create credentials ▸ OAuth client ID ▸ Web application**, authorized redirect URIs: `http://localhost:7870/plugins/google/oauth/callback` (add `:7871` for the dev instance; the URI must exactly match the origin you open the console on). 4. Paste the client ID + secret into the plugin settings. -Default scopes requested: `gmail.modify`, `calendar`, `drive.readonly` (override via the `oauth_scopes` setting — request the set you intend to grow into so adding a service needs no re-consent). +Default scopes requested: `gmail.modify`, `calendar`, `drive.readonly`, `contacts.readonly`, `contacts.other.readonly`, `documents` (override via the `oauth_scopes` setting). Tokens minted before v0.5.0 lack the contacts/docs scopes — hit **Reconnect** once to widen; those tools return a readable insufficient-scope error until then. Manual fallback (headless / no browser): mint a refresh token yourself and set `google.refresh_token` (or `GOOGLE_REFRESH_TOKEN`); env fallbacks `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` work too. diff --git a/__init__.py b/__init__.py index e20bd7b..8088630 100644 --- a/__init__.py +++ b/__init__.py @@ -331,6 +331,66 @@ def calendar_event_detail(event_id: str, calendar_id: str = "primary") -> str: return out if isinstance(out, str) else json.dumps(out, indent=2) +@tool +def calendar_create_event(title: str, start: str, end: str, description: str = "", + location: str = "", timezone: str = "", calendar_id: str = "primary") -> str: + """Create an event on the user's OWN calendar. Cannot invite attendees — that + would email people, and this plugin never sends. Reversible (delete in Calendar). + + Args: + title: event title. + start: ISO datetime (e.g. 2026-07-04T09:00:00-07:00) or YYYY-MM-DD for all-day. + end: same format as start. + description: optional notes. + location: optional location. + timezone: IANA tz (e.g. America/Los_Angeles) if start/end lack an offset. + calendar_id: calendar id (default "primary"). + """ + from . import gcal + + out = _run(gcal.create_event, _creds(), title, start, end, description, location, timezone, calendar_id) + if isinstance(out, str): + return out + return f"Event created: \"{out['title']}\" {out['start']} → {out['end']} — {out['link']} (no attendees invited)." + + +# ── Contacts (read-only) ────────────────────────────────────────────────────── + +@tool +def contacts_search(query: str, max: int = 10) -> str: + """Find people (name, email addresses, org) in the user's Google contacts — + including auto-collected past correspondents. Read-only. Use before drafting + mail when you only know a person's name. + + Args: + query: a name, email fragment, or company. + max: max people (default 10, cap 30). + """ + from . import gpeople + + out = _run(gpeople.search, _creds(), query, max) + return out if isinstance(out, str) else json.dumps({"query": query, "count": len(out), "people": out}, indent=2) + + +# ── Docs (create-only) ──────────────────────────────────────────────────────── + +@tool +def docs_create(title: str, text: str = "") -> str: + """Create a NEW Google Doc in the user's Drive with optional initial text. + Private until the user shares it; existing docs are never edited. + + Args: + title: document title. + text: initial plain-text content (optional). + """ + from . import gdocs + + out = _run(gdocs.create, _creds(), title, text) + if isinstance(out, str): + return out + return f"Doc created: \"{out['title']}\" — {out['link']}" + + # ── Drive (read-only) ───────────────────────────────────────────────────────── @tool @@ -367,6 +427,7 @@ def drive_read(file_id: str, max_chars: int = 20000) -> str: gmail_list_unread, gmail_search, gmail_get_thread, gmail_create_draft, gmail_mark_read, gmail_label, gmail_get_attachment, gmail_list_drafts, gmail_update_draft, calendar_list_upcoming, calendar_event_detail, calendar_availability, calendar_search, + calendar_create_event, contacts_search, docs_create, drive_search, drive_read, ] diff --git a/gcal.py b/gcal.py index 6d1e316..1b157b6 100644 --- a/gcal.py +++ b/gcal.py @@ -68,6 +68,32 @@ def search_events(creds: Creds, query: str, days_back: int = 30, days_ahead: int return [_summary(e) for e in (data.get("items") or [])] +def create_event(creds: Creds, title: str, start: str, end: str, description: str = "", + location: str = "", timezone_name: str = "", calendar_id: str = "primary", + *, client=None) -> dict: + """Create an event on the user's OWN calendar. Deliberately takes NO attendees — + inviting people emails them, which crosses the plugin's never-send line. + + ``start``/``end``: ISO datetimes (offset or ``timezone_name`` required) or bare + YYYY-MM-DD dates for all-day events. + """ + def _when(value: str) -> dict: + if "T" not in value: + return {"date": value} + out = {"dateTime": value} + if timezone_name: + out["timeZone"] = timezone_name + return out + + body: dict = {"summary": title, "start": _when(start), "end": _when(end)} + if description: + body["description"] = description + if location: + body["location"] = location + e = request(creds, "POST", f"{BASE}/{calendar_id}/events", json=body, client=client) + return _summary(e) + + def event_detail(creds: Creds, event_id: str, calendar_id: str = "primary", *, client=None) -> dict: e = request(creds, "GET", f"{BASE}/{calendar_id}/events/{event_id}", client=client) s = _summary(e) diff --git a/gdocs.py b/gdocs.py new file mode 100644 index 0000000..7f8e4e1 --- /dev/null +++ b/gdocs.py @@ -0,0 +1,23 @@ +"""Docs service — CREATE-only. Built on the shared auth core. + +Deliberately narrow: creating a NEW doc in the user's own Drive is private until +they share it (the Docs analog of draft-only email). Editing existing docs — +which may be shared with others — stays out of scope. Scope: documents. +""" + +from __future__ import annotations + +from .auth import Creds, request + +BASE = "https://docs.googleapis.com/v1/documents" + + +def create(creds: Creds, title: str, text: str = "", *, client=None) -> dict: + doc = request(creds, "POST", BASE, json={"title": title}, client=client) + doc_id = doc.get("documentId", "") + if text: + request(creds, "POST", f"{BASE}/{doc_id}:batchUpdate", json={ + "requests": [{"insertText": {"location": {"index": 1}, "text": text}}], + }, client=client) + return {"documentId": doc_id, "title": doc.get("title", title), + "link": f"https://docs.google.com/document/d/{doc_id}/edit"} diff --git a/gpeople.py b/gpeople.py new file mode 100644 index 0000000..ee99c1e --- /dev/null +++ b/gpeople.py @@ -0,0 +1,53 @@ +"""Contacts service (People API) — read-only search. Built on the shared auth core. + +Searches BOTH saved contacts and "other contacts" (addresses Google auto-collects +from past correspondence — usually where most of them live), merged and deduped. +Scopes: contacts.readonly + contacts.other.readonly. +""" + +from __future__ import annotations + +from .auth import Creds, request + +BASE = "https://people.googleapis.com/v1" + +# (endpoint, readMask) — otherContacts supports a narrower mask. +_SOURCES = ( + ("people:searchContacts", "names,emailAddresses,organizations"), + ("otherContacts:search", "names,emailAddresses"), +) + +# The People search cache wants a warmup request (empty query) before real +# queries return fresh results — Google's own recommendation. Once per token. +_WARMED: set[str] = set() + + +def _person(p: dict) -> dict: + names = p.get("names") or [{}] + orgs = p.get("organizations") or [{}] + return { + "name": names[0].get("displayName", ""), + "emails": [e.get("value", "") for e in (p.get("emailAddresses") or []) if e.get("value")], + "org": orgs[0].get("name", ""), + } + + +def search(creds: Creds, query: str, max_results: int = 10, *, client=None) -> list[dict]: + if creds.refresh_token not in _WARMED: + for path, mask in _SOURCES: + request(creds, "GET", f"{BASE}/{path}", + params={"query": "", "readMask": mask, "pageSize": 1}, client=client) + _WARMED.add(creds.refresh_token) + out, seen = [], set() + for path, mask in _SOURCES: + data = request(creds, "GET", f"{BASE}/{path}", + params={"query": query, "readMask": mask, + "pageSize": min(int(max_results), 30)}, client=client) + for r in data.get("results") or []: + person = _person(r.get("person") or {}) + key = (person["name"].lower(), tuple(sorted(person["emails"]))) + if key in seen or not (person["emails"] or person["name"]): + continue + seen.add(key) + out.append(person) + return out[: int(max_results)] diff --git a/oauth.py b/oauth.py index 257a0f0..ff0934a 100644 --- a/oauth.py +++ b/oauth.py @@ -23,12 +23,16 @@ AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" -# The "grow into" scope set (see README): Gmail read+draft, Calendar, Drive read. -# Override with the `oauth_scopes` config key (space-separated) before connecting. +# The "grow into" scope set (see README): Gmail read+draft+hygiene, Calendar rw, +# Drive read, Contacts read, Docs create. Override with the `oauth_scopes` config +# key (space-separated) before connecting. DEFAULT_SCOPES = ( "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", + "https://www.googleapis.com/auth/documents", ) _STATE_TTL_S = 600 diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index a86f2d9..3b25c0f 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,12 +1,12 @@ id: google name: Google Workspace -version: 0.4.0 +version: 0.5.0 description: >- Google Workspace for the agent, built to grow. Gmail (read, draft-only — never sends — plus mailbox hygiene: mark read, label, archive, attachments, draft - revision), Calendar (read + free/busy + 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 + revision), Calendar (read + free/busy + search + own-calendar events, no + attendee invites), Contacts (search), Docs (create-only), Drive (read) — over a + service-agnostic OAuth/REST core (no SDK). 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 @@ -15,7 +15,7 @@ description: >- enabled: false min_protoagent_version: "0.71.0" # manifest public_paths (#1345) capabilities: - network: [oauth2.googleapis.com, gmail.googleapis.com, www.googleapis.com] + network: [oauth2.googleapis.com, gmail.googleapis.com, www.googleapis.com, people.googleapis.com, docs.googleapis.com] filesystem: workspace (attachment downloads) # Console view (ADR 0026) — connection status + unread mail + upcoming events. diff --git a/pyproject.toml b/pyproject.toml index 71c8434..4560504 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "google-plugin" -version = "0.4.0" +version = "0.5.0" description = "Google Workspace plugin for protoAgent — Gmail (read + draft) + Calendar (read) over a service-agnostic OAuth/REST core, built to grow." requires-python = ">=3.11" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 4f40b3b..5d695a4 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -76,6 +76,9 @@ def test_register_wires_tools_and_routers(registry): "calendar_event_detail", "calendar_availability", "calendar_search", + "calendar_create_event", + "contacts_search", + "docs_create", "drive_search", "drive_read", } diff --git a/tests/test_v050.py b/tests/test_v050.py new file mode 100644 index 0000000..9867905 --- /dev/null +++ b/tests/test_v050.py @@ -0,0 +1,157 @@ +"""v0.5.0 batch — contacts search, docs create, own-calendar event creation.""" + +from __future__ import annotations + +import json + +import google_plugin as plugin +import httpx + +from google_plugin import auth, gcal, gdocs, gpeople + +CREDS = auth.Creds("cid", "csecret", "rtok") + + +def _client(handler): + auth._TOKEN_CACHE.clear() + + def h(request: httpx.Request) -> httpx.Response: + if request.url.host == "oauth2.googleapis.com": + return httpx.Response(200, json={"access_token": "at", "expires_in": 3600}) + return handler(request) + + return httpx.Client(transport=httpx.MockTransport(h)) + + +# ── Contacts ────────────────────────────────────────────────────────────────── + + +def test_contacts_search_merges_sources_dedupes_and_warms_once(): + calls = [] + + def handler(request: httpx.Request) -> httpx.Response: + q = dict(request.url.params).get("query", "") + calls.append((request.url.path, q)) + person = { + "names": [{"displayName": "Mike Roe"}], + "emailAddresses": [{"value": "mike@x.com"}], + "organizations": [{"name": "Acme"}], + } + return httpx.Response(200, json={"results": [{"person": person}]} if q else {}) + + gpeople._WARMED.clear() + with _client(handler) as c: + people = gpeople.search(CREDS, "mike", client=c) + gpeople.search(CREDS, "mike", client=c) # second call: no re-warmup + assert people == [{"name": "Mike Roe", "emails": ["mike@x.com"], "org": "Acme"}] # deduped across sources + warmups = [c for c in calls if c[1] == ""] + assert len(warmups) == 2 # one per source, once per token + assert {p for p, _ in calls} == {"/v1/people:searchContacts", "/v1/otherContacts:search"} + + +# ── Docs ────────────────────────────────────────────────────────────────────── + + +def test_docs_create_inserts_text_and_links(): + calls = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append((request.url.path, json.loads(request.read() or b"{}"))) + if request.url.path.endswith(":batchUpdate"): + return httpx.Response(200, json={}) + return httpx.Response(200, json={"documentId": "doc-1", "title": "Notes"}) + + with _client(handler) as c: + out = gdocs.create(CREDS, "Notes", "hello world", client=c) + assert out == {"documentId": "doc-1", "title": "Notes", "link": "https://docs.google.com/document/d/doc-1/edit"} + assert calls[1][0] == "/v1/documents/doc-1:batchUpdate" + assert calls[1][1]["requests"][0]["insertText"] == {"location": {"index": 1}, "text": "hello world"} + + +def test_docs_create_empty_text_skips_batch_update(): + calls = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + return httpx.Response(200, json={"documentId": "doc-2", "title": "Empty"}) + + with _client(handler) as c: + gdocs.create(CREDS, "Empty", client=c) + assert calls == ["/v1/documents"] + + +# ── Calendar event creation ─────────────────────────────────────────────────── + + +def test_create_event_timed_and_all_day(): + seen = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(json.loads(request.read())) + return httpx.Response( + 200, + json={ + "id": "e1", + "summary": seen[-1]["summary"], + "start": seen[-1]["start"], + "end": seen[-1]["end"], + "htmlLink": "http://cal/e1", + }, + ) + + with _client(handler) as c: + gcal.create_event( + CREDS, "Focus", "2026-07-04T09:00:00", "2026-07-04T10:00:00", timezone_name="America/Los_Angeles", client=c + ) + gcal.create_event(CREDS, "Trip", "2026-07-10", "2026-07-12", description="pack", client=c) + assert seen[0]["start"] == {"dateTime": "2026-07-04T09:00:00", "timeZone": "America/Los_Angeles"} + assert seen[1]["start"] == {"date": "2026-07-10"} and seen[1]["description"] == "pack" + assert all("attendees" not in body for body in seen) + + +def test_create_event_tool_has_no_attendees_surface(): + schema = plugin.calendar_create_event.args + assert "attendees" not in schema and "attendee" not in json.dumps(schema).lower() + assert ( + "never sends" in plugin.calendar_create_event.description.lower() + or "cannot invite" in plugin.calendar_create_event.description.lower() + ) + + +def test_create_event_tool_reports_link(monkeypatch): + monkeypatch.setattr(plugin, "_CREDS", auth.Creds("c", "s", "r")) + monkeypatch.setattr( + gcal, + "create_event", + lambda *a, **kw: { + "title": "Focus", + "start": "2026-07-04T09:00:00", + "end": "2026-07-04T10:00:00", + "link": "http://cal/e1", + "id": "e1", + "attendees": [], + "location": "", + }, + ) + out = plugin.calendar_create_event.invoke( + {"title": "Focus", "start": "2026-07-04T09:00:00", "end": "2026-07-04T10:00:00"} + ) + assert "http://cal/e1" in out and "no attendees invited" in out + + +# ── Scopes ──────────────────────────────────────────────────────────────────── + + +def test_default_scopes_cover_all_services(): + from google_plugin import oauth + + scopes = " ".join(oauth.DEFAULT_SCOPES) + for needle in ( + "gmail.modify", + "auth/calendar", + "drive.readonly", + "contacts.readonly", + "contacts.other.readonly", + "auth/documents", + ): + assert needle in scopes, needle