From 0eb5d2cab4f43129ad6bfee0de469541a9b5c3fa Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Sat, 14 Feb 2026 15:12:01 -0700 Subject: [PATCH] Add Google Contacts integration and fix critical Windows issues - Add People API integration with contact management tools (create, list, get) - Fix OAuth flow: replace deprecated OOB with localhost callback - Fix Ctrl+C handling with proper signal handlers for graceful shutdown - Fix UTF-8 encoding in scheduled_tasks.py for Windows compatibility - Add users/ directory to gitignore to protect personal data Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 3 + bot_runner.py | 20 ++- google_tools/__init__.py | 5 +- google_tools/oauth_manager.py | 57 +++--- google_tools/people_client.py | 327 ++++++++++++++++++++++++++++++++++ scheduled_tasks.py | 4 +- tools.py | 193 ++++++++++++++++++-- 7 files changed, 557 insertions(+), 52 deletions(-) create mode 100644 google_tools/people_client.py diff --git a/.gitignore b/.gitignore index 22d6456..5ecf925 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,9 @@ memory_workspace/memory_index.db memory_workspace/users/*.md # User profiles (jordan.md, etc.) memory_workspace/vectors.usearch +# User profiles (personal info) +users/ + # Usage tracking usage_data.json diff --git a/bot_runner.py b/bot_runner.py index 6aa426a..0961991 100644 --- a/bot_runner.py +++ b/bot_runner.py @@ -14,6 +14,8 @@ Environment variables: import argparse import asyncio +import signal +import sys import traceback from dotenv import load_dotenv @@ -45,6 +47,7 @@ class BotRunner: self.runtime: AdapterRuntime = None self.agent: Agent = None self.scheduler: TaskScheduler = None + self.shutdown_event = asyncio.Event() def _load_adapter(self, platform: str) -> bool: """Load and register a single adapter. Returns True if loaded.""" @@ -127,6 +130,17 @@ class BotRunner: if not self.setup(): return + # Set up signal handlers + loop = asyncio.get_running_loop() + + def signal_handler(signum, frame): + print(f"\n\n[Shutdown] Received signal {signum}...") + loop.call_soon_threadsafe(self.shutdown_event.set) + + # Register signal handlers (works on both Windows and Unix) + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + print("\n" + "=" * 60) print("Starting bot...") print("=" * 60 + "\n") @@ -143,11 +157,9 @@ class BotRunner: print("Bot is running! Press Ctrl+C to stop.") print("=" * 60 + "\n") - while True: - await asyncio.sleep(1) + # Wait for shutdown signal + await self.shutdown_event.wait() - except KeyboardInterrupt: - print("\n\n[Shutdown] Received interrupt signal...") except Exception as e: print(f"\n[Error] {e}") traceback.print_exc() diff --git a/google_tools/__init__.py b/google_tools/__init__.py index 8de0898..9dd09d0 100644 --- a/google_tools/__init__.py +++ b/google_tools/__init__.py @@ -1,7 +1,8 @@ -"""Google Tools - Gmail and Calendar integration for ajarbot.""" +"""Google Tools - Gmail, Calendar, and Contacts integration for ajarbot.""" from .calendar_client import CalendarClient from .gmail_client import GmailClient from .oauth_manager import GoogleOAuthManager +from .people_client import PeopleClient -__all__ = ["GoogleOAuthManager", "GmailClient", "CalendarClient"] +__all__ = ["GoogleOAuthManager", "GmailClient", "CalendarClient", "PeopleClient"] diff --git a/google_tools/oauth_manager.py b/google_tools/oauth_manager.py index b7c53ac..d9f020a 100644 --- a/google_tools/oauth_manager.py +++ b/google_tools/oauth_manager.py @@ -1,4 +1,4 @@ -"""OAuth2 Manager for Google APIs (Gmail, Calendar).""" +"""OAuth2 Manager for Google APIs (Gmail, Calendar, Contacts).""" import json import os @@ -18,6 +18,7 @@ from google_auth_oauthlib.flow import InstalledAppFlow SCOPES = [ "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/contacts", ] # Token file location @@ -155,47 +156,43 @@ class GoogleOAuthManager: "client_secret": creds_config["client_secret"], "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", - "redirect_uris": ["http://localhost:8080"], + "redirect_uris": ["http://localhost:8081"], } } - if manual: - # Manual flow - user pastes code - flow = InstalledAppFlow.from_client_config( - client_config, SCOPES - ) - self.credentials = flow.run_console() - else: - # Automated flow with local server - flow = InstalledAppFlow.from_client_config( - client_config, SCOPES - ) - flow.redirect_uri = "http://localhost:8080" + # Both manual and automatic modes use local server + # (Google deprecated OOB flow in 2022) + flow = InstalledAppFlow.from_client_config(client_config, SCOPES) + flow.redirect_uri = "http://localhost:8081" - auth_url, _ = flow.authorization_url(prompt="consent") + auth_url, _ = flow.authorization_url(prompt="consent") - print("\nPlease visit this URL to authorize:") - print(f"\n{auth_url}\n") + print("\nPlease visit this URL to authorize:") + print(f"\n{auth_url}\n") - # Try to open browser automatically + # Auto-open browser unless manual mode + if not manual: try: webbrowser.open(auth_url) + print("Opening browser automatically...\n") except Exception: - pass + print("Could not open browser automatically. Please visit the URL above.\n") + else: + print("Please open the URL above in your browser manually.\n") - # Start local server to receive callback - server = HTTPServer(("localhost", 8080), OAuthCallbackHandler) - print("Waiting for authorization... (press Ctrl+C to cancel)\n") + # Start local server to receive callback + server = HTTPServer(("localhost", 8081), OAuthCallbackHandler) + print("Waiting for authorization... (press Ctrl+C to cancel)\n") - # Wait for callback - server.handle_request() + # Wait for callback + server.handle_request() - if OAuthCallbackHandler.authorization_code: - flow.fetch_token(code=OAuthCallbackHandler.authorization_code) - self.credentials = flow.credentials - else: - print("[OAuth] Authorization failed - no code received") - return False + if OAuthCallbackHandler.authorization_code: + flow.fetch_token(code=OAuthCallbackHandler.authorization_code) + self.credentials = flow.credentials + else: + print("[OAuth] Authorization failed - no code received") + return False # Save tokens self._save_credentials() diff --git a/google_tools/people_client.py b/google_tools/people_client.py new file mode 100644 index 0000000..0c8aaff --- /dev/null +++ b/google_tools/people_client.py @@ -0,0 +1,327 @@ +"""Google People API client for managing contacts.""" + +from typing import Any, Dict, List, Optional + +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +from .oauth_manager import GoogleOAuthManager + +PERSON_FIELDS = "names,emailAddresses,phoneNumbers,biographies" + + +class PeopleClient: + """Client for Google People API operations.""" + + def __init__(self, oauth_manager: GoogleOAuthManager): + """Initialize People client. + + Args: + oauth_manager: Initialized OAuth manager with valid credentials + """ + self.oauth_manager = oauth_manager + self.service = None + self._initialize_service() + + def _initialize_service(self) -> bool: + """Initialize People API service. + + Returns: + True if initialized successfully, False otherwise + """ + credentials = self.oauth_manager.get_credentials() + if not credentials: + return False + + try: + self.service = build("people", "v1", credentials=credentials) + return True + except Exception as e: + print(f"[People] Failed to initialize service: {e}") + return False + + def _ensure_service(self) -> bool: + """Ensure the service is initialized.""" + if not self.service: + return self._initialize_service() + return True + + def create_contact( + self, + given_name: str, + family_name: str = "", + email: str = "", + phone: Optional[str] = None, + notes: Optional[str] = None, + ) -> Dict: + """Create a new contact. + + Args: + given_name: First name + family_name: Last name + email: Email address + phone: Optional phone number + notes: Optional notes/biography + + Returns: + Dict with success status and contact details or error + """ + if not self._ensure_service(): + return { + "success": False, + "error": "Not authorized. Run: python bot_runner.py --setup-google", + } + + try: + body: Dict[str, Any] = { + "names": [{"givenName": given_name, "familyName": family_name}], + } + + if email: + body["emailAddresses"] = [{"value": email}] + + if phone: + body["phoneNumbers"] = [{"value": phone}] + + if notes: + body["biographies"] = [{"value": notes, "contentType": "TEXT_PLAIN"}] + + result = ( + self.service.people() + .createContact(body=body) + .execute() + ) + + return { + "success": True, + "resource_name": result.get("resourceName", ""), + "name": f"{given_name} {family_name}".strip(), + } + + except HttpError as e: + return {"success": False, "error": str(e)} + + def list_contacts( + self, + max_results: int = 100, + query: Optional[str] = None, + ) -> Dict: + """List or search contacts. + + Args: + max_results: Maximum number of contacts to return (max: 1000) + query: Optional search query string + + Returns: + Dict with success status and contacts or error + """ + if not self._ensure_service(): + return { + "success": False, + "error": "Not authorized. Run: python bot_runner.py --setup-google", + } + + try: + max_results = min(max_results, 1000) + + if query: + # Use searchContacts for query-based search + result = ( + self.service.people() + .searchContacts( + query=query, + readMask="names,emailAddresses,phoneNumbers,biographies", + pageSize=min(max_results, 30), # searchContacts max is 30 + ) + .execute() + ) + people = [r["person"] for r in result.get("results", [])] + else: + # Use connections.list for full listing + result = ( + self.service.people() + .connections() + .list( + resourceName="people/me", + pageSize=max_results, + personFields=PERSON_FIELDS, + sortOrder="LAST_NAME_ASCENDING", + ) + .execute() + ) + people = result.get("connections", []) + + contacts = [self._format_contact(p) for p in people] + + return { + "success": True, + "contacts": contacts, + "count": len(contacts), + "summary": self._format_contacts_summary(contacts), + } + + except HttpError as e: + return {"success": False, "error": str(e)} + + def get_contact(self, resource_name: str) -> Dict: + """Get a single contact by resource name. + + Args: + resource_name: Contact resource name (e.g., "people/c1234567890") + + Returns: + Dict with success status and contact details or error + """ + if not self._ensure_service(): + return { + "success": False, + "error": "Not authorized. Run: python bot_runner.py --setup-google", + } + + try: + result = ( + self.service.people() + .get(resourceName=resource_name, personFields=PERSON_FIELDS) + .execute() + ) + + return { + "success": True, + "contact": self._format_contact(result), + } + + except HttpError as e: + return {"success": False, "error": str(e)} + + def update_contact(self, resource_name: str, updates: Dict) -> Dict: + """Update an existing contact. + + Args: + resource_name: Contact resource name (e.g., "people/c1234567890") + updates: Dict with fields to update (given_name, family_name, email, phone, notes) + + Returns: + Dict with success status or error + """ + if not self._ensure_service(): + return { + "success": False, + "error": "Not authorized. Run: python bot_runner.py --setup-google", + } + + try: + # Get current contact to obtain etag + current = ( + self.service.people() + .get(resourceName=resource_name, personFields=PERSON_FIELDS) + .execute() + ) + + body: Dict[str, Any] = {"etag": current["etag"]} + update_fields = [] + + if "given_name" in updates or "family_name" in updates: + names = current.get("names", [{}]) + name = names[0] if names else {} + body["names"] = [{ + "givenName": updates.get("given_name", name.get("givenName", "")), + "familyName": updates.get("family_name", name.get("familyName", "")), + }] + update_fields.append("names") + + if "email" in updates: + body["emailAddresses"] = [{"value": updates["email"]}] + update_fields.append("emailAddresses") + + if "phone" in updates: + body["phoneNumbers"] = [{"value": updates["phone"]}] + update_fields.append("phoneNumbers") + + if "notes" in updates: + body["biographies"] = [{"value": updates["notes"], "contentType": "TEXT_PLAIN"}] + update_fields.append("biographies") + + if not update_fields: + return {"success": False, "error": "No valid fields to update"} + + result = ( + self.service.people() + .updateContact( + resourceName=resource_name, + body=body, + updatePersonFields=",".join(update_fields), + ) + .execute() + ) + + return { + "success": True, + "resource_name": result.get("resourceName", resource_name), + "updated_fields": update_fields, + } + + except HttpError as e: + return {"success": False, "error": str(e)} + + def delete_contact(self, resource_name: str) -> Dict: + """Delete a contact. + + Args: + resource_name: Contact resource name (e.g., "people/c1234567890") + + Returns: + Dict with success status or error + """ + if not self._ensure_service(): + return { + "success": False, + "error": "Not authorized. Run: python bot_runner.py --setup-google", + } + + try: + self.service.people().deleteContact( + resourceName=resource_name, + ).execute() + + return {"success": True, "deleted": resource_name} + + except HttpError as e: + return {"success": False, "error": str(e)} + + def _format_contact(self, person: Dict) -> Dict: + """Format a person resource into a simple contact dict.""" + names = person.get("names", []) + emails = person.get("emailAddresses", []) + phones = person.get("phoneNumbers", []) + bios = person.get("biographies", []) + + name = names[0] if names else {} + + return { + "resource_name": person.get("resourceName", ""), + "given_name": name.get("givenName", ""), + "family_name": name.get("familyName", ""), + "display_name": name.get("displayName", ""), + "email": emails[0].get("value", "") if emails else "", + "phone": phones[0].get("value", "") if phones else "", + "notes": bios[0].get("value", "") if bios else "", + } + + def _format_contacts_summary(self, contacts: List[Dict]) -> str: + """Format contacts into a readable summary.""" + if not contacts: + return "No contacts found." + + lines = [] + for i, c in enumerate(contacts, 1): + name = c.get("display_name") or f"{c.get('given_name', '')} {c.get('family_name', '')}".strip() + parts = [f"{i}. {name or '(no name)'}"] + + if c.get("email"): + parts.append(f" Email: {c['email']}") + if c.get("phone"): + parts.append(f" Phone: {c['phone']}") + + lines.append("\n".join(parts)) + + return "\n".join(lines) diff --git a/scheduled_tasks.py b/scheduled_tasks.py index 0c941df..a2448b5 100644 --- a/scheduled_tasks.py +++ b/scheduled_tasks.py @@ -93,7 +93,7 @@ class TaskScheduler: # Track file modification time self._last_mtime = self.config_file.stat().st_mtime - with open(self.config_file) as f: + with open(self.config_file, encoding="utf-8") as f: config = yaml.safe_load(f) or {} self.tasks.clear() # Clear existing tasks before reload @@ -155,7 +155,7 @@ class TaskScheduler: } self.config_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.config_file, "w") as f: + with open(self.config_file, "w", encoding="utf-8") as f: yaml.dump( default_config, f, default_flow_style=False, diff --git a/tools.py b/tools.py index e2a3885..7230310 100644 --- a/tools.py +++ b/tools.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional # Google tools (lazy loaded when needed) _gmail_client: Optional[Any] = None _calendar_client: Optional[Any] = None +_people_client: Optional[Any] = None # Tool definitions in Anthropic's tool use format @@ -255,6 +256,71 @@ TOOL_DEFINITIONS = [ "required": ["query"], }, }, + # Contacts tools + { + "name": "create_contact", + "description": "Create a new Google contact. Requires prior OAuth setup (--setup-google).", + "input_schema": { + "type": "object", + "properties": { + "given_name": { + "type": "string", + "description": "Contact's first name", + }, + "family_name": { + "type": "string", + "description": "Contact's last name", + "default": "", + }, + "email": { + "type": "string", + "description": "Contact's email address", + "default": "", + }, + "phone": { + "type": "string", + "description": "Contact's phone number", + }, + "notes": { + "type": "string", + "description": "Optional notes about the contact", + }, + }, + "required": ["given_name"], + }, + }, + { + "name": "list_contacts", + "description": "List or search Google contacts. Without a query, lists all contacts sorted by last name.", + "input_schema": { + "type": "object", + "properties": { + "max_results": { + "type": "integer", + "description": "Maximum number of contacts to return (default: 100, max: 1000)", + "default": 100, + }, + "query": { + "type": "string", + "description": "Optional search query to filter contacts by name, email, etc.", + }, + }, + }, + }, + { + "name": "get_contact", + "description": "Get full details of a specific Google contact by resource name.", + "input_schema": { + "type": "object", + "properties": { + "resource_name": { + "type": "string", + "description": "Contact resource name (e.g., 'people/c1234567890')", + }, + }, + "required": ["resource_name"], + }, + }, ] @@ -320,6 +386,24 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any]) -> str: query=tool_input["query"], calendar_id=tool_input.get("calendar_id", "primary"), ) + # Contacts tools + elif tool_name == "create_contact": + return _create_contact( + given_name=tool_input["given_name"], + family_name=tool_input.get("family_name", ""), + email=tool_input.get("email", ""), + phone=tool_input.get("phone"), + notes=tool_input.get("notes"), + ) + elif tool_name == "list_contacts": + return _list_contacts( + max_results=tool_input.get("max_results", 100), + query=tool_input.get("query"), + ) + elif tool_name == "get_contact": + return _get_contact( + resource_name=tool_input["resource_name"], + ) else: return f"Error: Unknown tool '{tool_name}'" except Exception as e: @@ -442,36 +526,38 @@ def _run_command(command: str, working_dir: str) -> str: # Google Tools Handlers -def _initialize_google_clients() -> tuple[Optional[Any], Optional[Any]]: +def _initialize_google_clients() -> tuple[Optional[Any], Optional[Any], Optional[Any]]: """Lazy initialization of Google clients. Returns: - Tuple of (gmail_client, calendar_client) or (None, None) if not authorized + Tuple of (gmail_client, calendar_client, people_client) or (None, None, None) if not authorized """ - global _gmail_client, _calendar_client + global _gmail_client, _calendar_client, _people_client - if _gmail_client is not None and _calendar_client is not None: - return _gmail_client, _calendar_client + if _gmail_client is not None and _calendar_client is not None and _people_client is not None: + return _gmail_client, _calendar_client, _people_client try: from google_tools.oauth_manager import GoogleOAuthManager from google_tools.gmail_client import GmailClient from google_tools.calendar_client import CalendarClient + from google_tools.people_client import PeopleClient oauth_manager = GoogleOAuthManager() credentials = oauth_manager.get_credentials() if not credentials: - return None, None + return None, None, None _gmail_client = GmailClient(oauth_manager) _calendar_client = CalendarClient(oauth_manager) + _people_client = PeopleClient(oauth_manager) - return _gmail_client, _calendar_client + return _gmail_client, _calendar_client, _people_client except Exception as e: print(f"[Google Tools] Failed to initialize: {e}") - return None, None + return None, None, None def _send_email( @@ -482,7 +568,7 @@ def _send_email( reply_to_message_id: Optional[str] = None, ) -> str: """Send an email via Gmail API.""" - gmail_client, _ = _initialize_google_clients() + gmail_client, _, _ = _initialize_google_clients() if not gmail_client: return "Error: Google not authorized. Run: python bot_runner.py --setup-google" @@ -508,7 +594,7 @@ def _read_emails( include_body: bool = False, ) -> str: """Search and read emails from Gmail.""" - gmail_client, _ = _initialize_google_clients() + gmail_client, _, _ = _initialize_google_clients() if not gmail_client: return "Error: Google not authorized. Run: python bot_runner.py --setup-google" @@ -530,7 +616,7 @@ def _read_emails( def _get_email(message_id: str, format_type: str = "text") -> str: """Get full content of a specific email.""" - gmail_client, _ = _initialize_google_clients() + gmail_client, _, _ = _initialize_google_clients() if not gmail_client: return "Error: Google not authorized. Run: python bot_runner.py --setup-google" @@ -566,7 +652,7 @@ def _read_calendar( max_results: int = 20, ) -> str: """Read upcoming calendar events.""" - _, calendar_client = _initialize_google_clients() + _, calendar_client, _ = _initialize_google_clients() if not calendar_client: return "Error: Google not authorized. Run: python bot_runner.py --setup-google" @@ -595,7 +681,7 @@ def _create_calendar_event( calendar_id: str = "primary", ) -> str: """Create a new calendar event.""" - _, calendar_client = _initialize_google_clients() + _, calendar_client, _ = _initialize_google_clients() if not calendar_client: return "Error: Google not authorized. Run: python bot_runner.py --setup-google" @@ -634,7 +720,7 @@ def _create_calendar_event( def _search_calendar(query: str, calendar_id: str = "primary") -> str: """Search calendar events by text query.""" - _, calendar_client = _initialize_google_clients() + _, calendar_client, _ = _initialize_google_clients() if not calendar_client: return "Error: Google not authorized. Run: python bot_runner.py --setup-google" @@ -648,3 +734,82 @@ def _search_calendar(query: str, calendar_id: str = "primary") -> str: return f'Search results for "{query}":\n\n{summary}' else: return f"Error searching calendar: {result.get('error', 'Unknown error')}" + + +# Contacts Tools Handlers + + +def _create_contact( + given_name: str, + family_name: str = "", + email: str = "", + phone: Optional[str] = None, + notes: Optional[str] = None, +) -> str: + """Create a new Google contact.""" + _, _, people_client = _initialize_google_clients() + + if not people_client: + return "Error: Google not authorized. Run: python bot_runner.py --setup-google" + + result = people_client.create_contact( + given_name=given_name, + family_name=family_name, + email=email, + phone=phone, + notes=notes, + ) + + if result["success"]: + name = result.get("name", given_name) + resource = result.get("resource_name", "") + return f"Contact created: {name}\nResource: {resource}" + else: + return f"Error creating contact: {result.get('error', 'Unknown error')}" + + +def _list_contacts( + max_results: int = 100, + query: Optional[str] = None, +) -> str: + """List or search Google contacts.""" + _, _, people_client = _initialize_google_clients() + + if not people_client: + return "Error: Google not authorized. Run: python bot_runner.py --setup-google" + + result = people_client.list_contacts(max_results=max_results, query=query) + + if result["success"]: + summary = result.get("summary", "No contacts found.") + if len(summary) > _MAX_TOOL_OUTPUT: + summary = summary[:_MAX_TOOL_OUTPUT] + "\n... (results truncated)" + return f"Contacts ({result.get('count', 0)} found):\n\n{summary}" + else: + return f"Error listing contacts: {result.get('error', 'Unknown error')}" + + +def _get_contact(resource_name: str) -> str: + """Get full details of a specific contact.""" + _, _, people_client = _initialize_google_clients() + + if not people_client: + return "Error: Google not authorized. Run: python bot_runner.py --setup-google" + + result = people_client.get_contact(resource_name=resource_name) + + if result["success"]: + c = result.get("contact", {}) + output = [] + name = c.get("display_name") or f"{c.get('given_name', '')} {c.get('family_name', '')}".strip() + output.append(f"Name: {name or '(no name)'}") + if c.get("email"): + output.append(f"Email: {c['email']}") + if c.get("phone"): + output.append(f"Phone: {c['phone']}") + if c.get("notes"): + output.append(f"Notes: {c['notes']}") + output.append(f"Resource: {c.get('resource_name', resource_name)}") + return "\n".join(output) + else: + return f"Error getting contact: {result.get('error', 'Unknown error')}"