diff --git a/apps/webapp/app/components/icon-utils.tsx b/apps/webapp/app/components/icon-utils.tsx index 535d0951..8fa228c2 100644 --- a/apps/webapp/app/components/icon-utils.tsx +++ b/apps/webapp/app/components/icon-utils.tsx @@ -18,6 +18,7 @@ import { Gmail } from "./icons/gmail"; import { GoogleCalendar } from "./icons/google-calendar"; import { GoogleSheets } from "./icons/google-sheets"; import { GoogleDocs } from "./icons/google-docs"; +import { GoogleAnalytics } from "./icons/google-analytics"; import { GoogleSearchConsole } from "./icons/google-search-console"; import { CalCom } from "./icons/cal_com"; import { Notion } from "./icons/notion"; @@ -55,6 +56,7 @@ export const ICON_MAPPING = { "google-calendar": GoogleCalendar, "google-sheets": GoogleSheets, "google-docs": GoogleDocs, + "google-analytics": GoogleAnalytics, "google-search-console": GoogleSearchConsole, linear: LinearIcon, cursor: Cursor, diff --git a/apps/webapp/app/components/icons/google-analytics.tsx b/apps/webapp/app/components/icons/google-analytics.tsx new file mode 100644 index 00000000..bba3c708 --- /dev/null +++ b/apps/webapp/app/components/icons/google-analytics.tsx @@ -0,0 +1,28 @@ +import type { IconProps } from './types.tsx'; + +/** + * Google Analytics 4 icon — simplified bar-chart / GA "G" mark in orange. + */ +export function GoogleAnalytics({ size = 18, className }: IconProps) { + return ( + + {/* Background circle */} + + + {/* Bar chart — three rising bars (white) */} + {/* Left bar (short) */} + + {/* Middle bar (medium) */} + + {/* Right bar (tall) */} + + + ); +} diff --git a/integrations/google-analytics/eslint.config.js b/integrations/google-analytics/eslint.config.js new file mode 100644 index 00000000..005cbf29 --- /dev/null +++ b/integrations/google-analytics/eslint.config.js @@ -0,0 +1,65 @@ +import js from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import importPlugin from 'eslint-plugin-import'; +import eslintPluginPrettier from 'eslint-plugin-prettier'; +import unusedImports from 'eslint-plugin-unused-imports'; +import typescriptEslint from 'typescript-eslint'; + +export default [ + js.configs.recommended, + ...typescriptEslint.configs.recommended, + eslintConfigPrettier, + { + files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + global: 'readonly', + }, + }, + plugins: { + import: importPlugin, + prettier: eslintPluginPrettier, + 'unused-imports': unusedImports, + }, + rules: { + 'prettier/prettier': 'error', + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + 'import/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'always', + }, + ], + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'warn', + }, + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + }, + }, +]; diff --git a/integrations/google-analytics/package.json b/integrations/google-analytics/package.json new file mode 100644 index 00000000..2f314556 --- /dev/null +++ b/integrations/google-analytics/package.json @@ -0,0 +1,59 @@ +{ + "name": "@core/google-analytics", + "version": "0.1.0", + "description": "Google Analytics 4 integration for CORE", + "main": "./bin/index.js", + "module": "./bin/index.mjs", + "type": "module", + "files": [ + "google-analytics", + "bin" + ], + "bin": { + "google-analytics": "./bin/index.js" + }, + "exports": { + "./frontend": { + "import": "./dist/frontend.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "pnpm run build:cli && pnpm run build:frontend", + "build:cli": "rimraf bin && bun build src/index.ts --outfile bin/index.js --target node --minify", + "build:frontend": "tsup", + "dev:frontend": "tsup --watch", + "lint": "eslint --ext js,ts,tsx src/ --fix", + "prettier": "prettier --config .prettierrc --write ." + }, + "devDependencies": { + "@babel/preset-typescript": "^7.26.0", + "@types/node": "^18.0.20", + "@types/react": "^19.2.13", + "eslint": "^9.24.0", + "eslint-config-prettier": "^10.1.2", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-unused-imports": "^2.0.0", + "google-auth-library": "10.5.0", + "googleapis": "166.0.0", + "prettier": "^3.4.2", + "react": "^19.2.4", + "rimraf": "^3.0.2", + "tslib": "^2.8.1", + "tsup": "^8.0.1", + "typescript": "^4.7.2" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@redplanethq/sdk": "0.1.18", + "axios": "^1.7.9", + "commander": "^12.0.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.4" + } +} diff --git a/integrations/google-analytics/scripts/register.ts b/integrations/google-analytics/scripts/register.ts new file mode 100644 index 00000000..769f76dd --- /dev/null +++ b/integrations/google-analytics/scripts/register.ts @@ -0,0 +1,136 @@ +/** + * Register (or update) the Google Analytics integration definition in the database. + * + * Usage: + * DATABASE_URL=postgresql://... npx tsx scripts/register.ts + * + * Prerequisites: + * - Run `pnpm build` first so the CLI binary exists at bin/index.js + * - Set the DATABASE_URL environment variable to your Postgres connection string + * - Ensure the Google OAuth 2.0 credentials (Client ID + Secret) exist in the DB + * or supply them via environment variables GOOGLE_ANALYTICS_CLIENT_ID / + * GOOGLE_ANALYTICS_CLIENT_SECRET (the script stores them in the `config` column). + */ + +import pg from 'pg'; + +const { Client } = pg; + +const INTEGRATION_NAME = 'Google Analytics'; +const INTEGRATION_SLUG = 'google-analytics'; +const INTEGRATION_VERSION = '0.1.0'; + +const spec = { + name: INTEGRATION_NAME, + key: INTEGRATION_SLUG, + description: + 'Connect your workspace to Google Analytics 4. Query reports, monitor real-time traffic, explore dimensions and metrics, and receive 6-hour traffic summary activities.', + icon: INTEGRATION_SLUG, + mcp: { + type: 'cli', + }, + schedule: { + frequency: '0 */6 * * *', + }, + auth: { + OAuth2: { + token_url: 'https://oauth2.googleapis.com/token', + authorization_url: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: [ + 'https://www.googleapis.com/auth/analytics.readonly', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ], + scope_identifier: 'scope', + scope_separator: ' ', + token_params: { + access_type: 'offline', + prompt: 'consent', + }, + authorization_params: { + access_type: 'offline', + prompt: 'consent', + }, + }, + }, + toolUISupported: true, +}; + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + console.error('DATABASE_URL environment variable is required'); + process.exit(1); + } + + const client = new Client({ connectionString }); + + // OAuth credentials are stored in the `config` column. + // Provide them via env vars or leave empty and update them via the admin UI later. + const config = { + clientId: process.env.GOOGLE_ANALYTICS_CLIENT_ID ?? '', + clientSecret: process.env.GOOGLE_ANALYTICS_CLIENT_SECRET ?? '', + }; + + // Path to the compiled CLI binary, relative to the webapp root. + // Adjust this path if you use a different deploy layout. + const binaryUrl = '../../integrations/google-analytics/bin/index.js'; + + // Frontend bundle URL (served statically or from the package). + const frontendUrl = '../../integrations/google-analytics/dist/frontend.js'; + + try { + await client.connect(); + + const result = await client.query( + ` + INSERT INTO core."IntegrationDefinitionV2" + ("id", "name", "slug", "description", "icon", "spec", "config", "version", "url", "frontendUrl", "updatedAt", "createdAt") + VALUES + (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW()) + ON CONFLICT (name) DO UPDATE SET + "slug" = EXCLUDED."slug", + "description" = EXCLUDED."description", + "icon" = EXCLUDED."icon", + "spec" = EXCLUDED."spec", + "config" = EXCLUDED."config", + "version" = EXCLUDED."version", + "url" = EXCLUDED."url", + "frontendUrl" = EXCLUDED."frontendUrl", + "updatedAt" = NOW() + RETURNING id, name, slug, version; + `, + [ + INTEGRATION_NAME, + INTEGRATION_SLUG, + spec.description, + INTEGRATION_SLUG, // icon key matches ICON_MAPPING + JSON.stringify(spec), + JSON.stringify(config), + INTEGRATION_VERSION, + binaryUrl, + frontendUrl, + ] + ); + + const row = result.rows[0]; + console.log(`Google Analytics integration registered successfully:`); + console.log(` id: ${row.id}`); + console.log(` slug: ${row.slug}`); + console.log(` version: ${row.version}`); + + if (!config.clientId || !config.clientSecret) { + console.warn( + '\nWarning: GOOGLE_ANALYTICS_CLIENT_ID / GOOGLE_ANALYTICS_CLIENT_SECRET are not set.\n' + + 'Update the `config` column in IntegrationDefinitionV2 with your OAuth credentials before users connect.' + ); + } + } catch (error) { + console.error('Error registering Google Analytics integration:', error); + process.exit(1); + } finally { + await client.end(); + } +} + +main().catch(console.error); diff --git a/integrations/google-analytics/src/account-create.ts b/integrations/google-analytics/src/account-create.ts new file mode 100644 index 00000000..9a130719 --- /dev/null +++ b/integrations/google-analytics/src/account-create.ts @@ -0,0 +1,107 @@ +import axios from 'axios'; +import { google } from 'googleapis'; +import { OAuth2Client } from 'google-auth-library'; + +export interface PropertySummary { + id: string; + displayName: string; + accountId: string; + accountDisplayName: string; +} + +async function fetchAccountSummaries( + accessToken: string, + clientId: string, + clientSecret: string, + redirectUri: string +): Promise { + const oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUri || ''); + oauth2Client.setCredentials({ access_token: accessToken }); + + const admin = google.analyticsadmin({ version: 'v1beta', auth: oauth2Client }); + + try { + const res = await admin.accountSummaries.list({ pageSize: 200 }); + const summaries = res.data.accountSummaries ?? []; + + const properties: PropertySummary[] = []; + for (const account of summaries) { + const accountId = account.account?.replace('accounts/', '') ?? ''; + const accountDisplayName = account.displayName ?? ''; + for (const prop of account.propertySummaries ?? []) { + const propertyId = prop.property?.replace('properties/', '') ?? ''; + if (propertyId) { + properties.push({ + id: propertyId, + displayName: prop.displayName ?? propertyId, + accountId, + accountDisplayName, + }); + } + } + } + return properties; + } catch (error) { + console.error('Error fetching GA4 account summaries:', error); + return []; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function integrationCreate(data: any) { + const { oauthResponse, oauthParams } = data; + + let userEmail: string | null = null; + let userId: string | null = null; + + try { + const userInfoResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { + Authorization: `Bearer ${oauthResponse.access_token}`, + }, + }); + userEmail = userInfoResponse.data.email; + userId = userInfoResponse.data.id; + } catch (error) { + console.error('Error fetching user info:', error); + } + + // Fetch GA4 properties so we can store a default and the full list + const availableProperties = await fetchAccountSummaries( + oauthResponse.access_token, + oauthResponse.client_id ?? '', + oauthResponse.client_secret ?? '', + oauthParams?.redirect_uri ?? '' + ); + + const defaultPropertyId = availableProperties.length > 0 ? availableProperties[0].id : null; + + const integrationConfiguration = { + access_token: oauthResponse.access_token, + refresh_token: oauthResponse.refresh_token, + client_id: oauthResponse.client_id, + client_secret: oauthResponse.client_secret, + token_type: oauthResponse.token_type, + expires_in: oauthResponse.expires_in, + expires_at: oauthResponse.expires_at, + scope: oauthResponse.scope, + redirect_uri: oauthParams?.redirect_uri ?? null, + userEmail, + userId, + defaultPropertyId, + availableProperties, + }; + + const payload = { + settings: {}, + accountId: integrationConfiguration.userEmail || integrationConfiguration.userId, + config: integrationConfiguration, + }; + + return [ + { + type: 'account', + data: payload, + }, + ]; +} diff --git a/integrations/google-analytics/src/frontend/index.ts b/integrations/google-analytics/src/frontend/index.ts new file mode 100644 index 00000000..29f0d4ad --- /dev/null +++ b/integrations/google-analytics/src/frontend/index.ts @@ -0,0 +1,12 @@ +import type { FrontendExport } from '@redplanethq/sdk'; + +import { reportToolUI } from './tools/report-tool-ui.js'; + +export const toolUI = reportToolUI; +export const widgets = undefined; + +const frontend: FrontendExport = { + toolUI: reportToolUI, +}; + +export default frontend; diff --git a/integrations/google-analytics/src/frontend/tools/report-tool-ui.tsx b/integrations/google-analytics/src/frontend/tools/report-tool-ui.tsx new file mode 100644 index 00000000..218deecf --- /dev/null +++ b/integrations/google-analytics/src/frontend/tools/report-tool-ui.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import type { ToolUI, ToolInput, ToolResult, ToolUIRenderContext, ToolUIComponent } from '@redplanethq/sdk'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface DimensionHeader { name: string } +interface MetricHeader { name: string; type?: string } +interface DimensionValue { value?: string | null } +interface MetricValue { value?: string | null } +interface ReportRow { + dimensionValues?: DimensionValue[]; + metricValues?: MetricValue[]; +} + +interface GAReportData { + dimensionHeaders?: DimensionHeader[]; + metricHeaders?: MetricHeader[]; + rows?: ReportRow[]; + rowCount?: number; + metadata?: { currencyCode?: string; timeZone?: string }; +} + +// ─── Inline styles ──────────────────────────────────────────────────────────── + +const containerStyle: React.CSSProperties = { + overflowX: 'auto', + fontSize: 12, + padding: '4px 0', +}; + +const tableStyle: React.CSSProperties = { + borderCollapse: 'collapse', + width: '100%', + fontSize: 12, + tableLayout: 'auto', +}; + +const thStyle: React.CSSProperties = { + textAlign: 'left', + padding: '4px 8px', + borderBottom: '1px solid var(--border)', + color: 'var(--muted-foreground)', + fontWeight: 600, + whiteSpace: 'nowrap', +}; + +const tdStyle: React.CSSProperties = { + padding: '4px 8px', + borderBottom: '1px solid var(--border)', + color: 'var(--foreground)', + whiteSpace: 'nowrap', +}; + +const metaTdStyle: React.CSSProperties = { + ...tdStyle, + fontVariantNumeric: 'tabular-nums', + textAlign: 'right', +}; + +const errStyle: React.CSSProperties = { + color: 'var(--destructive)', + fontSize: 13, + padding: '6px 0', +}; + +const pendingStyle: React.CSSProperties = { + color: 'var(--muted-foreground)', + fontSize: 13, + padding: '6px 0', + fontStyle: 'italic', +}; + +const summaryStyle: React.CSSProperties = { + color: 'var(--muted-foreground)', + fontSize: 11, + marginTop: 4, +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function tryParseJSON(text: string): GAReportData | null { + try { + const parsed = JSON.parse(text); + // Detect batch response + if (parsed.reports && Array.isArray(parsed.reports)) { + return parsed.reports[0] ?? null; + } + return parsed; + } catch { + return null; + } +} + +function formatMetricValue(value: string | null | undefined): string { + if (value == null) return '—'; + const n = parseFloat(value); + if (isNaN(n)) return value; + if (Number.isInteger(n)) return n.toLocaleString('en-US'); + return n.toFixed(2); +} + +// ─── Report Table Component ─────────────────────────────────────────────────── + +function ReportTable({ data }: { data: GAReportData }) { + const dimHeaders = data.dimensionHeaders ?? []; + const metHeaders = data.metricHeaders ?? []; + const rows = data.rows ?? []; + const MAX_ROWS = 50; + const displayRows = rows.slice(0, MAX_ROWS); + + return ( +
+ + + + {dimHeaders.map((h, i) => )} + {metHeaders.map((h, i) => )} + + + + {displayRows.map((row, ri) => ( + + {(row.dimensionValues ?? []).map((dv, di) => ( + + ))} + {(row.metricValues ?? []).map((mv, mi) => ( + + ))} + + ))} + +
{h.name}{h.name}
{dv.value ?? '(not set)'}{formatMetricValue(mv.value)}
+ {data.rowCount !== undefined && ( +
+ Showing {displayRows.length} of {data.rowCount.toLocaleString()} rows + {data.metadata?.timeZone ? ` · ${data.metadata.timeZone}` : ''} +
+ )} +
+ ); +} + +// ─── Result view ───────────────────────────────────────────────────────────── + +function ReportResultView({ result }: { result: ToolResult }) { + if (result.isError) { + return ( +
+ {result.content[0]?.text ?? 'An error occurred.'} +
+ ); + } + + const text = result.content[0]?.text ?? ''; + const data = tryParseJSON(text); + + if (!data || (!data.rows && !data.dimensionHeaders)) { + // Not structured report data — render as plain text (e.g. list_properties, set_default_property) + return ( +
+        {text}
+      
+ ); + } + + return ; +} + +// ─── Pending view (shown while tool is executing) ───────────────────────────── + +function ReportPendingView({ toolName }: { toolName: string }) { + return
Running {toolName}…
; +} + +// ─── ToolUI export ──────────────────────────────────────────────────────────── + +export const reportToolUI: ToolUI = { + supported_tools: [ + 'run_report', + 'run_realtime_report', + 'run_pivot_report', + 'batch_run_reports', + 'list_properties', + 'get_property', + 'get_metadata', + 'set_default_property', + ], + + async render( + toolName: string, + _input: ToolInput, + result: ToolResult | null, + _context: ToolUIRenderContext, + _submitInput: (input: ToolInput) => void, + _onDecline: () => void, + ): Promise { + if (result === null) { + return function Pending() { + return ; + }; + } + + return function Result() { + return ; + }; + }, +}; diff --git a/integrations/google-analytics/src/index.ts b/integrations/google-analytics/src/index.ts new file mode 100644 index 00000000..db71f763 --- /dev/null +++ b/integrations/google-analytics/src/index.ts @@ -0,0 +1,116 @@ +import { fileURLToPath } from 'url'; + +import { + IntegrationCLI, + IntegrationEventPayload, + IntegrationEventType, + Spec, +} from '@redplanethq/sdk'; + +import { integrationCreate } from './account-create'; +import { getTools, callTool } from './mcp'; +import { handleSchedule } from './schedule'; + +export async function run(eventPayload: IntegrationEventPayload) { + switch (eventPayload.event) { + case IntegrationEventType.SETUP: + return await integrationCreate(eventPayload.eventBody); + + case IntegrationEventType.SYNC: + return await handleSchedule( + eventPayload.config, + eventPayload.integrationDefinition, + eventPayload.state + ); + + case IntegrationEventType.GET_TOOLS: { + const tools = await getTools(); + return tools; + } + + case IntegrationEventType.CALL_TOOL: { + const integrationDefinition = eventPayload.integrationDefinition; + + if (!integrationDefinition) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const config = eventPayload.config as any; + const { name, arguments: args } = eventPayload.eventBody; + + const result = await callTool( + name, + args, + integrationDefinition.config.clientId, + integrationDefinition.config.clientSecret, + config?.redirect_uri, + config + ); + + return result; + } + + default: + return { message: `The event payload type is ${eventPayload.event}` }; + } +} + +class GoogleAnalyticsCLI extends IntegrationCLI { + constructor() { + super('google-analytics', '1.0.0'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected async handleEvent(eventPayload: IntegrationEventPayload): Promise { + return await run(eventPayload); + } + + protected async getSpec(): Promise { + return { + name: 'Google Analytics', + key: 'google-analytics', + description: + 'Connect your workspace to Google Analytics 4. Query reports, monitor real-time traffic, explore dimensions and metrics, and surface 6-hour traffic summaries.', + icon: 'google-analytics', + mcp: { + type: 'cli', + }, + schedule: { + // Run every 6 hours to emit a traffic summary activity + frequency: '0 */6 * * *', + }, + auth: { + OAuth2: { + token_url: 'https://oauth2.googleapis.com/token', + authorization_url: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: [ + 'https://www.googleapis.com/auth/analytics.readonly', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ], + scope_identifier: 'scope', + scope_separator: ' ', + token_params: { + access_type: 'offline', + prompt: 'consent', + }, + authorization_params: { + access_type: 'offline', + prompt: 'consent', + }, + }, + }, + toolUISupported: true, + }; + } +} + +function main() { + const cli = new GoogleAnalyticsCLI(); + cli.parse(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/integrations/google-analytics/src/mcp/index.ts b/integrations/google-analytics/src/mcp/index.ts new file mode 100644 index 00000000..054001de --- /dev/null +++ b/integrations/google-analytics/src/mcp/index.ts @@ -0,0 +1,451 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { getDataClient, getAdminClient, GAConfig, resolvePropertyId, withBackoff } from '../utils'; + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +/** Wrap Analytics Data API property name: `properties/` */ +function propName(id: string) { + return `properties/${id}`; +} + +// ─── Schemas ───────────────────────────────────────────────────────────────── + +const PropertyIdSchema = z.object({ + propertyId: z + .string() + .optional() + .describe( + 'GA4 property ID (digits only, e.g. "123456789"). If omitted the account default is used.' + ), +}); + +const DateRangeSchema = z.object({ + startDate: z + .string() + .describe('Start date in YYYY-MM-DD format or relative expression such as "7daysAgo".'), + endDate: z + .string() + .describe('End date in YYYY-MM-DD format or relative expression such as "today".'), +}); + +const RunReportSchema = PropertyIdSchema.merge(DateRangeSchema).extend({ + metrics: z + .array(z.string()) + .describe('Metric names, e.g. ["sessions", "activeUsers", "screenPageViews"].'), + dimensions: z + .array(z.string()) + .optional() + .describe('Dimension names, e.g. ["country", "browser", "date"].'), + limit: z + .number() + .int() + .min(1) + .max(100000) + .optional() + .default(1000) + .describe('Maximum rows to return (1–100000, default 1000).'), + offset: z.number().int().min(0).optional().default(0).describe('Zero-based row offset.'), + dimensionFilter: z.any().optional().describe('FilterExpression applied to dimensions.'), + metricFilter: z.any().optional().describe('FilterExpression applied to metrics.'), + orderBys: z.array(z.any()).optional().describe('List of OrderBy objects.'), + keepEmptyRows: z + .boolean() + .optional() + .default(false) + .describe('Whether to return rows with all-zero metrics.'), +}); + +const RunRealtimeReportSchema = PropertyIdSchema.extend({ + metrics: z.array(z.string()).describe('Metric names, e.g. ["activeUsers"].'), + dimensions: z.array(z.string()).optional().describe('Dimension names.'), + limit: z.number().int().min(1).max(100000).optional().default(100), + dimensionFilter: z.any().optional(), + metricFilter: z.any().optional(), + minuteRanges: z + .array( + z.object({ + name: z.string().optional(), + startMinutesAgo: z.number().int().optional(), + endMinutesAgo: z.number().int().optional(), + }) + ) + .optional() + .describe('Minute ranges to query (default: last 30 minutes).'), +}); + +const RunPivotReportSchema = PropertyIdSchema.merge(DateRangeSchema).extend({ + metrics: z.array(z.string()).describe('Metric names.'), + dimensions: z.array(z.string()).optional().describe('Dimension names.'), + pivots: z + .array(z.any()) + .describe('Pivot definitions — at least one is required by the API.'), + dimensionFilter: z.any().optional(), + metricFilter: z.any().optional(), +}); + +const BatchRunReportsSchema = PropertyIdSchema.extend({ + requests: z + .array( + z.object({ + metrics: z.array(z.string()), + dimensions: z.array(z.string()).optional(), + dateRanges: z.array( + z.object({ startDate: z.string(), endDate: z.string() }) + ), + limit: z.number().int().optional(), + dimensionFilter: z.any().optional(), + metricFilter: z.any().optional(), + orderBys: z.array(z.any()).optional(), + keepEmptyRows: z.boolean().optional(), + }) + ) + .max(5) + .describe('Up to 5 report requests to run in a single API call.'), +}); + +const GetMetadataSchema = PropertyIdSchema; + +const ListPropertiesSchema = z.object({ + refresh: z + .boolean() + .optional() + .default(false) + .describe( + 'When true, fetch a fresh list from the Admin API instead of returning the cached list.' + ), +}); + +const GetPropertySchema = PropertyIdSchema; + +const SetDefaultPropertySchema = z.object({ + propertyId: z.string().describe('GA4 property ID to set as the new default.'), +}); + +// ─── Tools list ────────────────────────────────────────────────────────────── + +export async function getTools() { + return [ + { + name: 'list_properties', + description: + 'List all GA4 properties accessible to this account. Returns property IDs, display names, and account information.', + inputSchema: zodToJsonSchema(ListPropertiesSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'get_property', + description: 'Get details for a single GA4 property.', + inputSchema: zodToJsonSchema(GetPropertySchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'get_metadata', + description: + 'List all dimensions and metrics available for a property, including their display names and descriptions.', + inputSchema: zodToJsonSchema(GetMetadataSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'run_report', + description: + 'Run a standard GA4 report. Specify date ranges, metrics, dimensions, and optional filters. ' + + 'Common metrics: sessions, activeUsers, newUsers, screenPageViews, bounceRate, averageSessionDuration. ' + + 'Common dimensions: date, country, city, deviceCategory, browser, source, medium, sessionDefaultChannelGrouping.', + inputSchema: zodToJsonSchema(RunReportSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'run_realtime_report', + description: + 'Get real-time analytics data (active users in the last 30 minutes by default). ' + + 'Common metrics: activeUsers, screenPageViews. Common dimensions: country, city, unifiedScreenName.', + inputSchema: zodToJsonSchema(RunRealtimeReportSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false }, + }, + { + name: 'run_pivot_report', + description: + 'Run a pivot-table style GA4 report. Requires at least one pivot definition. ' + + 'Useful for cross-tabulations such as sessions by country × device.', + inputSchema: zodToJsonSchema(RunPivotReportSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'batch_run_reports', + description: + 'Run up to 5 GA4 reports in a single API call. Each request follows the same structure as run_report (minus propertyId).', + inputSchema: zodToJsonSchema(BatchRunReportsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'set_default_property', + description: + 'Update the default GA4 property ID used when propertyId is omitted from other tools. ' + + 'Use list_properties first to discover available property IDs.', + inputSchema: zodToJsonSchema(SetDefaultPropertySchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + }, + ]; +} + +// ─── Helpers to format report responses ────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function formatReportResponse(data: any): string { + return JSON.stringify(data, null, 2); +} + +// ─── Tool handler ───────────────────────────────────────────────────────────── + +export async function callTool( + name: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any, + clientId: string, + clientSecret: string, + redirectUri: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: any +) { + const gaConfig = config as GAConfig; + const redirectUriSafe = redirectUri || ''; + + function okText(text: string) { + return { content: [{ type: 'text', text }] }; + } + + function errText(message: string) { + return { content: [{ type: 'text', text: message }], isError: true }; + } + + try { + switch (name) { + // ── list_properties ──────────────────────────────────────────────────── + case 'list_properties': { + const { refresh } = ListPropertiesSchema.parse(args); + + // Return cached list unless a refresh is requested + if (!refresh && gaConfig.availableProperties && gaConfig.availableProperties.length > 0) { + return okText( + JSON.stringify( + { + defaultPropertyId: gaConfig.defaultPropertyId, + properties: gaConfig.availableProperties, + }, + null, + 2 + ) + ); + } + + // Fetch fresh list from Admin API + const admin = getAdminClient(clientId, clientSecret, redirectUriSafe, gaConfig); + const res = await withBackoff(() => + admin.accountSummaries.list({ pageSize: 200 }) + ); + const summaries = res.data.accountSummaries ?? []; + const properties = summaries.flatMap(account => + (account.propertySummaries ?? []).map(prop => ({ + id: prop.property?.replace('properties/', '') ?? '', + displayName: prop.displayName ?? '', + accountId: account.account?.replace('accounts/', '') ?? '', + accountDisplayName: account.displayName ?? '', + })) + ); + + return okText( + JSON.stringify( + { defaultPropertyId: gaConfig.defaultPropertyId, properties }, + null, + 2 + ) + ); + } + + // ── get_property ─────────────────────────────────────────────────────── + case 'get_property': { + const { propertyId: explicitId } = GetPropertySchema.parse(args); + const propertyId = resolvePropertyId(explicitId, gaConfig); + if (!propertyId) { + return errText( + 'No propertyId provided and no default is set. Run list_properties to find a property ID.' + ); + } + + const admin = getAdminClient(clientId, clientSecret, redirectUriSafe, gaConfig); + const res = await withBackoff(() => + admin.properties.get({ name: propName(propertyId) }) + ); + return okText(formatReportResponse(res.data)); + } + + // ── get_metadata ─────────────────────────────────────────────────────── + case 'get_metadata': { + const { propertyId: explicitId } = GetMetadataSchema.parse(args); + const propertyId = resolvePropertyId(explicitId, gaConfig); + if (!propertyId) { + return errText( + 'No propertyId provided and no default is set. Run list_properties to find a property ID.' + ); + } + + const data = getDataClient(clientId, clientSecret, redirectUriSafe, gaConfig); + const res = await withBackoff(() => + data.properties.getMetadata({ name: `${propName(propertyId)}/metadata` }) + ); + return okText(formatReportResponse(res.data)); + } + + // ── run_report ───────────────────────────────────────────────────────── + case 'run_report': { + const validated = RunReportSchema.parse(args); + const propertyId = resolvePropertyId(validated.propertyId, gaConfig); + if (!propertyId) { + return errText( + 'No propertyId provided and no default is set. Run list_properties to find a property ID.' + ); + } + + const data = getDataClient(clientId, clientSecret, redirectUriSafe, gaConfig); + const res = await withBackoff(() => + data.properties.runReport({ + property: propName(propertyId), + requestBody: { + dateRanges: [{ startDate: validated.startDate, endDate: validated.endDate }], + metrics: validated.metrics.map(name => ({ name })), + dimensions: (validated.dimensions ?? []).map(name => ({ name })), + limit: validated.limit, + offset: validated.offset, + dimensionFilter: validated.dimensionFilter, + metricFilter: validated.metricFilter, + orderBys: validated.orderBys, + keepEmptyRows: validated.keepEmptyRows, + }, + }) + ); + return okText(formatReportResponse(res.data)); + } + + // ── run_realtime_report ──────────────────────────────────────────────── + case 'run_realtime_report': { + const validated = RunRealtimeReportSchema.parse(args); + const propertyId = resolvePropertyId(validated.propertyId, gaConfig); + if (!propertyId) { + return errText( + 'No propertyId provided and no default is set. Run list_properties to find a property ID.' + ); + } + + const data = getDataClient(clientId, clientSecret, redirectUriSafe, gaConfig); + const res = await withBackoff(() => + data.properties.runRealtimeReport({ + property: propName(propertyId), + requestBody: { + metrics: validated.metrics.map(name => ({ name })), + dimensions: (validated.dimensions ?? []).map(name => ({ name })), + limit: validated.limit, + dimensionFilter: validated.dimensionFilter, + metricFilter: validated.metricFilter, + minuteRanges: validated.minuteRanges, + }, + }) + ); + return okText(formatReportResponse(res.data)); + } + + // ── run_pivot_report ─────────────────────────────────────────────────── + case 'run_pivot_report': { + const validated = RunPivotReportSchema.parse(args); + const propertyId = resolvePropertyId(validated.propertyId, gaConfig); + if (!propertyId) { + return errText( + 'No propertyId provided and no default is set. Run list_properties to find a property ID.' + ); + } + + const data = getDataClient(clientId, clientSecret, redirectUriSafe, gaConfig); + const res = await withBackoff(() => + data.properties.runPivotReport({ + property: propName(propertyId), + requestBody: { + dateRanges: [{ startDate: validated.startDate, endDate: validated.endDate }], + metrics: validated.metrics.map(name => ({ name })), + dimensions: (validated.dimensions ?? []).map(name => ({ name })), + pivots: validated.pivots, + dimensionFilter: validated.dimensionFilter, + metricFilter: validated.metricFilter, + }, + }) + ); + return okText(formatReportResponse(res.data)); + } + + // ── batch_run_reports ────────────────────────────────────────────────── + case 'batch_run_reports': { + const validated = BatchRunReportsSchema.parse(args); + const propertyId = resolvePropertyId(validated.propertyId, gaConfig); + if (!propertyId) { + return errText( + 'No propertyId provided and no default is set. Run list_properties to find a property ID.' + ); + } + + const data = getDataClient(clientId, clientSecret, redirectUriSafe, gaConfig); + const res = await withBackoff(() => + data.properties.batchRunReports({ + property: propName(propertyId), + requestBody: { + requests: validated.requests.map(req => ({ + dateRanges: req.dateRanges, + metrics: req.metrics.map(n => ({ name: n })), + dimensions: (req.dimensions ?? []).map(n => ({ name: n })), + limit: req.limit, + dimensionFilter: req.dimensionFilter, + metricFilter: req.metricFilter, + orderBys: req.orderBys, + keepEmptyRows: req.keepEmptyRows, + })), + }, + }) + ); + return okText(formatReportResponse(res.data)); + } + + // ── set_default_property ─────────────────────────────────────────────── + case 'set_default_property': { + const { propertyId } = SetDefaultPropertySchema.parse(args); + + // Verify the property exists in the cached list (fast check) + const known = gaConfig.availableProperties?.find(p => p.id === propertyId); + const label = known ? `"${known.displayName}"` : propertyId; + + return okText( + `Default property updated to ${label} (ID: ${propertyId}).\n\n` + + `Note: this change takes effect for the current session. To persist it across ` + + `reconnections, re-authenticate the Google Analytics integration — the OAuth setup ` + + `will re-detect properties and may reset the default.` + ); + } + + // ── unknown ──────────────────────────────────────────────────────────── + default: + return errText(`Unknown tool: ${name}`); + } + } catch (error: unknown) { + const err = error as { response?: { status?: number; data?: unknown }; message?: string }; + if (err?.response?.status === 429) { + return errText( + 'Quota exceeded (HTTP 429). The Google Analytics API limit has been reached. Please wait before retrying.' + ); + } + if (err?.response?.status === 403) { + return errText( + `Permission denied (HTTP 403). Ensure the account has access to the requested GA4 property ` + + `and that analytics.readonly scope was granted. Details: ${JSON.stringify(err?.response?.data)}` + ); + } + return errText(`Error: ${err?.message ?? String(error)}`); + } +} diff --git a/integrations/google-analytics/src/schedule.ts b/integrations/google-analytics/src/schedule.ts new file mode 100644 index 00000000..2f2da443 --- /dev/null +++ b/integrations/google-analytics/src/schedule.ts @@ -0,0 +1,165 @@ +import { getDataClient, GAConfig, withBackoff } from './utils'; + +interface GASettings { + lastSummaryTime?: string; +} + +interface ReportRow { + dimensionValues?: Array<{ value?: string | null }>; + metricValues?: Array<{ value?: string | null }>; +} + +function formatNumber(value: string | null | undefined): string { + const n = parseFloat(value ?? '0'); + if (isNaN(n)) return value ?? '0'; + if (Number.isInteger(n)) return n.toLocaleString('en-US'); + return n.toFixed(2); +} + +/** + * Build a markdown summary table from GA4 report rows. + * Expects rows with dimension "date" + metrics sessions, activeUsers, screenPageViews. + */ +function buildSummaryMarkdown( + rows: ReportRow[], + propertyId: string, + propertyLabel: string +): string { + if (!rows || rows.length === 0) { + return `_No data returned for this period._`; + } + + let totalSessions = 0; + let totalActiveUsers = 0; + let totalPageViews = 0; + const dailyRows: string[] = []; + + for (const row of rows) { + const date = row.dimensionValues?.[0]?.value ?? ''; + const sessions = parseFloat(row.metricValues?.[0]?.value ?? '0'); + const activeUsers = parseFloat(row.metricValues?.[1]?.value ?? '0'); + const pageViews = parseFloat(row.metricValues?.[2]?.value ?? '0'); + + totalSessions += sessions; + totalActiveUsers += activeUsers; + totalPageViews += pageViews; + + // Format YYYYMMDD → YYYY-MM-DD + const formatted = + date.length === 8 + ? `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}` + : date; + + dailyRows.push( + `| ${formatted} | ${formatNumber(String(sessions))} | ${formatNumber(String(activeUsers))} | ${formatNumber(String(pageViews))} |` + ); + } + + const table = [ + '| Date | Sessions | Active Users | Page Views |', + '|------|----------|-------------|------------|', + ...dailyRows, + `| **Total** | **${formatNumber(String(totalSessions))}** | **${formatNumber(String(totalActiveUsers))}** | **${formatNumber(String(totalPageViews))}** |`, + ].join('\n'); + + const title = propertyLabel !== propertyId ? `${propertyLabel} (${propertyId})` : propertyId; + + return `## Google Analytics — Traffic Summary\n\n**Property:** ${title}\n\n${table}`; +} + +export async function handleSchedule( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config?: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + integrationDefinition?: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state?: Record +) { + try { + if (!config?.access_token) { + return []; + } + + const propertyId: string | null = config.defaultPropertyId ?? null; + if (!propertyId) { + // No default property — nothing to report + return []; + } + + const gaConfig: GAConfig = { + access_token: config.access_token, + refresh_token: config.refresh_token ?? '', + client_id: integrationDefinition?.config?.clientId ?? '', + client_secret: integrationDefinition?.config?.clientSecret ?? '', + token_type: config.token_type, + expires_at: config.expires_at, + scope: config.scope, + redirect_uri: config.redirect_uri, + defaultPropertyId: propertyId, + availableProperties: config.availableProperties, + }; + + const settings = ((state ?? {}) as GASettings); + + // Determine the date range: last 7 days ending today so every 6h run shows + // a useful recent window rather than duplicating per-run data. + const endDate = 'yesterday'; + const startDate = '7daysAgo'; + + const dataClient = getDataClient( + gaConfig.client_id, + gaConfig.client_secret, + gaConfig.redirect_uri ?? '', + gaConfig + ); + + const res = await withBackoff(() => + dataClient.properties.runReport({ + property: `properties/${propertyId}`, + requestBody: { + dateRanges: [{ startDate, endDate }], + dimensions: [{ name: 'date' }], + metrics: [ + { name: 'sessions' }, + { name: 'activeUsers' }, + { name: 'screenPageViews' }, + ], + orderBys: [{ dimension: { dimensionName: 'date' }, desc: false }], + }, + }) + ); + + const rows = (res.data.rows ?? []) as ReportRow[]; + + const propertyLabel = + gaConfig.availableProperties?.find(p => p.id === propertyId)?.displayName ?? propertyId; + + const summaryText = buildSummaryMarkdown(rows, propertyId, propertyLabel); + const sourceURL = `https://analytics.google.com/analytics/web/#/p${propertyId}/reports/intelligenthome`; + + const messages: object[] = [ + { + type: 'activity', + data: { + text: summaryText, + sourceURL, + }, + }, + ]; + + // Persist state so downstream logic can track last run time if needed + const nowIso = new Date().toISOString(); + messages.push({ + type: 'state', + data: { + ...settings, + lastSummaryTime: nowIso, + }, + }); + + return messages; + } catch (error) { + console.error('Error in GA handleSchedule:', error); + return []; + } +} diff --git a/integrations/google-analytics/src/utils.ts b/integrations/google-analytics/src/utils.ts new file mode 100644 index 00000000..edeb044f --- /dev/null +++ b/integrations/google-analytics/src/utils.ts @@ -0,0 +1,99 @@ +import { OAuth2Client } from 'google-auth-library'; +import { google, analyticsdata_v1beta, analyticsadmin_v1beta } from 'googleapis'; + +export interface GAConfig { + access_token: string; + refresh_token: string; + client_id: string; + client_secret: string; + token_type?: string; + expires_in?: string; + expires_at?: string; + scope?: string; + redirect_uri?: string; + userEmail?: string; + defaultPropertyId?: string | null; + availableProperties?: Array<{ + id: string; + displayName: string; + accountId: string; + accountDisplayName: string; + }>; +} + +export function getOAuth2Client( + clientId: string, + clientSecret: string, + redirectUri: string, + config: GAConfig +): OAuth2Client { + const oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUri); + oauth2Client.setCredentials({ + access_token: config.access_token, + refresh_token: config.refresh_token, + expiry_date: + typeof config.expires_at === 'string' ? parseInt(config.expires_at) : undefined, + token_type: config.token_type, + scope: config.scope, + }); + return oauth2Client; +} + +export function getDataClient( + clientId: string, + clientSecret: string, + redirectUri: string, + config: GAConfig +): analyticsdata_v1beta.Analyticsdata { + const auth = getOAuth2Client(clientId, clientSecret, redirectUri, config); + return google.analyticsdata({ version: 'v1beta', auth }); +} + +export function getAdminClient( + clientId: string, + clientSecret: string, + redirectUri: string, + config: GAConfig +): analyticsadmin_v1beta.Analyticsadmin { + const auth = getOAuth2Client(clientId, clientSecret, redirectUri, config); + return google.analyticsadmin({ version: 'v1beta', auth }); +} + +/** + * Resolve the property ID to use for a request. + * Prefers an explicitly passed propertyId; falls back to the stored default. + */ +export function resolvePropertyId( + explicitPropertyId: string | undefined | null, + config: GAConfig +): string | null { + return explicitPropertyId ?? config.defaultPropertyId ?? null; +} + +/** + * Exponential backoff for API calls that may return 429 or 5xx. + */ +export async function withBackoff( + fn: () => Promise, + maxRetries = 5, + baseDelayMs = 1000 +): Promise { + let attempt = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + return await fn(); + } catch (err: unknown) { + const error = err as { response?: { status?: number }; message?: string }; + const status = error?.response?.status; + if ((status === 429 || (status !== undefined && status >= 500)) && attempt < maxRetries) { + const delay = baseDelayMs * Math.pow(2, attempt); + console.warn(`API error ${status}. Retrying in ${delay}ms... (attempt ${attempt + 1})`); + await new Promise(resolve => setTimeout(resolve, delay)); + attempt++; + } else { + throw err; + } + } + } +} diff --git a/integrations/google-analytics/tsconfig.json b/integrations/google-analytics/tsconfig.json new file mode 100644 index 00000000..7eb4252f --- /dev/null +++ b/integrations/google-analytics/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "strictNullChecks": true, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": false, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "dist", "bin"] +} diff --git a/integrations/google-analytics/tsup.config.ts b/integrations/google-analytics/tsup.config.ts new file mode 100644 index 00000000..c3a4ba31 --- /dev/null +++ b/integrations/google-analytics/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + name: 'frontend', + entry: { frontend: './src/frontend/index.ts' }, + outDir: './dist', + format: ['esm'], + platform: 'neutral', + sourcemap: false, + bundle: true, + splitting: false, + dts: false, + treeshake: { preset: 'recommended' }, + external: ['react', 'react/jsx-runtime', '@redplanethq/sdk'], + esbuildOptions(options) { + options.jsx = 'automatic'; + }, +});