diff --git a/docs/content/docs/(features)/google-calendar.mdx b/docs/content/docs/(features)/google-calendar.mdx new file mode 100644 index 000000000..1c9c70af5 --- /dev/null +++ b/docs/content/docs/(features)/google-calendar.mdx @@ -0,0 +1,154 @@ +--- +title: Google Calendar +description: Native Google Calendar integration for listing, creating, and managing events. +--- + +# Google Calendar + +Native Google Calendar integration that gives workers and branches access to calendar tools — list events, create meetings, find free time, and more. No MCP server needed; credentials are configured directly in Spacebot. + +## Overview + +When configured, Spacebot registers calendar tools on the appropriate process types: + +| Tool | Branches | Workers | +|------|----------|---------| +| `google_calendar_list_events` | read-only | read-only | +| `google_calendar_list_calendars` | read-only | read-only | +| `google_calendar_find_free_time` | read-only | read-only | +| `google_calendar_create_event` | — | write | +| `google_calendar_update_event` | — | write | +| `google_calendar_delete_event` | — | write | +| `google_calendar_respond_event` | — | write | + +Branches get read-only tools. Workers get both read and write tools. This follows the delegation model — when you ask the channel to check your schedule, it spawns a branch to do the lookup, and only workers can modify calendar data. + +## Prerequisites + +1. A Google Cloud project with the **Google Calendar API** enabled +2. An OAuth 2.0 Client ID (type: **Web application** or **Desktop**) +3. A refresh token obtained via the OAuth consent flow + +## Step 1: Create OAuth Credentials + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Navigate to **APIs & Services → Library** +4. Search for **Google Calendar API** and enable it +5. Go to **APIs & Services → Credentials** +6. Click **Create Credentials → OAuth client ID** +7. Choose **Web application** as the application type +8. Under **Authorized redirect URIs**, add: `https://developers.google.com/oauthplayground` +9. Click **Create** and note the **Client ID** and **Client Secret** + + +If your project's OAuth consent screen is in "Testing" mode, add your Google account as a test user under **APIs & Services → OAuth consent screen → Test users**. + + +## Step 2: Get a Refresh Token + +### Option A: OAuth Playground + +1. Open [Google OAuth Playground](https://developers.google.com/oauthplayground) +2. Click the gear icon (⚙️) in the top right +3. Check **Use your own OAuth credentials** +4. Enter your Client ID and Client Secret +5. In the left panel, find **Google Calendar API v3** and select `https://www.googleapis.com/auth/calendar` +6. Click **Authorize APIs** and complete the consent flow +7. Click **Exchange authorization code for tokens** +8. Copy the **Refresh token** + +### Option B: Manual curl + +```bash +# 1. Open this URL in your browser (replace CLIENT_ID): +https://accounts.google.com/o/oauth2/v2/auth?\ +client_id=YOUR_CLIENT_ID&\ +redirect_uri=urn:ietf:wg:oauth:2.0:oob&\ +response_type=code&\ +scope=https://www.googleapis.com/auth/calendar&\ +access_type=offline&\ +prompt=consent + +# 2. After consent, you'll get an authorization code. Exchange it: +curl -s -X POST https://oauth2.googleapis.com/token \ + -d client_id=YOUR_CLIENT_ID \ + -d client_secret=YOUR_CLIENT_SECRET \ + -d code=AUTH_CODE \ + -d grant_type=authorization_code \ + -d redirect_uri=urn:ietf:wg:oauth:2.0:oob | jq .refresh_token +``` + +## Step 3: Configure Spacebot + + + + +1. Open the Spacebot dashboard +2. Go to **Settings → API Keys** +3. Find the **Google Calendar** card and click **Configure** +4. Enter your Client ID, Client Secret, and Refresh Token +5. Optionally set a Default Calendar ID (defaults to `primary`) +6. Click **Save** + + + + +Add to your `config.toml`: + +```toml +[defaults.google_calendar] +client_id = "YOUR_CLIENT_ID.apps.googleusercontent.com" +client_secret = "GOCSPX-YOUR_SECRET" +refresh_token = "1//YOUR_REFRESH_TOKEN" +default_calendar_id = "primary" +``` + +You can also use `env:` references to load from environment variables: + +```toml +[defaults.google_calendar] +client_id = "env:GOOGLE_CALENDAR_CLIENT_ID" +client_secret = "env:GOOGLE_CALENDAR_CLIENT_SECRET" +refresh_token = "env:GOOGLE_CALENDAR_REFRESH_TOKEN" +default_calendar_id = "primary" +``` + + + + +## Per-Agent Override + +You can override Google Calendar credentials for a specific agent: + +```toml +[[agents]] +id = "personal-assistant" + +[agents.google_calendar] +client_id = "DIFFERENT_CLIENT_ID.apps.googleusercontent.com" +client_secret = "GOCSPX-DIFFERENT_SECRET" +refresh_token = "1//DIFFERENT_REFRESH_TOKEN" +default_calendar_id = "work@example.com" +``` + +This lets different agents access different Google accounts or calendars. + +## Troubleshooting + +**"invalid_grant" errors** +- Your refresh token may have expired. Google revokes tokens if the OAuth consent screen is in "Testing" mode and the token is older than 7 days. Publish your app or re-generate the token. +- Make sure `access_type=offline` and `prompt=consent` were used when obtaining the token. + +**Tools not appearing** +- Verify all three required fields are set: `client_id`, `client_secret`, `refresh_token`. +- Check the dashboard — the Google Calendar card should show "Configured". +- Config changes hot-reload automatically; no restart needed. + +**"Access Not Configured" or 403 errors** +- Ensure the Google Calendar API is enabled in your Google Cloud project. +- Verify the OAuth consent screen includes your account as a test user (if in Testing mode). + +**Wrong calendar** +- Set `default_calendar_id` to the specific calendar email address (e.g., `work@example.com`) instead of `primary`. +- Use the `google_calendar_list_calendars` tool to see available calendars. diff --git a/docs/content/docs/(features)/meta.json b/docs/content/docs/(features)/meta.json index a9903832a..3a0dc820d 100644 --- a/docs/content/docs/(features)/meta.json +++ b/docs/content/docs/(features)/meta.json @@ -1,4 +1,4 @@ { "title": "Features", - "pages": ["workers", "tasks", "opencode", "tools", "mcp", "browser", "cron", "skills", "ingestion"] + "pages": ["workers", "tasks", "opencode", "tools", "mcp", "browser", "cron", "skills", "ingestion", "google-calendar"] } diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 8cab46c2d..aea8600f8 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1338,6 +1338,11 @@ export interface OpenCodeSettingsUpdate { export interface GlobalSettingsResponse { brave_search_key: string | null; + google_calendar_configured: boolean; + google_calendar_client_id: string | null; + google_calendar_client_secret: string | null; + google_calendar_refresh_token: string | null; + google_calendar_default_calendar_id: string | null; api_enabled: boolean; api_port: number; api_bind: string; @@ -1347,6 +1352,12 @@ export interface GlobalSettingsResponse { export interface GlobalSettingsUpdate { brave_search_key?: string | null; + google_calendar?: { + client_id?: string; + client_secret?: string; + refresh_token?: string; + default_calendar_id?: string; + }; api_enabled?: boolean; api_port?: number; api_bind?: string; diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 9ea2df47d..00db45254 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -7,7 +7,7 @@ import { PlatformCatalog, InstanceCard, AddInstanceCard } from "@/components/Cha import { ModelSelect } from "@/components/ModelSelect"; import { ProviderIcon } from "@/lib/providerIcons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faSearch } from "@fortawesome/free-solid-svg-icons"; +import { faSearch, faCalendarDays } from "@fortawesome/free-solid-svg-icons"; import { parse as parseToml } from "smol-toml"; import { useTheme, THEMES, type ThemeId } from "@/hooks/useTheme"; @@ -1619,6 +1619,11 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) { const queryClient = useQueryClient(); const [editingBraveKey, setEditingBraveKey] = useState(false); const [braveKeyInput, setBraveKeyInput] = useState(""); + const [editingGCalCredentials, setEditingGCalCredentials] = useState(false); + const [gCalClientId, setGCalClientId] = useState(""); + const [gCalClientSecret, setGCalClientSecret] = useState(""); + const [gCalRefreshToken, setGCalRefreshToken] = useState(""); + const [gCalDefaultCalendarId, setGCalDefaultCalendarId] = useState(""); const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null); const updateMutation = useMutation({ @@ -1627,6 +1632,11 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) { if (result.success) { setEditingBraveKey(false); setBraveKeyInput(""); + setEditingGCalCredentials(false); + setGCalClientId(""); + setGCalClientSecret(""); + setGCalRefreshToken(""); + setGCalDefaultCalendarId(""); setMessage({ text: result.message, type: "success" }); queryClient.invalidateQueries({ queryKey: ["global-settings"] }); } else { @@ -1646,6 +1656,28 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) { updateMutation.mutate({ brave_search_key: null }); }; + const handleSaveGCalCredentials = () => { + const calId = gCalDefaultCalendarId.trim(); + updateMutation.mutate({ + google_calendar: { + client_id: gCalClientId.trim(), + client_secret: gCalClientSecret.trim(), + refresh_token: gCalRefreshToken.trim(), + ...(calId ? { default_calendar_id: calId } : {}), + }, + }); + }; + + const handleRemoveGCalCredentials = () => { + updateMutation.mutate({ + google_calendar: { + client_id: "", + client_secret: "", + refresh_token: "", + }, + }); + }; + return (
@@ -1701,6 +1733,50 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) {
+ + {/* Google Calendar */} +
+
+ +
+
+ Google Calendar + {settings?.google_calendar_configured && ( + ● Configured + )} +
+

+ Enables calendar tools for listing, creating, and managing events +

+
+
+ + {settings?.google_calendar_configured && ( + + )} +
+
+
)} @@ -1748,6 +1824,60 @@ function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) { + + {/* Google Calendar Dialog */} + { if (!open) setEditingGCalCredentials(false); }}> + + + {settings?.google_calendar_configured ? "Update" : "Configure"} Google Calendar + + Enter your Google OAuth credentials. See the{" "} + setup guide{" "} + for instructions. + + +
+ setGCalClientId(e.target.value)} + placeholder="xxxx.apps.googleusercontent.com" + autoFocus + /> + setGCalClientSecret(e.target.value)} + placeholder="GOCSPX-..." + /> + setGCalRefreshToken(e.target.value)} + placeholder="1//0..." + /> + setGCalDefaultCalendarId(e.target.value)} + placeholder="primary (optional)" + /> +
+ + + + +
+
); } diff --git a/prompts/en/tools/google_calendar_create_event_description.md.j2 b/prompts/en/tools/google_calendar_create_event_description.md.j2 new file mode 100644 index 000000000..2cd178860 --- /dev/null +++ b/prompts/en/tools/google_calendar_create_event_description.md.j2 @@ -0,0 +1 @@ +Create a new event on Google Calendar. Specify a title, start/end time, and optionally a description, location, attendees, or recurrence rule. Use this to schedule meetings, set reminders, or block time. diff --git a/prompts/en/tools/google_calendar_delete_event_description.md.j2 b/prompts/en/tools/google_calendar_delete_event_description.md.j2 new file mode 100644 index 000000000..9123450f9 --- /dev/null +++ b/prompts/en/tools/google_calendar_delete_event_description.md.j2 @@ -0,0 +1 @@ +Delete an event from Google Calendar. Requires the event ID. Use this to cancel meetings or remove events that are no longer needed. diff --git a/prompts/en/tools/google_calendar_find_free_time_description.md.j2 b/prompts/en/tools/google_calendar_find_free_time_description.md.j2 new file mode 100644 index 000000000..82575399d --- /dev/null +++ b/prompts/en/tools/google_calendar_find_free_time_description.md.j2 @@ -0,0 +1 @@ +Check free/busy status across one or more Google Calendars for a given time range. Returns busy periods. Use this to find available meeting slots or check if someone is free at a specific time. diff --git a/prompts/en/tools/google_calendar_list_calendars_description.md.j2 b/prompts/en/tools/google_calendar_list_calendars_description.md.j2 new file mode 100644 index 000000000..92cdc92ef --- /dev/null +++ b/prompts/en/tools/google_calendar_list_calendars_description.md.j2 @@ -0,0 +1 @@ +List all calendars available to the authenticated Google account. Returns calendar names, IDs, and access roles. Use this to discover which calendars exist before querying events. diff --git a/prompts/en/tools/google_calendar_list_events_description.md.j2 b/prompts/en/tools/google_calendar_list_events_description.md.j2 new file mode 100644 index 000000000..95e6c7eab --- /dev/null +++ b/prompts/en/tools/google_calendar_list_events_description.md.j2 @@ -0,0 +1 @@ +List events from Google Calendar. Returns event titles, times, locations, and attendees. Use this to check someone's schedule, find upcoming meetings, or search for specific events by keyword. diff --git a/prompts/en/tools/google_calendar_respond_event_description.md.j2 b/prompts/en/tools/google_calendar_respond_event_description.md.j2 new file mode 100644 index 000000000..2894aea71 --- /dev/null +++ b/prompts/en/tools/google_calendar_respond_event_description.md.j2 @@ -0,0 +1 @@ +Respond to a Google Calendar event invitation. Set your RSVP status to accepted, declined, or tentative. Requires the event ID. diff --git a/prompts/en/tools/google_calendar_update_event_description.md.j2 b/prompts/en/tools/google_calendar_update_event_description.md.j2 new file mode 100644 index 000000000..be89507f3 --- /dev/null +++ b/prompts/en/tools/google_calendar_update_event_description.md.j2 @@ -0,0 +1 @@ +Update an existing Google Calendar event. Modify the title, time, description, location, attendees, or `color_id`. Only the fields you provide will be changed. Requires the event ID from a previous list or create call. diff --git a/src/api/agents.rs b/src/api/agents.rs index ce75842c1..80fc359c5 100644 --- a/src/api/agents.rs +++ b/src/api/agents.rs @@ -619,6 +619,7 @@ pub async fn create_agent_internal( channel: None, mcp: None, brave_search_key: None, + google_calendar: None, cron_timezone: None, user_timezone: None, sandbox: None, diff --git a/src/api/secrets.rs b/src/api/secrets.rs index bc97fccd5..b9bc5e97b 100644 --- a/src/api/secrets.rs +++ b/src/api/secrets.rs @@ -640,10 +640,18 @@ fn migrate_section_secrets( // Migrate default (top-level) fields. for field in fields { + // Split dot-separated toml_key segments (e.g. "google_calendar.client_id" + // becomes ["google_calendar", "client_id"]) so nested tables are traversed + // correctly rather than treated as a single literal key. + let key_segments: Vec<&str> = field.toml_key.split('.').collect(); let path: Vec<&str> = if is_adapter { - vec!["messaging", section, field.toml_key] + let mut p = vec!["messaging", section]; + p.extend_from_slice(&key_segments); + p } else { - vec![section, field.toml_key] + let mut p = vec![section]; + p.extend_from_slice(&key_segments); + p }; try_migrate_field(store, doc, &path, field.secret_name, migrated); } diff --git a/src/api/settings.rs b/src/api/settings.rs index 52eaf44a4..6b2793e88 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -9,6 +9,11 @@ use std::sync::Arc; #[derive(Serialize)] pub(super) struct GlobalSettingsResponse { brave_search_key: Option, + google_calendar_configured: bool, + google_calendar_client_id: Option, + google_calendar_client_secret: Option, + google_calendar_refresh_token: Option, + google_calendar_default_calendar_id: Option, api_enabled: bool, api_port: u16, api_bind: String, @@ -37,6 +42,7 @@ pub(super) struct OpenCodePermissionsResponse { #[derive(Deserialize)] pub(super) struct GlobalSettingsUpdate { brave_search_key: Option, + google_calendar: Option, api_enabled: Option, api_port: Option, api_bind: Option, @@ -45,6 +51,14 @@ pub(super) struct GlobalSettingsUpdate { ssh_enabled: Option, } +#[derive(Deserialize)] +pub(super) struct GoogleCalendarSettingsUpdate { + client_id: Option, + client_secret: Option, + refresh_token: Option, + default_calendar_id: Option, +} + #[derive(Deserialize)] pub(super) struct OpenCodeSettingsUpdate { enabled: Option, @@ -90,140 +104,204 @@ pub(super) async fn get_global_settings( ) -> Result, StatusCode> { let config_path = state.config_path.read().await.clone(); - let (brave_search_key, api_enabled, api_port, api_bind, worker_log_mode, opencode, ssh_enabled) = - if config_path.exists() { - let content = tokio::fs::read_to_string(&config_path) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let doc: toml_edit::DocumentMut = content - .parse() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let brave_search = doc - .get("defaults") - .and_then(|d| d.get("brave_search_key")) + let ( + brave_search_key, + google_calendar_configured, + gcal_client_id, + gcal_client_secret, + gcal_refresh_token, + gcal_default_calendar_id, + api_enabled, + api_port, + api_bind, + worker_log_mode, + opencode, + ssh_enabled, + ) = if config_path.exists() { + let content = tokio::fs::read_to_string(&config_path) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let doc: toml_edit::DocumentMut = content + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let brave_search = doc + .get("defaults") + .and_then(|d| d.get("brave_search_key")) + .and_then(|v| v.as_str()) + .and_then(|s| { + if let Some(var) = s.strip_prefix("env:") { + std::env::var(var).ok() + } else { + Some(s.to_string()) + } + }); + + let gcal_read = |g: &toml_edit::Item| { + let client_id = g + .get("client_id") .and_then(|v| v.as_str()) - .and_then(|s| { - if let Some(var) = s.strip_prefix("env:") { - std::env::var(var).ok() - } else { - Some(s.to_string()) - } - }); - - let api_enabled = doc - .get("api") - .and_then(|a| a.get("enabled")) - .and_then(|v| v.as_bool()) - .unwrap_or(true); - - let api_port = doc - .get("api") - .and_then(|a| a.get("port")) - .and_then(|v| v.as_integer()) - .and_then(|i| u16::try_from(i).ok()) - .unwrap_or(19898); - - let api_bind = doc - .get("api") - .and_then(|a| a.get("bind")) + .map(str::to_owned); + let client_secret = g + .get("client_secret") .and_then(|v| v.as_str()) - .unwrap_or("127.0.0.1") - .to_string(); - - let worker_log_mode = doc - .get("defaults") - .and_then(|d| d.get("worker_log_mode")) + .map(str::to_owned); + let refresh_token = g + .get("refresh_token") + .and_then(|v| v.as_str()) + .map(str::to_owned); + let default_calendar_id = g + .get("default_calendar_id") + .and_then(|v| v.as_str()) + .map(str::to_owned); + (client_id, client_secret, refresh_token, default_calendar_id) + }; + let (gcal_client_id, gcal_client_secret, gcal_refresh_token, gcal_default_calendar_id) = + doc.get("defaults") + .and_then(|d| d.get("google_calendar")) + .map(gcal_read) + .or_else(|| { + doc.get("agents") + .and_then(|a| a.as_array_of_tables()) + .and_then(|agents| { + agents + .iter() + .find_map(|agent| agent.get("google_calendar").map(gcal_read)) + }) + }) + .unwrap_or((None, None, None, None)); + let google_calendar_configured = gcal_client_id.as_deref().is_some_and(|s| !s.is_empty()) + && gcal_client_secret.as_deref().is_some_and(|s| !s.is_empty()) + && gcal_refresh_token.as_deref().is_some_and(|s| !s.is_empty()); + + let api_enabled = doc + .get("api") + .and_then(|a| a.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let api_port = doc + .get("api") + .and_then(|a| a.get("port")) + .and_then(|v| v.as_integer()) + .and_then(|i| u16::try_from(i).ok()) + .unwrap_or(19898); + + let api_bind = doc + .get("api") + .and_then(|a| a.get("bind")) + .and_then(|v| v.as_str()) + .unwrap_or("127.0.0.1") + .to_string(); + + let worker_log_mode = doc + .get("defaults") + .and_then(|d| d.get("worker_log_mode")) + .and_then(|v| v.as_str()) + .unwrap_or("errors_only") + .to_string(); + + let opencode_table = doc.get("defaults").and_then(|d| d.get("opencode")); + let opencode_perms = opencode_table.and_then(|o| o.get("permissions")); + let opencode = OpenCodeSettingsResponse { + enabled: opencode_table + .and_then(|o| o.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false), + path: opencode_table + .and_then(|o| o.get("path")) .and_then(|v| v.as_str()) - .unwrap_or("errors_only") - .to_string(); - - let opencode_table = doc.get("defaults").and_then(|d| d.get("opencode")); - let opencode_perms = opencode_table.and_then(|o| o.get("permissions")); - let opencode = OpenCodeSettingsResponse { - enabled: opencode_table - .and_then(|o| o.get("enabled")) - .and_then(|v| v.as_bool()) - .unwrap_or(false), - path: opencode_table - .and_then(|o| o.get("path")) + .unwrap_or("opencode") + .to_string(), + max_servers: opencode_table + .and_then(|o| o.get("max_servers")) + .and_then(|v| v.as_integer()) + .and_then(|i| usize::try_from(i).ok()) + .unwrap_or(5), + server_startup_timeout_secs: opencode_table + .and_then(|o| o.get("server_startup_timeout_secs")) + .and_then(|v| v.as_integer()) + .and_then(|i| u64::try_from(i).ok()) + .unwrap_or(30), + max_restart_retries: opencode_table + .and_then(|o| o.get("max_restart_retries")) + .and_then(|v| v.as_integer()) + .and_then(|i| u32::try_from(i).ok()) + .unwrap_or(5), + permissions: OpenCodePermissionsResponse { + edit: opencode_perms + .and_then(|p| p.get("edit")) .and_then(|v| v.as_str()) - .unwrap_or("opencode") + .unwrap_or("allow") .to_string(), - max_servers: opencode_table - .and_then(|o| o.get("max_servers")) - .and_then(|v| v.as_integer()) - .and_then(|i| usize::try_from(i).ok()) - .unwrap_or(5), - server_startup_timeout_secs: opencode_table - .and_then(|o| o.get("server_startup_timeout_secs")) - .and_then(|v| v.as_integer()) - .and_then(|i| u64::try_from(i).ok()) - .unwrap_or(30), - max_restart_retries: opencode_table - .and_then(|o| o.get("max_restart_retries")) - .and_then(|v| v.as_integer()) - .and_then(|i| u32::try_from(i).ok()) - .unwrap_or(5), - permissions: OpenCodePermissionsResponse { - edit: opencode_perms - .and_then(|p| p.get("edit")) - .and_then(|v| v.as_str()) - .unwrap_or("allow") - .to_string(), - bash: opencode_perms - .and_then(|p| p.get("bash")) - .and_then(|v| v.as_str()) - .unwrap_or("allow") - .to_string(), - webfetch: opencode_perms - .and_then(|p| p.get("webfetch")) - .and_then(|v| v.as_str()) - .unwrap_or("allow") - .to_string(), - }, - }; + bash: opencode_perms + .and_then(|p| p.get("bash")) + .and_then(|v| v.as_str()) + .unwrap_or("allow") + .to_string(), + webfetch: opencode_perms + .and_then(|p| p.get("webfetch")) + .and_then(|v| v.as_str()) + .unwrap_or("allow") + .to_string(), + }, + }; - let ssh_enabled = doc - .get("ssh") - .and_then(|s| s.get("enabled")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - ( - brave_search, - api_enabled, - api_port, - api_bind, - worker_log_mode, - opencode, - ssh_enabled, - ) - } else { - ( - None, - true, - 19898, - "127.0.0.1".to_string(), - "errors_only".to_string(), - OpenCodeSettingsResponse { - enabled: false, - path: "opencode".to_string(), - max_servers: 5, - server_startup_timeout_secs: 30, - max_restart_retries: 5, - permissions: OpenCodePermissionsResponse { - edit: "allow".to_string(), - bash: "allow".to_string(), - webfetch: "allow".to_string(), - }, + let ssh_enabled = doc + .get("ssh") + .and_then(|s| s.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + ( + brave_search, + google_calendar_configured, + gcal_client_id, + gcal_client_secret, + gcal_refresh_token, + gcal_default_calendar_id, + api_enabled, + api_port, + api_bind, + worker_log_mode, + opencode, + ssh_enabled, + ) + } else { + ( + None, + false, + None, + None, + None, + None, + true, + 19898, + "127.0.0.1".to_string(), + "errors_only".to_string(), + OpenCodeSettingsResponse { + enabled: false, + path: "opencode".to_string(), + max_servers: 5, + server_startup_timeout_secs: 30, + max_restart_retries: 5, + permissions: OpenCodePermissionsResponse { + edit: "allow".to_string(), + bash: "allow".to_string(), + webfetch: "allow".to_string(), }, - false, - ) - }; + }, + false, + ) + }; Ok(Json(GlobalSettingsResponse { brave_search_key, + google_calendar_configured, + google_calendar_client_id: gcal_client_id, + google_calendar_client_secret: gcal_client_secret, + google_calendar_refresh_token: gcal_refresh_token, + google_calendar_default_calendar_id: gcal_default_calendar_id, api_enabled, api_port, api_bind, @@ -266,6 +344,51 @@ pub(super) async fn update_global_settings( } } + if let Some(google_calendar) = request.google_calendar { + let all_empty = google_calendar + .client_id + .as_deref() + .is_some_and(|s| s.is_empty()) + && google_calendar + .client_secret + .as_deref() + .is_some_and(|s| s.is_empty()) + && google_calendar + .refresh_token + .as_deref() + .is_some_and(|s| s.is_empty()); + + if all_empty { + // Remove the entire section. + if let Some(defaults) = doc.get_mut("defaults").and_then(|d| d.as_table_mut()) { + defaults.remove("google_calendar"); + } + } else { + if doc.get("defaults").is_none() { + doc["defaults"] = toml_edit::Item::Table(toml_edit::Table::new()); + } + if doc["defaults"].get("google_calendar").is_none() { + doc["defaults"]["google_calendar"] = + toml_edit::Item::Table(toml_edit::Table::new()); + } + if let Some(client_id) = google_calendar.client_id { + doc["defaults"]["google_calendar"]["client_id"] = toml_edit::value(client_id); + } + if let Some(client_secret) = google_calendar.client_secret { + doc["defaults"]["google_calendar"]["client_secret"] = + toml_edit::value(client_secret); + } + if let Some(refresh_token) = google_calendar.refresh_token { + doc["defaults"]["google_calendar"]["refresh_token"] = + toml_edit::value(refresh_token); + } + if let Some(default_calendar_id) = google_calendar.default_calendar_id { + doc["defaults"]["google_calendar"]["default_calendar_id"] = + toml_edit::value(default_calendar_id); + } + } + } + if request.api_enabled.is_some() || request.api_port.is_some() || request.api_bind.is_some() { requires_restart = true; diff --git a/src/config/load.rs b/src/config/load.rs index 3da09e61f..accc7b3cc 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -12,12 +12,12 @@ use super::toml_schema::*; use super::{ AgentConfig, ApiConfig, ApiType, Binding, BrowserConfig, ChannelConfig, ClosePolicy, CoalesceConfig, CompactionConfig, Config, CortexConfig, CronDef, DefaultsConfig, DiscordConfig, - DiscordInstanceConfig, EmailConfig, EmailInstanceConfig, GroupDef, HumanDef, IngestionConfig, - LinkDef, LlmConfig, McpServerConfig, McpTransport, MemoryPersistenceConfig, MessagingConfig, - MetricsConfig, OpenCodeConfig, ProjectsConfig, ProviderConfig, SlackCommandConfig, SlackConfig, - SlackInstanceConfig, TelegramConfig, TelegramInstanceConfig, TelemetryConfig, TwitchConfig, - TwitchInstanceConfig, WarmupConfig, WebhookConfig, normalize_adapter, - validate_named_messaging_adapters, + DiscordInstanceConfig, EmailConfig, EmailInstanceConfig, GoogleCalendarConfig, GroupDef, + HumanDef, IngestionConfig, LinkDef, LlmConfig, McpServerConfig, McpTransport, + MemoryPersistenceConfig, MessagingConfig, MetricsConfig, OpenCodeConfig, ProjectsConfig, + ProviderConfig, SlackCommandConfig, SlackConfig, SlackInstanceConfig, TelegramConfig, + TelegramInstanceConfig, TelemetryConfig, TwitchConfig, TwitchInstanceConfig, WarmupConfig, + WebhookConfig, normalize_adapter, validate_named_messaging_adapters, }; use crate::error::{ConfigError, Result}; @@ -26,6 +26,56 @@ use anyhow::Context as _; use std::collections::HashMap; use std::path::{Path, PathBuf}; +/// Resolve Google Calendar config from an optional TOML config block. +/// +/// Resolves each credential via `resolve_env_value`, then falls back to direct +/// environment variables. Returns `None` when any required credential is missing. +fn resolve_google_calendar_config( + google_calendar_config: Option<&TomlGoogleCalendarConfig>, +) -> Option { + let (client_id, client_secret, refresh_token, default_calendar_id) = + match google_calendar_config { + Some(google_calendar) => ( + google_calendar + .client_id + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("GOOGLE_CALENDAR_CLIENT_ID").ok()), + google_calendar + .client_secret + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("GOOGLE_CALENDAR_CLIENT_SECRET").ok()), + google_calendar + .refresh_token + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("GOOGLE_CALENDAR_REFRESH_TOKEN").ok()), + google_calendar + .default_calendar_id + .as_deref() + .and_then(resolve_env_value) + .unwrap_or_else(|| "primary".to_string()), + ), + None => ( + std::env::var("GOOGLE_CALENDAR_CLIENT_ID").ok(), + std::env::var("GOOGLE_CALENDAR_CLIENT_SECRET").ok(), + std::env::var("GOOGLE_CALENDAR_REFRESH_TOKEN").ok(), + "primary".to_string(), + ), + }; + + match (client_id, client_secret, refresh_token) { + (Some(id), Some(secret), Some(token)) => Some(GoogleCalendarConfig { + client_id: id, + client_secret: secret, + refresh_token: token, + default_calendar_id, + }), + _ => None, + } +} + /// Resolve a value that might be an "env:VAR_NAME" or "secret:NAME" reference. /// /// Three resolution modes: @@ -836,6 +886,7 @@ impl Config { channel: None, mcp: None, brave_search_key: None, + google_calendar: None, cron_timezone: None, user_timezone: None, sandbox: None, @@ -848,6 +899,7 @@ impl Config { let mut defaults = DefaultsConfig::default(); defaults.browser.chrome_cache_dir = instance_dir.join("chrome_cache"); + defaults.google_calendar = resolve_google_calendar_config(None); Ok(Self { instance_dir: instance_dir.to_path_buf(), @@ -1508,6 +1560,7 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("BRAVE_SEARCH_API_KEY").ok()), + google_calendar: resolve_google_calendar_config(toml.defaults.google_calendar.as_ref()), cron_timezone: toml .defaults .cron_timezone @@ -1715,6 +1768,7 @@ impl Config { None => None, }, brave_search_key: a.brave_search_key.as_deref().and_then(resolve_env_value), + google_calendar: resolve_google_calendar_config(a.google_calendar.as_ref()), cron_timezone: a.cron_timezone.as_deref().and_then(resolve_env_value), user_timezone: a.user_timezone.as_deref().and_then(resolve_env_value), sandbox: a.sandbox, @@ -1769,6 +1823,7 @@ impl Config { channel: None, mcp: None, brave_search_key: None, + google_calendar: None, cron_timezone: None, user_timezone: None, sandbox: None, diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 58c36ce47..4b4fd7943 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -5,8 +5,9 @@ use arc_swap::ArcSwap; use super::{ BrowserConfig, ChannelConfig, CoalesceConfig, CompactionConfig, Config, CortexConfig, - DefaultsConfig, IngestionConfig, McpServerConfig, MemoryPersistenceConfig, OpenCodeConfig, - ResolvedAgentConfig, WarmupConfig, WarmupStatus, WorkReadiness, evaluate_work_readiness, + DefaultsConfig, GoogleCalendarConfig, IngestionConfig, McpServerConfig, + MemoryPersistenceConfig, OpenCodeConfig, ResolvedAgentConfig, WarmupConfig, WarmupStatus, + WorkReadiness, evaluate_work_readiness, }; use crate::llm::routing::RoutingConfig; use crate::tools::browser::SharedBrowserHandle; @@ -40,6 +41,7 @@ pub struct RuntimeConfig { pub mcp: ArcSwap>, pub history_backfill_count: ArcSwap, pub brave_search_key: ArcSwap>, + pub google_calendar: ArcSwap>, pub cron_timezone: ArcSwap>, pub user_timezone: ArcSwap>, pub cortex: ArcSwap, @@ -121,6 +123,7 @@ impl RuntimeConfig { mcp: ArcSwap::from_pointee(agent_config.mcp.clone()), history_backfill_count: ArcSwap::from_pointee(agent_config.history_backfill_count), brave_search_key: ArcSwap::from_pointee(agent_config.brave_search_key.clone()), + google_calendar: ArcSwap::from_pointee(agent_config.google_calendar.clone()), cron_timezone: ArcSwap::from_pointee(agent_config.cron_timezone.clone()), user_timezone: ArcSwap::from_pointee(agent_config.user_timezone.clone()), cortex: ArcSwap::from_pointee(agent_config.cortex), @@ -283,6 +286,8 @@ impl RuntimeConfig { .store(Arc::new(resolved.history_backfill_count)); self.brave_search_key .store(Arc::new(resolved.brave_search_key)); + self.google_calendar + .store(Arc::new(resolved.google_calendar.clone())); self.cron_timezone.store(Arc::new(resolved.cron_timezone)); self.user_timezone.store(Arc::new(resolved.user_timezone)); self.cortex.store(Arc::new(resolved.cortex)); diff --git a/src/config/toml_schema.rs b/src/config/toml_schema.rs index 969333053..8f30bbc5e 100644 --- a/src/config/toml_schema.rs +++ b/src/config/toml_schema.rs @@ -285,6 +285,7 @@ pub(super) struct TomlDefaultsConfig { #[serde(default)] pub(super) mcp: Vec, pub(super) brave_search_key: Option, + pub(super) google_calendar: Option, pub(super) cron_timezone: Option, pub(super) user_timezone: Option, pub(super) opencode: Option, @@ -414,6 +415,14 @@ pub(super) struct TomlProjectsConfig { pub(super) disk_usage_warning_threshold: Option, } +#[derive(Deserialize)] +pub(super) struct TomlGoogleCalendarConfig { + pub(super) client_id: Option, + pub(super) client_secret: Option, + pub(super) refresh_token: Option, + pub(super) default_calendar_id: Option, +} + #[derive(Deserialize, Clone)] pub(super) struct TomlMcpServerConfig { pub(super) name: String, @@ -460,6 +469,7 @@ pub(super) struct TomlAgentConfig { pub(super) channel: Option, pub(super) mcp: Option>, pub(super) brave_search_key: Option, + pub(super) google_calendar: Option, pub(super) cron_timezone: Option, pub(super) user_timezone: Option, pub(super) sandbox: Option, diff --git a/src/config/types.rs b/src/config/types.rs index 6c6111f66..350520839 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -528,6 +528,8 @@ pub struct DefaultsConfig { pub mcp: Vec, /// Brave Search API key for web search tool. Supports "env:VAR_NAME" references. pub brave_search_key: Option, + /// Google Calendar API configuration for calendar tools. + pub google_calendar: Option, /// Default timezone used when evaluating cron active hours. pub cron_timezone: Option, /// Default timezone for channel/worker temporal context. @@ -563,6 +565,10 @@ impl std::fmt::Debug for DefaultsConfig { "brave_search_key", &self.brave_search_key.as_ref().map(|_| "[REDACTED]"), ) + .field( + "google_calendar", + &self.google_calendar.as_ref().map(|_| "[REDACTED]"), + ) .field("cron_timezone", &self.cron_timezone) .field("user_timezone", &self.user_timezone) .field("history_backfill_count", &self.history_backfill_count) @@ -580,11 +586,28 @@ impl SystemSecrets for DefaultsConfig { } fn secret_fields() -> &'static [SecretField] { - &[SecretField { - toml_key: "brave_search_key", - secret_name: "BRAVE_SEARCH_API_KEY", - instance_pattern: None, - }] + &[ + SecretField { + toml_key: "brave_search_key", + secret_name: "BRAVE_SEARCH_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "google_calendar.client_id", + secret_name: "GOOGLE_CALENDAR_CLIENT_ID", + instance_pattern: None, + }, + SecretField { + toml_key: "google_calendar.client_secret", + secret_name: "GOOGLE_CALENDAR_CLIENT_SECRET", + instance_pattern: None, + }, + SecretField { + toml_key: "google_calendar.refresh_token", + secret_name: "GOOGLE_CALENDAR_REFRESH_TOKEN", + instance_pattern: None, + }, + ] } } @@ -619,6 +642,26 @@ impl McpTransport { } } +/// Google Calendar API configuration. +#[derive(Clone)] +pub struct GoogleCalendarConfig { + pub client_id: String, + pub client_secret: String, + pub refresh_token: String, + pub default_calendar_id: String, +} + +impl std::fmt::Debug for GoogleCalendarConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GoogleCalendarConfig") + .field("client_id", &self.client_id) + .field("client_secret", &"[REDACTED]") + .field("refresh_token", &"[REDACTED]") + .field("default_calendar_id", &self.default_calendar_id) + .finish() + } +} + /// Compaction threshold configuration. #[derive(Debug, Clone, Copy)] pub struct CompactionConfig { @@ -1119,6 +1162,8 @@ pub struct AgentConfig { pub mcp: Option>, /// Per-agent Brave Search API key override. None inherits from defaults. pub brave_search_key: Option, + /// Per-agent Google Calendar API configuration override. + pub google_calendar: Option, /// Optional timezone override for cron active-hours evaluation. pub cron_timezone: Option, /// Optional timezone override for channel/worker temporal context. @@ -1181,6 +1226,7 @@ pub struct ResolvedAgentConfig { pub channel: ChannelConfig, pub mcp: Vec, pub brave_search_key: Option, + pub google_calendar: Option, pub cron_timezone: Option, pub user_timezone: Option, /// Sandbox configuration for process containment. @@ -1211,6 +1257,7 @@ impl Default for DefaultsConfig { channel: ChannelConfig::default(), mcp: Vec::new(), brave_search_key: None, + google_calendar: None, cron_timezone: None, user_timezone: None, history_backfill_count: 50, @@ -1282,6 +1329,10 @@ impl AgentConfig { .brave_search_key .clone() .or_else(|| defaults.brave_search_key.clone()), + google_calendar: self + .google_calendar + .clone() + .or_else(|| defaults.google_calendar.clone()), cron_timezone: resolved_cron_timezone, user_timezone: resolved_user_timezone, sandbox: self.sandbox.clone().unwrap_or_default(), diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 88fe86df8..91dcd56a9 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -237,6 +237,27 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "tools/attachment_recall") => { include_str!("../../prompts/en/tools/attachment_recall_description.md.j2") } + ("en", "tools/google_calendar_list_events") => { + include_str!("../../prompts/en/tools/google_calendar_list_events_description.md.j2") + } + ("en", "tools/google_calendar_create_event") => { + include_str!("../../prompts/en/tools/google_calendar_create_event_description.md.j2") + } + ("en", "tools/google_calendar_update_event") => { + include_str!("../../prompts/en/tools/google_calendar_update_event_description.md.j2") + } + ("en", "tools/google_calendar_delete_event") => { + include_str!("../../prompts/en/tools/google_calendar_delete_event_description.md.j2") + } + ("en", "tools/google_calendar_list_calendars") => { + include_str!("../../prompts/en/tools/google_calendar_list_calendars_description.md.j2") + } + ("en", "tools/google_calendar_find_free_time") => { + include_str!("../../prompts/en/tools/google_calendar_find_free_time_description.md.j2") + } + ("en", "tools/google_calendar_respond_event") => { + include_str!("../../prompts/en/tools/google_calendar_respond_event_description.md.j2") + } // Fallback: unknown language or key -> try English (lang, key) if lang != "en" => { diff --git a/src/tools.rs b/src/tools.rs index 4ed3bd7f5..37a383c58 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -16,17 +16,22 @@ //! - `spacebot_docs` for embedded self-documentation lookup //! - `task_create` + `task_list` + `task_update` //! - `spawn_worker` is included for channel-originated branches only +//! - Google Calendar read-only tools (`list_calendars`, `list_events`, `get_event`, +//! `find_free_time`, `find_meeting_times`) //! //! **Worker ToolServer** (one per worker, created at spawn time): //! - `shell`, `file_read`/`file_write`/`file_edit`/`file_list` — stateless, registered at creation //! - `task_update` — scoped to the worker's assigned task //! - `set_status` — per-worker instance, registered at creation +//! - Full Google Calendar tools (read + write: `create_event`, `update_event`, `delete_event`, +//! `respond_to_event`, plus all read tools) //! //! **Cortex ToolServer** (one per agent): //! - `memory_save` — registered at startup //! //! **Cortex Chat ToolServer** (interactive admin chat): //! - branch + worker tool superset plus `spacebot_docs`, `config_inspect`, and `spawn_worker` +//! - Full Google Calendar tools (read + write) pub mod attachment_recall; pub mod branch_tool; @@ -37,6 +42,7 @@ pub mod config_inspect; pub mod cron; pub mod email_search; pub mod file; +pub mod google_calendar; pub mod install_skill; pub mod mcp; pub mod memory_delete; @@ -92,6 +98,11 @@ pub use file::{ FileOutput, FileReadArgs, FileReadTool, FileType, FileWriteArgs, FileWriteTool, register_file_tools, }; +pub use google_calendar::{ + GoogleCalendarCreateEventTool, GoogleCalendarDeleteEventTool, GoogleCalendarFreeTimeTool, + GoogleCalendarListCalendarsTool, GoogleCalendarListEventsTool, GoogleCalendarRespondEventTool, + GoogleCalendarUpdateEventTool, +}; pub use install_skill::{ InstallSkillArgs, InstallSkillError, InstallSkillOutput, InstallSkillTool, }; @@ -468,6 +479,29 @@ pub async fn remove_channel_tools( Ok(()) } +/// Register read-only Google Calendar tools (list events, list calendars, free/busy). +fn register_google_calendar_readonly_tools( + server: ToolServer, + cal_client: Arc, +) -> ToolServer { + server + .tool(GoogleCalendarListEventsTool::new(cal_client.clone())) + .tool(GoogleCalendarListCalendarsTool::new(cal_client.clone())) + .tool(GoogleCalendarFreeTimeTool::new(cal_client)) +} + +/// Register all Google Calendar tools (read + write). +fn register_google_calendar_tools( + server: ToolServer, + cal_client: Arc, +) -> ToolServer { + register_google_calendar_readonly_tools(server, cal_client.clone()) + .tool(GoogleCalendarCreateEventTool::new(cal_client.clone())) + .tool(GoogleCalendarUpdateEventTool::new(cal_client.clone())) + .tool(GoogleCalendarDeleteEventTool::new(cal_client.clone())) + .tool(GoogleCalendarRespondEventTool::new(cal_client)) +} + fn memory_save_with_events( memory_search: Arc, agent_id: AgentId, @@ -503,7 +537,7 @@ pub fn create_branch_tool_server( .tool(MemoryDeleteTool::new(memory_search)) .tool(ChannelRecallTool::new(conversation_logger, channel_store)) .tool(SpacebotDocsTool::new()) - .tool(EmailSearchTool::new(runtime_config)) + .tool(EmailSearchTool::new(runtime_config.clone())) .tool(WorkerInspectTool::new(run_logger, agent_id.to_string())) .tool(TaskCreateTool::new( task_store.clone(), @@ -513,6 +547,11 @@ pub fn create_branch_tool_server( .tool(TaskListTool::new(task_store.clone(), agent_id.to_string())) .tool(TaskUpdateTool::for_branch(task_store, agent_id.clone())); + if let Some(cal_config) = runtime_config.google_calendar.load().as_ref().as_ref() { + let cal_client = Arc::new(google_calendar::GoogleCalendarClient::new(cal_config)); + server = register_google_calendar_readonly_tools(server, cal_client); + } + if let Some(state) = state { server = server.tool(SpawnWorkerTool::new(state)); } @@ -573,6 +612,11 @@ pub fn create_worker_tool_server( server = server.tool(WebSearchTool::new(key)); } + if let Some(cal_config) = runtime_config.google_calendar.load().as_ref().as_ref() { + let cal_client = Arc::new(google_calendar::GoogleCalendarClient::new(cal_config)); + server = register_google_calendar_tools(server, cal_client); + } + for mcp_tool in mcp_tools { server = server.tool(mcp_tool); } @@ -671,6 +715,11 @@ pub fn create_cortex_chat_tool_server( server = server.tool(WebSearchTool::new(key)); } + if let Some(cal_config) = runtime_config.google_calendar.load().as_ref().as_ref() { + let cal_client = Arc::new(google_calendar::GoogleCalendarClient::new(cal_config)); + server = register_google_calendar_tools(server, cal_client); + } + server.run() } diff --git a/src/tools/google_calendar.rs b/src/tools/google_calendar.rs new file mode 100644 index 000000000..9b822e88c --- /dev/null +++ b/src/tools/google_calendar.rs @@ -0,0 +1,316 @@ +//! Google Calendar tool integration. +//! +//! Provides tools for interacting with Google Calendar API v3, including +//! listing events, creating/updating/deleting events, listing calendars, +//! finding free time, and responding to event invitations. + +pub mod create_event; +pub mod delete_event; +pub mod find_free_time; +pub mod list_calendars; +pub mod list_events; +pub mod respond_event; +pub mod update_event; + +pub use create_event::GoogleCalendarCreateEventTool; +pub use delete_event::GoogleCalendarDeleteEventTool; +pub use find_free_time::GoogleCalendarFreeTimeTool; +pub use list_calendars::GoogleCalendarListCalendarsTool; +pub use list_events::GoogleCalendarListEventsTool; +pub use respond_event::GoogleCalendarRespondEventTool; +pub use update_event::GoogleCalendarUpdateEventTool; + +use crate::config::GoogleCalendarConfig; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Base URL for Google Calendar API v3. +pub const GOOGLE_CALENDAR_API_BASE: &str = "https://www.googleapis.com/calendar/v3"; + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +/// Error type for Google Calendar operations. +#[derive(Debug, thiserror::Error)] +pub enum GoogleCalendarError { + #[error("Google Calendar request failed: {0}")] + RequestFailed(String), + + #[error("Failed to parse Google Calendar response: {0}")] + InvalidResponse(String), + + #[error("Rate limited by Google Calendar API")] + RateLimited, + + #[error("Failed to refresh access token: {0}")] + TokenRefreshFailed(String), +} + +// --------------------------------------------------------------------------- +// Shared output types +// --------------------------------------------------------------------------- + +/// A Google Calendar event. +#[derive(Debug, Serialize, Deserialize)] +pub struct CalendarEvent { + /// Unique event identifier. + pub id: String, + /// Short summary / title of the event. + pub summary: Option, + /// Longer description of the event. + pub description: Option, + /// When the event starts. + pub start: Option, + /// When the event ends. + pub end: Option, + /// Physical location or meeting room. + pub location: Option, + /// List of attendees. + pub attendees: Option>, + /// Event status (e.g. "confirmed", "tentative", "cancelled"). + pub status: Option, + /// Link to view the event in Google Calendar. + #[serde(rename = "htmlLink")] + pub html_link: Option, +} + +/// Date/time representation used by the Google Calendar API. +/// +/// Exactly one of `date_time` (for timed events) or `date` (for all-day +/// events) will be set. +#[derive(Debug, Serialize, Deserialize)] +pub struct EventDateTime { + /// Combined date-time value (RFC 3339), for timed events. + #[serde(rename = "dateTime")] + pub date_time: Option, + /// Date value (yyyy-MM-dd), for all-day events. + pub date: Option, + /// IANA time zone (e.g. "Europe/Prague"). + #[serde(rename = "timeZone")] + pub time_zone: Option, +} + +/// An event attendee. +#[derive(Debug, Serialize, Deserialize)] +pub struct Attendee { + /// E-mail address of the attendee. + pub email: Option, + /// Human-readable display name. + #[serde(rename = "displayName")] + pub display_name: Option, + /// RSVP status: "needsAction", "declined", "tentative", or "accepted". + #[serde(rename = "responseStatus")] + pub response_status: Option, + /// Whether this attendee entry represents the calendar owner. + #[serde(default, rename = "self")] + pub is_self: bool, +} + +// --------------------------------------------------------------------------- +// Token manager +// --------------------------------------------------------------------------- + +/// Cached OAuth2 access token with its expiry instant. +struct CachedToken { + access_token: String, + expires_at: std::time::Instant, +} + +impl Default for CachedToken { + fn default() -> Self { + Self { + access_token: String::new(), + // Already expired so the first call always refreshes. + expires_at: std::time::Instant::now(), + } + } +} + +/// Response shape returned by the Google OAuth2 token endpoint. +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + expires_in: u64, +} + +/// Thread-safe manager that caches and refreshes Google OAuth2 access tokens. +pub struct TokenManager { + client_id: String, + client_secret: String, + refresh_token: String, + cached: RwLock, +} + +impl TokenManager { + /// Create a new `TokenManager` from OAuth2 credentials. + fn new(client_id: String, client_secret: String, refresh_token: String) -> Self { + Self { + client_id, + client_secret, + refresh_token, + cached: RwLock::new(CachedToken::default()), + } + } + + /// Return a valid access token, refreshing it if necessary. + /// + /// The token is considered stale 60 seconds before its actual expiry to + /// avoid using a token that expires mid-request. + pub async fn get_access_token( + &self, + client: &reqwest::Client, + ) -> Result { + // Fast path: return the cached token if still valid. + { + let cached = self.cached.read().await; + if !cached.access_token.is_empty() + && cached.expires_at + > std::time::Instant::now() + std::time::Duration::from_secs(60) + { + return Ok(cached.access_token.clone()); + } + } + + // Slow path: acquire write lock and re-check before refreshing. + let mut cached = self.cached.write().await; + if !cached.access_token.is_empty() + && cached.expires_at > std::time::Instant::now() + std::time::Duration::from_secs(60) + { + return Ok(cached.access_token.clone()); + } + + let response = client + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("refresh_token", self.refresh_token.as_str()), + ("grant_type", "refresh_token"), + ]) + .send() + .await + .map_err(|e| GoogleCalendarError::TokenRefreshFailed(e.to_string()))?; + + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "failed to read response body".into()); + return Err(GoogleCalendarError::TokenRefreshFailed(format!( + "HTTP {status}: {body}" + ))); + } + + let token_response: TokenResponse = response + .json() + .await + .map_err(|e| GoogleCalendarError::TokenRefreshFailed(e.to_string()))?; + + let expires_at = + std::time::Instant::now() + std::time::Duration::from_secs(token_response.expires_in); + + let access_token = token_response.access_token.clone(); + + // Update the cache (write lock already held from above). + cached.access_token = token_response.access_token; + cached.expires_at = expires_at; + + Ok(access_token) + } +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// HTTP client for the Google Calendar API. +/// +/// Wraps a `reqwest::Client` with automatic OAuth2 token management and +/// standard error handling (rate-limit detection, status code checks). +pub struct GoogleCalendarClient { + client: reqwest::Client, + token_manager: Arc, + default_calendar_id: String, +} + +impl std::fmt::Debug for GoogleCalendarClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GoogleCalendarClient") + .field("default_calendar_id", &self.default_calendar_id) + .finish_non_exhaustive() + } +} + +impl GoogleCalendarClient { + /// Create a new `GoogleCalendarClient` from the provided configuration. + pub fn new(config: &GoogleCalendarConfig) -> Self { + let client = reqwest::Client::builder() + .gzip(true) + .build() + .expect("hardcoded reqwest client config"); + + let token_manager = Arc::new(TokenManager::new( + config.client_id.clone(), + config.client_secret.clone(), + config.refresh_token.clone(), + )); + + Self { + client, + token_manager, + default_calendar_id: config.default_calendar_id.clone(), + } + } + + /// Return the default calendar ID configured for this client. + pub fn default_calendar_id(&self) -> &str { + &self.default_calendar_id + } + + /// Build an authenticated `RequestBuilder` for the given HTTP method and URL. + /// + /// The returned builder already has the `Authorization: Bearer ` header + /// set. Callers can chain additional query parameters, headers, or a body + /// before sending. + pub async fn request( + &self, + method: reqwest::Method, + url: &str, + ) -> Result { + let token = self.token_manager.get_access_token(&self.client).await?; + + Ok(self.client.request(method, url).bearer_auth(token)) + } + + /// Send an already-built request, handling rate limiting and error status codes. + pub async fn send_request( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let response = request + .send() + .await + .map_err(|e| GoogleCalendarError::RequestFailed(e.to_string()))?; + + let status = response.status(); + + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + return Err(GoogleCalendarError::RateLimited); + } + + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "failed to read response body".into()); + return Err(GoogleCalendarError::RequestFailed(format!( + "HTTP {status}: {body}" + ))); + } + + Ok(response) + } +} diff --git a/src/tools/google_calendar/create_event.rs b/src/tools/google_calendar/create_event.rs new file mode 100644 index 000000000..498333a1c --- /dev/null +++ b/src/tools/google_calendar/create_event.rs @@ -0,0 +1,148 @@ +//! Tool for creating events in a Google Calendar. + +use super::{CalendarEvent, GOOGLE_CALENDAR_API_BASE, GoogleCalendarClient, GoogleCalendarError}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::Deserialize; +use std::sync::Arc; + +/// Tool for creating events in a Google Calendar. +#[derive(Debug, Clone)] +pub struct GoogleCalendarCreateEventTool { + client: Arc, +} + +impl GoogleCalendarCreateEventTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +/// Arguments for creating a calendar event. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateEventArgs { + /// Calendar ID to create the event in. Defaults to the configured primary calendar. + pub calendar_id: Option, + /// Short summary / title of the event. + pub summary: String, + /// Longer description of the event. + pub description: Option, + /// Event start time as an RFC 3339 timestamp (e.g. "2026-03-10T09:00:00Z"). + pub start: String, + /// Event end time as an RFC 3339 timestamp (e.g. "2026-03-10T10:00:00Z"). + pub end: String, + /// Physical location or meeting room. + pub location: Option, + /// List of attendee email addresses. + pub attendees: Option>, + /// RRULE strings for recurring events (e.g. ["RRULE:FREQ=WEEKLY;COUNT=10"]). + pub recurrence: Option>, +} + +impl Tool for GoogleCalendarCreateEventTool { + const NAME: &'static str = "google_calendar_create_event"; + + type Error = GoogleCalendarError; + type Args = CreateEventArgs; + type Output = CalendarEvent; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/google_calendar_create_event") + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "calendar_id": { + "type": "string", + "description": "Calendar ID to create the event in. Defaults to the configured primary calendar." + }, + "summary": { + "type": "string", + "description": "Short summary / title of the event." + }, + "description": { + "type": "string", + "description": "Longer description of the event." + }, + "start": { + "type": "string", + "description": "Event start time as an RFC 3339 timestamp (e.g. \"2026-03-10T09:00:00Z\")." + }, + "end": { + "type": "string", + "description": "Event end time as an RFC 3339 timestamp (e.g. \"2026-03-10T10:00:00Z\")." + }, + "location": { + "type": "string", + "description": "Physical location or meeting room." + }, + "attendees": { + "type": "array", + "items": { "type": "string" }, + "description": "List of attendee email addresses." + }, + "recurrence": { + "type": "array", + "items": { "type": "string" }, + "description": "RRULE strings for recurring events (e.g. [\"RRULE:FREQ=WEEKLY;COUNT=10\"])." + } + }, + "required": ["summary", "start", "end"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let calendar_id = args + .calendar_id + .as_deref() + .unwrap_or_else(|| self.client.default_calendar_id()); + + let url = format!( + "{}/calendars/{}/events", + GOOGLE_CALENDAR_API_BASE, calendar_id + ); + + let mut body = serde_json::json!({ + "summary": args.summary, + "start": { "dateTime": args.start }, + "end": { "dateTime": args.end }, + }); + + if let Some(description) = &args.description { + body["description"] = serde_json::json!(description); + } + if let Some(location) = &args.location { + body["location"] = serde_json::json!(location); + } + if let Some(attendees) = &args.attendees { + body["attendees"] = serde_json::json!( + attendees + .iter() + .map(|e| serde_json::json!({"email": e})) + .collect::>() + ); + } + if let Some(recurrence) = &args.recurrence { + body["recurrence"] = serde_json::json!(recurrence); + } + + let request = self + .client + .request(reqwest::Method::POST, &url) + .await? + .json(&body); + + let response = self.client.send_request(request).await?; + + let event: CalendarEvent = response + .json() + .await + .map_err(|e| GoogleCalendarError::InvalidResponse(e.to_string()))?; + + Ok(event) + } +} diff --git a/src/tools/google_calendar/delete_event.rs b/src/tools/google_calendar/delete_event.rs new file mode 100644 index 000000000..da6b5d472 --- /dev/null +++ b/src/tools/google_calendar/delete_event.rs @@ -0,0 +1,90 @@ +//! Tool for deleting an event from a Google Calendar. + +use super::{GOOGLE_CALENDAR_API_BASE, GoogleCalendarClient, GoogleCalendarError}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Tool for deleting an event from a Google Calendar. +#[derive(Debug, Clone)] +pub struct GoogleCalendarDeleteEventTool { + client: Arc, +} + +impl GoogleCalendarDeleteEventTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +/// Arguments for deleting a calendar event. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteEventArgs { + /// Calendar ID to delete the event from. Defaults to the configured primary calendar. + pub calendar_id: Option, + /// The unique identifier of the event to delete. + pub event_id: String, +} + +/// Output from deleting a calendar event. +#[derive(Debug, Serialize)] +pub struct DeleteEventOutput { + /// Whether the event was successfully deleted. + pub deleted: bool, + /// The ID of the deleted event. + pub event_id: String, +} + +impl Tool for GoogleCalendarDeleteEventTool { + const NAME: &'static str = "google_calendar_delete_event"; + + type Error = GoogleCalendarError; + type Args = DeleteEventArgs; + type Output = DeleteEventOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/google_calendar_delete_event") + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "calendar_id": { + "type": "string", + "description": "Calendar ID to delete the event from. Defaults to the configured primary calendar." + }, + "event_id": { + "type": "string", + "description": "The unique identifier of the event to delete." + } + }, + "required": ["event_id"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let calendar_id = args + .calendar_id + .as_deref() + .unwrap_or_else(|| self.client.default_calendar_id()); + + let url = format!( + "{}/calendars/{}/events/{}", + GOOGLE_CALENDAR_API_BASE, calendar_id, args.event_id + ); + + let request = self.client.request(reqwest::Method::DELETE, &url).await?; + + // The DELETE endpoint returns 204 No Content on success — no body to parse. + self.client.send_request(request).await?; + + Ok(DeleteEventOutput { + deleted: true, + event_id: args.event_id, + }) + } +} diff --git a/src/tools/google_calendar/find_free_time.rs b/src/tools/google_calendar/find_free_time.rs new file mode 100644 index 000000000..5bcd4f576 --- /dev/null +++ b/src/tools/google_calendar/find_free_time.rs @@ -0,0 +1,150 @@ +//! Tool for finding free/busy time across Google Calendars. + +use super::{GOOGLE_CALENDAR_API_BASE, GoogleCalendarClient, GoogleCalendarError}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Tool for querying free/busy information from Google Calendar. +#[derive(Debug, Clone)] +pub struct GoogleCalendarFreeTimeTool { + client: Arc, +} + +impl GoogleCalendarFreeTimeTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +/// Arguments for finding free/busy time. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FindFreeTimeArgs { + /// Calendar IDs to check. Defaults to `["primary"]` if not specified. + pub calendar_ids: Option>, + /// Start of the time range to query, as an RFC 3339 timestamp + /// (e.g. "2026-03-10T08:00:00Z"). + pub time_min: String, + /// End of the time range to query, as an RFC 3339 timestamp. + pub time_max: String, +} + +/// Output from the free/busy query. +#[derive(Debug, Serialize)] +pub struct FindFreeTimeOutput { + /// Busy periods found across the queried calendars. + pub busy_periods: Vec, +} + +/// A single busy period on a calendar. +#[derive(Debug, Serialize)] +pub struct BusyPeriod { + /// The calendar ID this busy period belongs to. + pub calendar_id: String, + /// Start of the busy period (RFC 3339). + pub start: String, + /// End of the busy period (RFC 3339). + pub end: String, +} + +/// Private response types for the Google Calendar freeBusy endpoint. +#[derive(Debug, Deserialize)] +struct FreeBusyResponse { + #[serde(default)] + calendars: std::collections::HashMap, +} + +#[derive(Debug, Deserialize)] +struct FreeBusyCalendar { + #[serde(default)] + busy: Vec, +} + +#[derive(Debug, Deserialize)] +struct FreeBusyPeriod { + start: String, + end: String, +} + +impl Tool for GoogleCalendarFreeTimeTool { + const NAME: &'static str = "google_calendar_find_free_time"; + + type Error = GoogleCalendarError; + type Args = FindFreeTimeArgs; + type Output = FindFreeTimeOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/google_calendar_find_free_time") + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "calendar_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Calendar IDs to check for busy time. Defaults to [\"primary\"] if not specified." + }, + "time_min": { + "type": "string", + "description": "Start of the time range to query, as an RFC 3339 timestamp (e.g. \"2026-03-10T08:00:00Z\")." + }, + "time_max": { + "type": "string", + "description": "End of the time range to query, as an RFC 3339 timestamp." + } + }, + "required": ["time_min", "time_max"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let calendar_ids = args + .calendar_ids + .unwrap_or_else(|| vec!["primary".to_string()]); + + let items: Vec = calendar_ids + .iter() + .map(|id| serde_json::json!({ "id": id })) + .collect(); + + let body = serde_json::json!({ + "timeMin": args.time_min, + "timeMax": args.time_max, + "items": items, + }); + + let url = format!("{}/freeBusy", GOOGLE_CALENDAR_API_BASE); + + let request = self + .client + .request(reqwest::Method::POST, &url) + .await? + .json(&body); + + let response = self.client.send_request(request).await?; + + let api_response: FreeBusyResponse = response + .json() + .await + .map_err(|e| GoogleCalendarError::InvalidResponse(e.to_string()))?; + + let mut busy_periods = Vec::new(); + + for (calendar_id, calendar_data) in api_response.calendars { + for period in calendar_data.busy { + busy_periods.push(BusyPeriod { + calendar_id: calendar_id.clone(), + start: period.start, + end: period.end, + }); + } + } + + Ok(FindFreeTimeOutput { busy_periods }) + } +} diff --git a/src/tools/google_calendar/list_calendars.rs b/src/tools/google_calendar/list_calendars.rs new file mode 100644 index 000000000..8b9916e1f --- /dev/null +++ b/src/tools/google_calendar/list_calendars.rs @@ -0,0 +1,89 @@ +//! Tool for listing available Google Calendars. + +use super::{GOOGLE_CALENDAR_API_BASE, GoogleCalendarClient, GoogleCalendarError}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Tool for listing available Google Calendars for the authenticated user. +#[derive(Debug, Clone)] +pub struct GoogleCalendarListCalendarsTool { + client: Arc, +} + +impl GoogleCalendarListCalendarsTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +/// Arguments for listing calendars (none required). +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListCalendarsArgs {} + +/// Output from listing calendars. +#[derive(Debug, Serialize)] +pub struct ListCalendarsOutput { + /// The available calendars. + pub calendars: Vec, +} + +/// Summary information about a single calendar. +#[derive(Debug, Serialize, Deserialize)] +pub struct CalendarSummary { + /// The calendar ID. + pub id: String, + /// Human-readable name of the calendar. + pub summary: Option, + /// Whether this is the user's primary calendar. + pub primary: Option, + /// The effective access role the authenticated user has on the calendar. + #[serde(rename = "accessRole")] + pub access_role: Option, +} + +/// Private response shape for the Google Calendar calendar list endpoint. +#[derive(Debug, Deserialize)] +struct CalendarListResponse { + #[serde(default)] + items: Vec, +} + +impl Tool for GoogleCalendarListCalendarsTool { + const NAME: &'static str = "google_calendar_list_calendars"; + + type Error = GoogleCalendarError; + type Args = ListCalendarsArgs; + type Output = ListCalendarsOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/google_calendar_list_calendars") + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": {} + }), + } + } + + async fn call(&self, _args: Self::Args) -> Result { + let url = format!("{}/users/me/calendarList", GOOGLE_CALENDAR_API_BASE); + + let request = self.client.request(reqwest::Method::GET, &url).await?; + + let response = self.client.send_request(request).await?; + + let api_response: CalendarListResponse = response + .json() + .await + .map_err(|e| GoogleCalendarError::InvalidResponse(e.to_string()))?; + + Ok(ListCalendarsOutput { + calendars: api_response.items, + }) + } +} diff --git a/src/tools/google_calendar/list_events.rs b/src/tools/google_calendar/list_events.rs new file mode 100644 index 000000000..f59b89c94 --- /dev/null +++ b/src/tools/google_calendar/list_events.rs @@ -0,0 +1,138 @@ +//! Tool for listing events from a Google Calendar. + +use super::{CalendarEvent, GOOGLE_CALENDAR_API_BASE, GoogleCalendarClient, GoogleCalendarError}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Tool for listing events from a Google Calendar. +#[derive(Debug, Clone)] +pub struct GoogleCalendarListEventsTool { + client: Arc, +} + +impl GoogleCalendarListEventsTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +/// Arguments for listing calendar events. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListEventsArgs { + /// Calendar ID to list events from. Defaults to the configured primary calendar. + pub calendar_id: Option, + /// Exclusive lower bound on event end time (events whose end time is after this are returned), + /// as an RFC 3339 timestamp (e.g. "2026-03-01T00:00:00Z"). + pub time_min: Option, + /// Upper bound (exclusive) for event start time, as an RFC 3339 timestamp. + pub time_max: Option, + /// Free-text search query to filter events. + pub query: Option, + /// Maximum number of events to return (default 10). + #[serde(default = "default_max_results")] + pub max_results: u32, +} + +fn default_max_results() -> u32 { + 10 +} + +/// Output from listing calendar events. +#[derive(Debug, Serialize)] +pub struct ListEventsOutput { + /// The matching calendar events. + pub events: Vec, +} + +/// Private response shape for the Google Calendar events list endpoint. +#[derive(Debug, Deserialize)] +struct ListEventsResponse { + #[serde(default)] + items: Vec, +} + +impl Tool for GoogleCalendarListEventsTool { + const NAME: &'static str = "google_calendar_list_events"; + + type Error = GoogleCalendarError; + type Args = ListEventsArgs; + type Output = ListEventsOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/google_calendar_list_events").to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "calendar_id": { + "type": "string", + "description": "Calendar ID to list events from. Defaults to the configured primary calendar." + }, + "time_min": { + "type": "string", + "description": "Exclusive lower bound on event end time (events whose end time is after this are returned), as an RFC 3339 timestamp (e.g. \"2026-03-01T00:00:00Z\")." + }, + "time_max": { + "type": "string", + "description": "Upper bound (exclusive) for event start time, as an RFC 3339 timestamp." + }, + "query": { + "type": "string", + "description": "Free-text search query to filter events." + }, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": 2500, + "default": 10, + "description": "Maximum number of events to return (default 10)." + } + } + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let calendar_id = args + .calendar_id + .as_deref() + .unwrap_or_else(|| self.client.default_calendar_id()); + + let url = format!( + "{}/calendars/{}/events", + GOOGLE_CALENDAR_API_BASE, calendar_id + ); + + let request = self.client.request(reqwest::Method::GET, &url).await?; + + let mut request = request + .query(&[("singleEvents", "true")]) + .query(&[("orderBy", "startTime")]) + .query(&[("maxResults", &args.max_results.to_string())]); + + if let Some(time_min) = &args.time_min { + request = request.query(&[("timeMin", time_min)]); + } + if let Some(time_max) = &args.time_max { + request = request.query(&[("timeMax", time_max)]); + } + if let Some(query) = &args.query { + request = request.query(&[("q", query)]); + } + + let response = self.client.send_request(request).await?; + + let api_response: ListEventsResponse = response + .json() + .await + .map_err(|e| GoogleCalendarError::InvalidResponse(e.to_string()))?; + + Ok(ListEventsOutput { + events: api_response.items, + }) + } +} diff --git a/src/tools/google_calendar/respond_event.rs b/src/tools/google_calendar/respond_event.rs new file mode 100644 index 000000000..04621b709 --- /dev/null +++ b/src/tools/google_calendar/respond_event.rs @@ -0,0 +1,131 @@ +//! Tool for responding to an event invitation on Google Calendar. + +use super::{CalendarEvent, GOOGLE_CALENDAR_API_BASE, GoogleCalendarClient, GoogleCalendarError}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::Deserialize; +use std::sync::Arc; + +/// Tool for responding to a Google Calendar event invitation (accept, decline, tentative). +#[derive(Debug, Clone)] +pub struct GoogleCalendarRespondEventTool { + client: Arc, +} + +impl GoogleCalendarRespondEventTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +/// Arguments for responding to a calendar event invitation. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RespondEventArgs { + /// Calendar ID the event belongs to. Defaults to the configured primary calendar. + pub calendar_id: Option, + /// The unique identifier of the event to respond to. + pub event_id: String, + /// RSVP response: one of "accepted", "declined", or "tentative". + pub response: String, +} + +impl Tool for GoogleCalendarRespondEventTool { + const NAME: &'static str = "google_calendar_respond_event"; + + type Error = GoogleCalendarError; + type Args = RespondEventArgs; + type Output = CalendarEvent; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/google_calendar_respond_event") + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "calendar_id": { + "type": "string", + "description": "Calendar ID the event belongs to. Defaults to the configured primary calendar." + }, + "event_id": { + "type": "string", + "description": "The unique identifier of the event to respond to." + }, + "response": { + "type": "string", + "enum": ["accepted", "declined", "tentative"], + "description": "RSVP response: one of \"accepted\", \"declined\", or \"tentative\"." + } + }, + "required": ["event_id", "response"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let calendar_id = args + .calendar_id + .as_deref() + .unwrap_or_else(|| self.client.default_calendar_id()); + + let url = format!( + "{}/calendars/{}/events/{}", + GOOGLE_CALENDAR_API_BASE, calendar_id, args.event_id + ); + + // Validate response value. + if !["accepted", "declined", "tentative"].contains(&args.response.as_str()) { + return Err(GoogleCalendarError::RequestFailed(format!( + "invalid response value '{}': must be one of accepted, declined, tentative", + args.response + ))); + } + + // Fetch current event. + let get_request = self.client.request(reqwest::Method::GET, &url).await?; + + let response = self.client.send_request(get_request).await?; + + let mut event: CalendarEvent = response + .json() + .await + .map_err(|e| GoogleCalendarError::InvalidResponse(e.to_string()))?; + + // Update the self attendee's response status. + let mut self_attendee_found = false; + if let Some(attendees) = &mut event.attendees { + for attendee in attendees.iter_mut() { + if attendee.is_self { + attendee.response_status = Some(args.response.clone()); + self_attendee_found = true; + } + } + } + + if !self_attendee_found { + return Err(GoogleCalendarError::RequestFailed( + "You are not listed as an attendee for this event.".to_string(), + )); + } + + // PATCH back with updated attendees. + let patch_body = serde_json::json!({ "attendees": event.attendees }); + + let patch_request = self + .client + .request(reqwest::Method::PATCH, &url) + .await? + .json(&patch_body); + + let patch_response = self.client.send_request(patch_request).await?; + + let updated_event: CalendarEvent = patch_response + .json() + .await + .map_err(|e| GoogleCalendarError::InvalidResponse(e.to_string()))?; + + Ok(updated_event) + } +} diff --git a/src/tools/google_calendar/update_event.rs b/src/tools/google_calendar/update_event.rs new file mode 100644 index 000000000..884509939 --- /dev/null +++ b/src/tools/google_calendar/update_event.rs @@ -0,0 +1,161 @@ +//! Tool for updating events in a Google Calendar. + +use super::{CalendarEvent, GOOGLE_CALENDAR_API_BASE, GoogleCalendarClient, GoogleCalendarError}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::Deserialize; +use std::sync::Arc; + +/// Tool for updating events in a Google Calendar. +#[derive(Debug, Clone)] +pub struct GoogleCalendarUpdateEventTool { + client: Arc, +} + +impl GoogleCalendarUpdateEventTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +/// Arguments for updating a calendar event. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateEventArgs { + /// Calendar ID containing the event. Defaults to the configured primary calendar. + pub calendar_id: Option, + /// The unique identifier of the event to update. + pub event_id: String, + /// New summary / title for the event. + pub summary: Option, + /// New description for the event. + pub description: Option, + /// New start time as an RFC 3339 timestamp (e.g. "2026-03-10T09:00:00Z"). + pub start: Option, + /// New end time as an RFC 3339 timestamp (e.g. "2026-03-10T10:00:00Z"). + pub end: Option, + /// New physical location or meeting room. + pub location: Option, + /// New list of attendee email addresses (replaces existing attendees). + pub attendees: Option>, + /// Event color ID (1=Lavender, 2=Sage, 3=Grape, 4=Flamingo, 5=Banana, 6=Tangerine, 7=Peacock, 8=Graphite, 9=Blueberry, 10=Basil, 11=Tomato). + pub color_id: Option, +} + +impl Tool for GoogleCalendarUpdateEventTool { + const NAME: &'static str = "google_calendar_update_event"; + + type Error = GoogleCalendarError; + type Args = UpdateEventArgs; + type Output = CalendarEvent; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/google_calendar_update_event") + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "calendar_id": { + "type": "string", + "description": "Calendar ID containing the event. Defaults to the configured primary calendar." + }, + "event_id": { + "type": "string", + "description": "The unique identifier of the event to update." + }, + "summary": { + "type": "string", + "description": "New summary / title for the event." + }, + "description": { + "type": "string", + "description": "New description for the event." + }, + "start": { + "type": "string", + "description": "New start time as an RFC 3339 timestamp (e.g. \"2026-03-10T09:00:00Z\")." + }, + "end": { + "type": "string", + "description": "New end time as an RFC 3339 timestamp (e.g. \"2026-03-10T10:00:00Z\")." + }, + "location": { + "type": "string", + "description": "New physical location or meeting room." + }, + "attendees": { + "type": "array", + "items": { "type": "string" }, + "description": "New list of attendee email addresses (replaces existing attendees)." + }, + "color_id": { + "type": "string", + "description": "Event color ID: 1=Lavender, 2=Sage, 3=Grape, 4=Flamingo, 5=Banana, 6=Tangerine, 7=Peacock, 8=Graphite, 9=Blueberry, 10=Basil, 11=Tomato." + } + }, + "required": ["event_id"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let calendar_id = args + .calendar_id + .as_deref() + .unwrap_or_else(|| self.client.default_calendar_id()); + + let url = format!( + "{}/calendars/{}/events/{}", + GOOGLE_CALENDAR_API_BASE, calendar_id, args.event_id + ); + + let mut body = serde_json::Map::new(); + + if let Some(summary) = &args.summary { + body.insert("summary".into(), serde_json::json!(summary)); + } + if let Some(description) = &args.description { + body.insert("description".into(), serde_json::json!(description)); + } + if let Some(start) = &args.start { + body.insert("start".into(), serde_json::json!({"dateTime": start})); + } + if let Some(end) = &args.end { + body.insert("end".into(), serde_json::json!({"dateTime": end})); + } + if let Some(location) = &args.location { + body.insert("location".into(), serde_json::json!(location)); + } + if let Some(attendees) = &args.attendees { + body.insert( + "attendees".into(), + serde_json::json!( + attendees + .iter() + .map(|e| serde_json::json!({"email": e})) + .collect::>() + ), + ); + } + if let Some(color_id) = &args.color_id { + body.insert("colorId".into(), serde_json::json!(color_id)); + } + + let request = self + .client + .request(reqwest::Method::PATCH, &url) + .await? + .json(&serde_json::Value::Object(body)); + + let response = self.client.send_request(request).await?; + + let event: CalendarEvent = response + .json() + .await + .map_err(|e| GoogleCalendarError::InvalidResponse(e.to_string()))?; + + Ok(event) + } +}