diff --git a/.claude/skills/add-keys/SKILL.md b/.claude/skills/add-keys/SKILL.md index 5e8ef8d..c37b963 100644 --- a/.claude/skills/add-keys/SKILL.md +++ b/.claude/skills/add-keys/SKILL.md @@ -216,3 +216,4 @@ Each plugin has its own skill with the exact script to run: | Discord | `api_key` | `/add-keys/discord` | | Google Calendar | `oauth_2` | `/add-keys/google` | | Google Drive | `oauth_2` | `/add-keys/google` (shares credentials with Calendar) | +| Spotify | `oauth_2` | `/add-keys/spotify` | diff --git a/.claude/skills/add-keys/gmail/SKILL.md b/.claude/skills/add-keys/gmail/SKILL.md new file mode 100644 index 0000000..847faef --- /dev/null +++ b/.claude/skills/add-keys/gmail/SKILL.md @@ -0,0 +1,42 @@ +--- +name: add-keys/gmail +description: Set up Gmail for Corsair. Use when the user wants to connect Gmail, read emails, send emails, or manage their inbox through the agent. +--- + +# Gmail Key Setup + +Read `/add-keys` and `/add-keys/google` first — they cover the shared Google OAuth steps (create Cloud project, OAuth consent screen, OAuth credentials, setup script, OAuth flow). + +**Plugin ID:** `gmail` +**OAuth URL:** `http://localhost:3000/oauth/gmail` + +--- + +## 1. Enable the Gmail API + +In your Google Cloud project, go to **APIs & Services → Library**: +- Search **Gmail API** → **Enable** + +--- + +## 2. Register the plugin in server/corsair.ts + +**Before running the setup script**, check that the plugin is registered. Read `server/corsair.ts` and verify `gmail` is imported and included in the `plugins` array: + +```typescript +import { createCorsair, gmail, slack } from 'corsair'; +export const corsair = createCorsair({ + plugins: [slack(), gmail()], + ... +}); +``` + +If missing, add it now. The container will pick up the change automatically — no restart needed. + +--- + +## 3. Run the common setup + +Follow steps 4–5 from `/add-keys/google` with: +- `PLUGIN = 'gmail'` +- OAuth URL: `http://localhost:3000/oauth/gmail` diff --git a/.claude/skills/add-keys/google-calendar/SKILL.md b/.claude/skills/add-keys/google-calendar/SKILL.md new file mode 100644 index 0000000..0163404 --- /dev/null +++ b/.claude/skills/add-keys/google-calendar/SKILL.md @@ -0,0 +1,272 @@ +--- +name: add-keys/google-calendar +description: Set up Google Calendar for Corsair. Use when the user wants to connect Google Calendar, read events, create calendar events, or manage their schedule through the agent. +--- + +# Google Calendar Key Setup + +Read `/add-keys` and `/add-keys/google` first — they cover the shared Google OAuth steps (create Cloud project, OAuth consent screen, OAuth credentials, setup script, OAuth flow). + +**Plugin ID:** `googlecalendar` +**OAuth URL:** `http://localhost:3000/oauth/googlecalendar` + +--- + +## 1. Enable the Google Calendar API + +In your Google Cloud project, go to **APIs & Services → Library**: +- Search **Google Calendar API** → **Enable** + +--- + +## 2. Register the plugin in server/corsair.ts + +**Before running the setup script**, check that the plugin is registered. Read `server/corsair.ts` and verify `googlecalendar` is imported and included in the `plugins` array: + +```typescript +import { createCorsair, googlecalendar, slack } from 'corsair'; +export const corsair = createCorsair({ + plugins: [slack(), googlecalendar()], + ... +}); +``` + +If missing, add it now. The container will pick up the change automatically — no restart needed. + +--- + +## 3. Run the common setup + +Follow steps 4–5 from `/add-keys/google` with: +- `PLUGIN = 'googlecalendar'` +- OAuth URL: `http://localhost:3000/oauth/googlecalendar` + +--- + +## 4. Webhook Setup (Optional) + +Ask the user: "Would you like to set up webhooks so Corsair can trigger automations when your calendar events change? This requires a public HTTPS URL." + +If no → done. Google Calendar is connected for reading/writing events. + +If yes → continue below. + +--- + +## 5. Get a public webhook URL + +Ask: "Do you already have a public HTTPS URL for this Corsair server (e.g. from Railway, Render, or a VPS)?" + +- If yes → the webhook endpoint is `{their-url}/api/webhook`. Skip to Step 7. +- If no → proceed to Step 6 (ngrok). + +--- + +## 6. Set up ngrok + +Install ngrok if needed: +- Mac: `brew install ngrok` +- Or download from https://ngrok.com/download + +Authenticate: +1. Sign up at https://ngrok.com +2. Go to https://dashboard.ngrok.com/get-started/your-authtoken +3. Run: `ngrok config add-authtoken YOUR_TOKEN` + +Start the tunnel (keep this terminal open): +```bash +ngrok http 3001 +``` + +Copy the HTTPS forwarding URL (e.g. `https://abc123.ngrok-free.app`). The webhook endpoint is `{ngrok-url}/api/webhook`. + +--- + +## 7. Register the watch channel + +Write `scripts/setup-gcal-webhook.ts`, filling in `WEBHOOK_URL` with the full endpoint URL from the previous step: + +```typescript +import 'dotenv/config'; +import * as crypto from 'node:crypto'; +import { corsair } from '../server/corsair'; + +const WEBHOOK_URL = 'REPLACE_WITH_WEBHOOK_URL'; // e.g. https://abc123.ngrok-free.app/api/webhook +const CALENDAR_ID = 'primary'; // or a specific calendar ID + +const main = async () => { + const [clientId, clientSecret, refreshToken] = await Promise.all([ + corsair.keys.googlecalendar.get_client_id(), + corsair.keys.googlecalendar.get_client_secret(), + corsair.googlecalendar.keys.get_refresh_token(), + ]); + + if (!clientId || !clientSecret || !refreshToken) { + console.error('Missing credentials — complete credential setup first.'); + process.exit(1); + } + + // Get a fresh access token + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + }); + + if (!tokenRes.ok) { + console.error('Token refresh failed:', await tokenRes.text()); + process.exit(1); + } + + const { access_token } = (await tokenRes.json()) as { access_token: string }; + + // Create the watch channel + const channelId = crypto.randomUUID(); + + const watchRes = await fetch( + 'https://www.googleapis.com/calendar/v3/calendars/' + + encodeURIComponent(CALENDAR_ID) + '/events/watch', + { + method: 'POST', + headers: { + Authorization: 'Bearer ' + access_token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: channelId, type: 'web_hook', address: WEBHOOK_URL }), + }, + ); + + if (!watchRes.ok) { + console.error('Calendar watch failed:', await watchRes.text()); + process.exit(1); + } + + const data = (await watchRes.json()) as { + id: string; + resourceId: string; + expiration: string; + }; + + const expiration = new Date(Number(data.expiration)).toISOString(); + console.log('✓ Watch channel created'); + console.log(' Channel ID :', channelId); + console.log(' Resource ID:', data.resourceId); + console.log(' Expires :', expiration); + console.log('\nIMPORTANT: Re-run this script before the channel expires to renew it.'); + process.exit(0); +}; + +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +Run it: +```bash +docker compose exec agent pnpm tsx scripts/setup-gcal-webhook.ts +``` + +Then delete the script: +```bash +rm scripts/setup-gcal-webhook.ts +``` + +Report the Channel ID, Resource ID, and expiration to the user. Webhook events will now arrive at `/api/webhook` and trigger any workflows configured with `plugin: 'googlecalendar'`. + +--- + +## 8. Auto-renewal cron workflow + +Google Calendar watch channels expire after ~7 days. Set up a cron workflow that renews the channel every 6 days automatically. The workflow reads credentials from the DB (via `corsair`) and fetches the current ngrok URL from the docker-compose ngrok service at `http://ngrok:4040`. + +Write `scripts/setup-gcal-renewal-workflow.ts`: + +```typescript +import 'dotenv/config'; +import { registerCronWorkflow } from '../server/workflow-scheduler'; +import { storeWorkflow } from '../server/executor'; + +const WORKFLOW_NAME = 'renewGoogleCalendarWatch'; +const CALENDAR_ID = 'primary'; + +const code = ` +async function ${WORKFLOW_NAME}() { + // Fetch the current ngrok public URL from the docker-compose ngrok service + const tunnelsRes = await fetch('http://ngrok:4040/api/tunnels'); + if (!tunnelsRes.ok) throw new Error('Could not reach ngrok API'); + const { tunnels } = await tunnelsRes.json() as { tunnels: Array<{ public_url: string; proto: string }> }; + const tunnel = tunnels.find(t => t.proto === 'https'); + if (!tunnel) throw new Error('No HTTPS ngrok tunnel found'); + const webhookUrl = tunnel.public_url + '/api/webhook'; + + // Get credentials + const [clientId, clientSecret, refreshToken] = await Promise.all([ + corsair.keys.googlecalendar.get_client_id(), + corsair.keys.googlecalendar.get_client_secret(), + corsair.googlecalendar.keys.get_refresh_token(), + ]); + if (!clientId || !clientSecret || !refreshToken) throw new Error('Missing Google credentials'); + + // Refresh access token + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token' }), + }); + if (!tokenRes.ok) throw new Error('Token refresh failed: ' + await tokenRes.text()); + const { access_token } = await tokenRes.json() as { access_token: string }; + + // Register new watch channel + const channelId = crypto.randomUUID(); + const watchRes = await fetch( + 'https://www.googleapis.com/calendar/v3/calendars/' + encodeURIComponent('${CALENDAR_ID}') + '/events/watch', + { + method: 'POST', + headers: { Authorization: 'Bearer ' + access_token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: channelId, type: 'web_hook', address: webhookUrl }), + }, + ); + if (!watchRes.ok) throw new Error('Calendar watch failed: ' + await watchRes.text()); + const data = await watchRes.json() as { id: string; resourceId: string; expiration: string }; + console.log('✓ Watch channel renewed'); + console.log(' Channel ID :', channelId); + console.log(' Resource ID:', data.resourceId); + console.log(' Expires :', new Date(Number(data.expiration)).toISOString()); + console.log(' Webhook URL:', webhookUrl); +} +`; + +async function main() { + // Every 6 days at 00:00 (safe margin before 7-day expiry) + const cronSchedule = '0 0 */6 * *'; + + const workflow = await storeWorkflow({ + type: 'workflow', + workflowId: WORKFLOW_NAME, + code, + description: 'Renews the Google Calendar watch channel every 6 days', + cronSchedule, + }); + + registerCronWorkflow(workflow!.id, WORKFLOW_NAME, code, cronSchedule); + console.log(`✓ Cron workflow "${WORKFLOW_NAME}" registered (${cronSchedule})`); + process.exit(0); +} + +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +Run it: +```bash +docker compose exec agent pnpm tsx scripts/setup-gcal-renewal-workflow.ts +``` + +Then delete the script: +```bash +rm scripts/setup-gcal-renewal-workflow.ts +``` + +The workflow is now stored in the DB and will be loaded automatically on every server restart. It renews the watch channel 6 days after the last renewal, keeping webhooks alive indefinitely. diff --git a/.claude/skills/add-keys/google-drive/SKILL.md b/.claude/skills/add-keys/google-drive/SKILL.md new file mode 100644 index 0000000..6633ee7 --- /dev/null +++ b/.claude/skills/add-keys/google-drive/SKILL.md @@ -0,0 +1,42 @@ +--- +name: add-keys/google-drive +description: Set up Google Drive for Corsair. Use when the user wants to connect Google Drive, read files, upload files, or manage their drive through the agent. +--- + +# Google Drive Key Setup + +Read `/add-keys` and `/add-keys/google` first — they cover the shared Google OAuth steps (create Cloud project, OAuth consent screen, OAuth credentials, setup script, OAuth flow). + +**Plugin ID:** `googledrive` +**OAuth URL:** `http://localhost:3000/oauth/googledrive` + +--- + +## 1. Enable the Google Drive API + +In your Google Cloud project, go to **APIs & Services → Library**: +- Search **Google Drive API** → **Enable** + +--- + +## 2. Register the plugin in server/corsair.ts + +**Before running the setup script**, check that the plugin is registered. Read `server/corsair.ts` and verify `googledrive` is imported and included in the `plugins` array: + +```typescript +import { createCorsair, googledrive, slack } from 'corsair'; +export const corsair = createCorsair({ + plugins: [slack(), googledrive()], + ... +}); +``` + +If missing, add it now. The container will pick up the change automatically — no restart needed. + +--- + +## 3. Run the common setup + +Follow steps 4–5 from `/add-keys/google` with: +- `PLUGIN = 'googledrive'` +- OAuth URL: `http://localhost:3000/oauth/googledrive` diff --git a/.claude/skills/add-keys/google/SKILL.md b/.claude/skills/add-keys/google/SKILL.md index 1e061ce..e890903 100644 --- a/.claude/skills/add-keys/google/SKILL.md +++ b/.claude/skills/add-keys/google/SKILL.md @@ -1,17 +1,17 @@ --- name: add-keys/google -description: Set up Google OAuth credentials for Corsair. Use when the user wants to connect Google Calendar or Google Drive. Both plugins share the same OAuth app and credentials. +description: Common Google OAuth setup for Corsair. Contains shared steps used by all Google plugins (Gmail, Google Calendar, Google Drive). Read this when setting up any Google integration — then read the plugin-specific skill for the remaining steps. --- -# Google Key Setup (Calendar + Drive) +# Google OAuth — Common Setup Read `/add-keys` first if you haven't — it explains the key model. Auth type: **`oauth_2`** -- Integration level: `client_id`, `client_secret`, `redirect_url` (your Google OAuth app — shared) +- Integration level: `client_id`, `client_secret`, `redirect_url` (your Google OAuth app — shared across all Google plugins) - Account level: `access_token`, `refresh_token` (the user's grant — per-tenant) -Google Calendar and Google Drive share the same OAuth app (same client_id/client_secret), but they are **separate plugins** with separate token stores and separate OAuth flows. Set up only the plugin the user asked for. Tell the user upfront: "Google takes more steps than the others, but it's a one-time setup." +Gmail, Google Calendar, and Google Drive share the same OAuth app (same client_id/client_secret), but they are **separate plugins** with separate token stores and separate OAuth flows. Set up only the plugin the user asked for. Tell the user upfront: "Google takes more steps than the others, but it's a one-time setup." --- @@ -23,15 +23,7 @@ Google Calendar and Google Drive share the same OAuth app (same client_id/client --- -## 2. Enable APIs - -Go to **APIs & Services → Library**: -- For Google Calendar: search **Google Calendar API** → **Enable** -- For Google Drive: search **Google Drive API** → **Enable** - ---- - -## 3. Configure OAuth consent screen +## 2. Configure OAuth consent screen Go to **APIs & Services → OAuth consent screen**: 1. Choose **External** → **Create** @@ -42,7 +34,7 @@ Go to **APIs & Services → OAuth consent screen**: --- -## 4. Create OAuth credentials +## 3. Create OAuth credentials Go to **APIs & Services → Credentials → Create Credentials → OAuth client ID**: 1. Application type: **Web application** → Name: "Corsair" @@ -51,39 +43,11 @@ Go to **APIs & Services → Credentials → Create Credentials → OAuth client --- -## 5. Register the plugin in server/corsair.ts +## 4. Write and run the setup script -**Before running the setup script**, check that the plugin is registered in `server/corsair.ts`. Read the file and verify the plugin is imported and included in the `plugins` array. +Ask the user for their Client ID and Client Secret. Fill in `PLUGIN` from the plugin-specific skill (`gmail`, `googlecalendar`, or `googledrive`). -For Google Calendar, it should look like: -```typescript -import { createCorsair, googlecalendar, slack } from 'corsair'; -export const corsair = createCorsair({ - plugins: [slack(), googlecalendar()], - ... -}); -``` - -For Google Drive: -```typescript -import { createCorsair, googledrive, slack } from 'corsair'; -export const corsair = createCorsair({ - plugins: [slack(), googledrive()], - ... -}); -``` - -If the plugin is missing, add it now. The container will pick up the change automatically (via hot reload) — no restart needed. - ---- - -## 6. Write and run the setup script - -Ask the user to provide Client ID and Client Secret. Determine which plugin to set up based on what the user asked for: -- Google Calendar → `PLUGIN = 'googlecalendar'` -- Google Drive → `PLUGIN = 'googledrive'` - -Then write `scripts/setup-google.ts`: +Write `scripts/setup-google.ts`: ```typescript import 'dotenv/config'; @@ -100,8 +64,8 @@ const CLIENT_ID = '...'; const CLIENT_SECRET = '...'; // ───────────────────────────────────────────────────────────────────────────── -// Set to 'googlecalendar' or 'googledrive' depending on what the user asked for -const PLUGIN = 'googledrive'; +// Set to 'googlecalendar', 'googledrive', or 'gmail' +const PLUGIN = '...'; async function main() { console.log(`\nSetting up ${PLUGIN}...`); @@ -155,11 +119,7 @@ async function main() { await accountKeys.issue_new_dek(); console.log(` ✓ Account DEK ready`); - // 5. Print the correct OAuth URL for this plugin - const oauthUrl = PLUGIN === 'googledrive' - ? 'http://localhost:3000/oauth/googledrive' - : 'http://localhost:3000/oauth/googlecalendar'; - console.log(`\n✓ Credentials stored. Now complete OAuth at ${oauthUrl}`); + console.log(`\n✓ Credentials stored. Now complete OAuth at http://localhost:3000/oauth/${PLUGIN}`); process.exit(0); } @@ -180,20 +140,13 @@ rm scripts/setup-google.ts --- -## 7. Complete the OAuth flow - -Tell the user to open the correct URL for the plugin they set up: - -| Plugin | OAuth URL | -|--------|-----------| -| Google Calendar | http://localhost:3000/oauth/googlecalendar | -| Google Drive | http://localhost:3000/oauth/googledrive | +## 5. Complete the OAuth flow -This will: +Tell the user to open the OAuth URL for the plugin they set up (listed in the plugin-specific skill). The flow will: 1. Redirect them to Google's consent screen 2. After they click Allow, redirect back to `/oauth/callback` -3. Automatically exchange the code for tokens and store both `access_token` and `refresh_token` for the correct plugin -4. Show a success page naming the correct plugin +3. Automatically exchange the code for tokens and store both `access_token` and `refresh_token` +4. Show a success page No copying tokens manually — the server handles everything. @@ -201,10 +154,8 @@ No copying tokens manually — the server handles everything. ## Notes -**Token refresh:** The plugin calls `getValidAccessToken()` internally on every request, using the stored `refresh_token` + `client_id` + `client_secret` to get a fresh access token. However, it also requires an `access_token` to be stored — the OAuth flow at step 6 stores both. +**Token refresh:** The plugin calls `getValidAccessToken()` internally on every request, using the stored `refresh_token` + `client_id` + `client_secret` to get a fresh access token. **Token expiry (test mode):** Google OAuth refresh tokens for apps in test mode expire after 7 days of inactivity. To avoid this, publish the app: **OAuth consent screen → Publish App**. The unverified app warning during login is fine for personal use. -**Re-authorizing:** If tokens expire or are revoked, just visit the correct URL for the plugin again: -- Google Calendar: http://localhost:3000/oauth/googlecalendar -- Google Drive: http://localhost:3000/oauth/googledrive +**Re-authorizing:** If tokens expire or are revoked, just visit the plugin's OAuth URL again (listed in the plugin-specific skill). diff --git a/.claude/skills/add-keys/spotify/SKILL.md b/.claude/skills/add-keys/spotify/SKILL.md new file mode 100644 index 0000000..c8714e4 --- /dev/null +++ b/.claude/skills/add-keys/spotify/SKILL.md @@ -0,0 +1,158 @@ +--- +name: add-keys/spotify +description: Set up Spotify OAuth credentials for Corsair. Use when the user wants to connect Spotify to their agent. +--- + +# Spotify Key Setup + +Read `/add-keys` first if you haven't — it explains the key model. + +Auth type: **`oauth_2`** +- Integration level: `client_id`, `client_secret`, `redirect_url` (your Spotify app — shared) +- Account level: `access_token`, `refresh_token`, `expires_at`, `scope` (per-tenant) + +--- + +## 1. Create a Spotify app + +1. Go to https://developer.spotify.com/dashboard +2. Log in and click **Create app** +3. Fill in: + - **App name**: Corsair (or anything you like) + - **App description**: (any) + - **Redirect URI**: `http://localhost:3000/oauth/callback` + - **APIs used**: check **Web API** +4. Agree to the terms → **Save** +5. Open the app → **Settings** +6. Copy the **Client ID** and **Client secret** + +--- + +## 2. Write and run the setup script + +Ask the user to provide the Client ID and Client Secret, then write `scripts/setup-spotify.ts`: + +```typescript +import 'dotenv/config'; +import { and, eq } from 'drizzle-orm'; +import { corsair } from '../server/corsair'; +import { db } from '../server/db'; +import { corsairAccounts, corsairIntegrations } from '../server/db/schema'; + +const PLUGIN = 'spotify'; +const TENANT_ID = 'default'; +const REDIRECT_URL = 'http://localhost:3000/oauth/callback'; + +// ── credentials (fill these in) ─────────────────────────────────────────────── +const CLIENT_ID = '...'; +const CLIENT_SECRET = '...'; +// ───────────────────────────────────────────────────────────────────────────── + +async function main() { + console.log('\nSetting up Spotify...'); + + // 1. Ensure integration row exists + let [integration] = await db + .select() + .from(corsairIntegrations) + .where(eq(corsairIntegrations.name, PLUGIN)); + + if (!integration) { + [integration] = await db + .insert(corsairIntegrations) + .values({ id: crypto.randomUUID(), name: PLUGIN }) + .returning(); + console.log(' ✓ Created integration'); + } + + // 2. Issue integration DEK and store OAuth app credentials + await corsair.keys.spotify.issue_new_dek(); + await corsair.keys.spotify.set_client_id(CLIENT_ID); + await corsair.keys.spotify.set_client_secret(CLIENT_SECRET); + await corsair.keys.spotify.set_redirect_url(REDIRECT_URL); + console.log(' ✓ Integration credentials stored'); + + // 3. Ensure account row exists + const [existing] = await db + .select() + .from(corsairAccounts) + .where( + and( + eq(corsairAccounts.tenantId, TENANT_ID), + eq(corsairAccounts.integrationId, integration!.id), + ), + ); + + if (!existing) { + await db.insert(corsairAccounts).values({ + id: crypto.randomUUID(), + tenantId: TENANT_ID, + integrationId: integration!.id, + }); + console.log(' ✓ Created account'); + } + + // 4. Issue account DEK (tokens come from OAuth flow) + await corsair.spotify.keys.issue_new_dek(); + console.log(' ✓ Account DEK ready'); + + console.log('\n✓ Credentials stored. Now complete OAuth at http://localhost:3000/oauth/spotify'); + process.exit(0); +} + +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +Run it: + +```bash +docker compose exec agent pnpm tsx scripts/setup-spotify.ts +``` + +Then delete the script: + +```bash +rm scripts/setup-spotify.ts +``` + +--- + +## 3. Complete the OAuth flow + +Tell the user to open: + +``` +http://localhost:3000/oauth/spotify +``` + +This will: +1. Redirect them to Spotify's authorization page +2. After they click **Agree**, redirect back to `/oauth/callback` +3. Automatically exchange the code for tokens and store `access_token`, `refresh_token`, `expires_at`, and `scope` +4. Show a success page + +No copying tokens manually — the server handles everything. + +--- + +## 4. Verify + +```bash +docker compose exec agent pnpm tsx -e " +import 'dotenv/config'; +import { corsair } from './server/corsair'; +corsair.spotify.keys.get_access_token().then(k => console.log('token:', k?.slice(0,8) + '...')).then(() => process.exit(0)); +" +``` + +No restart needed — the agent reads from the DB on every request. + +--- + +## Notes + +**Token refresh:** The plugin calls `getValidAccessToken()` internally on every request, using the stored `refresh_token` + `client_id` + `client_secret` to get a fresh access token automatically. + +**Re-authorizing:** If tokens expire or are revoked, just visit `http://localhost:3000/oauth/spotify` again. + +**Scopes:** Spotify scopes are requested during the OAuth flow. If you need additional scopes (e.g. `playlist-modify-public`, `user-read-playback-state`), re-authorize at the OAuth URL above. diff --git a/package.json b/package.json index 2328584..9f8c17f 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,11 @@ "@trpc/server": "^11.10.0", "@whiskeysockets/baileys": "^7.0.0-rc.9", "chokidar": "^4.0.3", - "corsair": "0.1.13", + "corsair": "0.1.16", "dotenv": "^16.4.0", "drizzle-orm": "^0.44.5", "express": "^4.21.0", + "express-rate-limit": "^7.0.0", "grammy": "^1.39.3", "mem0ai": "^2.2.3", "node-cron": "^4.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10c4f27..31e33ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^4.0.3 version: 4.0.3 corsair: - specifier: 0.1.13 - version: 0.1.13 + specifier: 0.1.16 + version: 0.1.16 dotenv: specifier: ^16.4.0 version: 16.6.1 @@ -32,6 +32,9 @@ importers: express: specifier: ^4.21.0 version: 4.22.1 + express-rate-limit: + specifier: ^7.0.0 + version: 7.5.1(express@4.22.1) grammy: specifier: ^1.39.3 version: 1.40.0(encoding@0.1.13) @@ -1525,8 +1528,8 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - corsair@0.1.13: - resolution: {integrity: sha512-BcTK4DuPzNXbhYZ3Qu6b1BIH0dO1+VTScz2m/1BQs3Et35IIQv9uMJhI24Ij07kN4IPJ7YDnS37kFkET4fry9Q==} + corsair@0.1.16: + resolution: {integrity: sha512-RDhlhEiO6DSnoEqamz/p3f6tzNpHPlxtOWDu9ukAFNTJr4gPct9hpq2yfpgfuAed67TKvIJU86kGl7vHu8rWew==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -1828,6 +1831,12 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} @@ -4432,7 +4441,7 @@ snapshots: cookie@0.7.2: {} - corsair@0.1.13: + corsair@0.1.16: dependencies: kysely: 0.28.11 uuid: 13.0.0 @@ -4687,6 +4696,10 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + express-rate-limit@7.5.1(express@4.22.1): + dependencies: + express: 4.22.1 + express@4.22.1: dependencies: accepts: 1.3.8 diff --git a/server/corsair.ts b/server/corsair.ts index d91acf4..43f893f 100644 --- a/server/corsair.ts +++ b/server/corsair.ts @@ -1,8 +1,8 @@ -import { createCorsair, slack } from 'corsair'; +import { createCorsair, googlecalendar, slack, spotify } from 'corsair'; import { pool } from './db'; export const corsair = createCorsair({ - plugins: [slack()], + plugins: [slack(), spotify(), googlecalendar()], database: pool, kek: process.env.CORSAIR_KEK!, multiTenancy: false, diff --git a/server/index.ts b/server/index.ts index bdf33cf..20aa213 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,6 +3,7 @@ import { createExpressMiddleware } from '@trpc/server/adapters/express'; import { processWebhook } from 'corsair'; import { asc, eq } from 'drizzle-orm'; import express from 'express'; +import rateLimit from 'express-rate-limit'; import type { SimpleMessage } from './agent'; import { runAgent, WORKFLOW_FAILURE_PROMPT } from './agent'; import { corsair } from './corsair'; @@ -205,15 +206,29 @@ async function main() { const GOOGLE_PLUGIN_CONFIG = { googlecalendar: { - scope: 'https://www.googleapis.com/auth/calendar', + scope: ['https://www.googleapis.com/auth/calendar'], label: 'Google Calendar', }, googledrive: { - scope: 'https://www.googleapis.com/auth/drive', + scope: ['https://www.googleapis.com/auth/drive'], label: 'Google Drive', }, + gmail: { + scope:[ 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.compose'], + label: 'Gmail', + }, } as const; + const oauthLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, // limit each IP to 20 OAuth requests per window + standardHeaders: true, + legacyHeaders: false, + }); + async function startGoogleOAuth( plugin: keyof typeof GOOGLE_PLUGIN_CONFIG, res: import('express').Response, @@ -229,7 +244,7 @@ async function main() { url.searchParams.set('client_id', creds.client_id); url.searchParams.set('redirect_uri', creds.redirect_url); url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', GOOGLE_PLUGIN_CONFIG[plugin].scope); + url.searchParams.set('scope', GOOGLE_PLUGIN_CONFIG[plugin].scope.join(' ')); url.searchParams.set('access_type', 'offline'); url.searchParams.set('prompt', 'consent'); url.searchParams.set('state', plugin); @@ -241,8 +256,51 @@ async function main() { } } - app.get('/oauth/googlecalendar', (req, res) => startGoogleOAuth('googlecalendar', res)); - app.get('/oauth/googledrive', (req, res) => startGoogleOAuth('googledrive', res)); + app.get('/oauth/googlecalendar', oauthLimiter, (req, res) => + startGoogleOAuth('googlecalendar', res), + ); + app.get('/oauth/googledrive', oauthLimiter, (req, res) => + startGoogleOAuth('googledrive', res), + ); + app.get('/oauth/gmail', oauthLimiter, (req, res) => + startGoogleOAuth('gmail', res), + ); + + // ── Spotify OAuth flow ──────────────────────────────────────────────────── + + app.get('/oauth/spotify', oauthLimiter, async (req, res) => { + try { + const creds = await corsair.spotify.keys.get_integration_credentials(); + if (!creds.client_id || !creds.redirect_url) { + res.status(400).send('Spotify not configured. Run the setup script first.'); + return; + } + const url = new URL('https://accounts.spotify.com/authorize'); + url.searchParams.set('client_id', creds.client_id); + url.searchParams.set('redirect_uri', creds.redirect_url); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', [ + 'user-read-playback-state', + 'user-modify-playback-state', + 'user-read-currently-playing', + 'playlist-read-private', + 'playlist-read-collaborative', + 'playlist-modify-public', + 'playlist-modify-private', + 'user-library-read', + 'user-library-modify', + 'user-read-private', + 'user-read-email', + 'user-top-read', + ].join(' ')); + url.searchParams.set('state', 'spotify'); + console.log('[oauth] Redirecting to Spotify consent screen'); + res.redirect(url.toString()); + } catch (err) { + console.error('[oauth] Failed to build Spotify auth URL:', err); + res.status(500).send('OAuth setup error — check server logs.'); + } + }); app.get('/oauth/callback', async (req, res) => { const { code, error, state } = req.query as { code?: string; error?: string; state?: string }; @@ -256,6 +314,65 @@ async function main() { return; } + // ── Spotify callback ────────────────────────────────────────────────── + if (state === 'spotify') { + try { + const pluginKeys = corsair.spotify.keys; + const creds = await pluginKeys.get_integration_credentials(); + if (!creds.client_id || !creds.client_secret || !creds.redirect_url) { + res.status(400).send('Missing Spotify credentials.'); + return; + } + const basic = Buffer.from(`${creds.client_id}:${creds.client_secret}`).toString('base64'); + const tokenRes = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + code, + redirect_uri: creds.redirect_url, + grant_type: 'authorization_code', + }), + }); + if (!tokenRes.ok) { + const err = await tokenRes.text(); + console.error('[oauth] Spotify token exchange failed:', err); + res.status(500).send(`Token exchange failed: ${err}`); + return; + } + const tokens = await tokenRes.json() as { + access_token: string; + refresh_token?: string; + expires_in: number; + scope: string; + }; + await pluginKeys.set_access_token(tokens.access_token); + if (tokens.refresh_token) await pluginKeys.set_refresh_token(tokens.refresh_token); + await pluginKeys.set_expires_at(new Date(Date.now() + tokens.expires_in * 1000).toISOString()); + await pluginKeys.set_scope(tokens.scope); + console.log('[oauth] Spotify tokens stored successfully'); + res.setHeader('Content-Type', 'text/html').send(` + + + Spotify Connected + +
+
+

Spotify connected!

+

You can close this tab. Your agent is ready to use Spotify.

+
+ + `); + } catch (err) { + console.error('[oauth] Spotify callback error:', err); + res.status(500).send('OAuth callback error — check server logs.'); + } + return; + } + + // ── Google callback ─────────────────────────────────────────────────── const plugin = (state && state in GOOGLE_PLUGIN_CONFIG) ? (state as keyof typeof GOOGLE_PLUGIN_CONFIG) : 'googlecalendar';