mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
Integrate Google Calendar sync functionality with Composio, enhancing the connection handling in composio-handler and oauth-handler. Update onboarding modal and connectors-popover to manage connection states and provide user feedback during the sync process. Implement Composio-based event syncing in sync_calendar.ts.
This commit is contained in:
parent
71e2e43ed1
commit
bc54ef2177
7 changed files with 267 additions and 72 deletions
|
|
@ -5,6 +5,7 @@ import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
|
|||
import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js';
|
||||
import { z } from 'zod';
|
||||
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
|
||||
|
||||
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
|
||||
|
||||
|
|
@ -145,7 +146,11 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
|
||||
// Set up callback server
|
||||
let cleanupTimeout: NodeJS.Timeout;
|
||||
let callbackHandled = false;
|
||||
const { server } = await createAuthServer(8081, async (_code, _state) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
// OAuth callback received - sync the account status
|
||||
try {
|
||||
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
|
||||
|
|
@ -156,6 +161,9 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
|||
if (toolkitSlug === 'gmail') {
|
||||
triggerGmailSync();
|
||||
}
|
||||
if (toolkitSlug === 'googlecalendar') {
|
||||
triggerCalendarSync();
|
||||
}
|
||||
} else {
|
||||
emitComposioEvent({
|
||||
toolkitSlug,
|
||||
|
|
|
|||
|
|
@ -186,7 +186,11 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
|||
});
|
||||
|
||||
// Create callback server
|
||||
let callbackHandled = false;
|
||||
const { server } = await createAuthServer(8080, async (code, receivedState) => {
|
||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||
if (callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
// Validate state
|
||||
if (receivedState !== state) {
|
||||
throw new Error('Invalid state parameter - possible CSRF attack');
|
||||
|
|
|
|||
|
|
@ -496,7 +496,10 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
setGoogleCalendarConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Google Calendar')
|
||||
toast.success('Connected to Google Calendar', {
|
||||
description: 'Syncing your calendar in the background. This may take a few minutes before changes appear.',
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Google Calendar')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -538,10 +538,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
}, [open, providers, refreshAllStatuses])
|
||||
|
||||
// Listen for OAuth completion events
|
||||
// Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover)
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
const { provider, success, error } = event
|
||||
const { provider, success } = event
|
||||
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
|
|
@ -551,13 +551,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
isConnecting: false,
|
||||
}
|
||||
}))
|
||||
|
||||
if (success) {
|
||||
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
toast.success(`Connected to ${displayName}`)
|
||||
} else {
|
||||
toast.error(error || `Failed to connect to ${provider}`)
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
|
|
@ -576,34 +569,19 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
return cleanup
|
||||
}, [onboardingPath, currentStep])
|
||||
|
||||
// Listen for Composio connection events (Gmail, Google Calendar)
|
||||
// Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover)
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success, error } = event
|
||||
const { toolkitSlug, success } = event
|
||||
|
||||
if (toolkitSlug === 'gmail') {
|
||||
setGmailConnected(success)
|
||||
setGmailConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Gmail', {
|
||||
description: 'Syncing your emails in the background. This may take a few minutes before changes appear.',
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Gmail')
|
||||
}
|
||||
}
|
||||
|
||||
if (toolkitSlug === 'googlecalendar') {
|
||||
setGoogleCalendarConnected(success)
|
||||
setGoogleCalendarConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Google Calendar')
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Google Calendar')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -510,10 +510,10 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
}
|
||||
}, [open, providers, refreshAllStatuses])
|
||||
|
||||
// Listen for OAuth completion events
|
||||
// Listen for OAuth completion events (state updates only — toasts handled by ConnectorsPopover)
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
const { provider, success, error } = event
|
||||
const { provider, success } = event
|
||||
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
|
|
@ -523,13 +523,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
isConnecting: false,
|
||||
}
|
||||
}))
|
||||
|
||||
if (success) {
|
||||
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
toast.success(`Connected to ${displayName}`)
|
||||
} else {
|
||||
toast.error(error || `Failed to connect to ${provider}`)
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
|
|
@ -559,41 +552,23 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
return cleanup
|
||||
}, [onboardingPath, currentStep])
|
||||
|
||||
// Listen for Composio connection events
|
||||
// Listen for Composio connection events (state updates only — toasts handled by ConnectorsPopover)
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success, error } = event
|
||||
const { toolkitSlug, success } = event
|
||||
|
||||
if (toolkitSlug === 'slack') {
|
||||
setSlackEnabled(success)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Slack')
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Slack')
|
||||
}
|
||||
}
|
||||
|
||||
if (toolkitSlug === 'gmail') {
|
||||
setGmailConnected(success)
|
||||
setGmailConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Gmail')
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Gmail')
|
||||
}
|
||||
}
|
||||
|
||||
if (toolkitSlug === 'googlecalendar') {
|
||||
setGoogleCalendarConnected(success)
|
||||
setGoogleCalendarConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Google Calendar')
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Google Calendar')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ import { OAuth2Client } from 'google-auth-library';
|
|||
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js';
|
||||
import { composioAccountsRepo } from '../composio/repo.js';
|
||||
|
||||
// Configuration
|
||||
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||
const LOOKBACK_DAYS = 14;
|
||||
const COMPOSIO_LOOKBACK_DAYS = 14;
|
||||
const REQUIRED_SCOPES = [
|
||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||
'https://www.googleapis.com/auth/drive.readonly'
|
||||
|
|
@ -56,7 +59,7 @@ function cleanUpOldFiles(currentEventIds: Set<string>, syncDir: string): string[
|
|||
const files = fs.readdirSync(syncDir);
|
||||
const deleted: string[] = [];
|
||||
for (const filename of files) {
|
||||
if (filename === 'sync_state.json') continue;
|
||||
if (filename === 'sync_state.json' || filename === 'composio_state.json') continue;
|
||||
|
||||
// We expect files like:
|
||||
// {eventId}.json
|
||||
|
|
@ -133,10 +136,10 @@ async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, s
|
|||
const filename = `${eventId}_doc_${safeTitle}.md`;
|
||||
const filePath = path.join(syncDir, filename);
|
||||
|
||||
// Simple cache check: if file exists, skip.
|
||||
// Simple cache check: if file exists, skip.
|
||||
// Ideally we check modifiedTime, but that requires an extra API call per file.
|
||||
// Given the loop interval, we can just check existence to save quota.
|
||||
// If user updates notes, they might want them re-synced.
|
||||
// If user updates notes, they might want them re-synced.
|
||||
// For now, let's just check existence. To be smarter, we'd need a state file or check API.
|
||||
if (fs.existsSync(filePath)) continue;
|
||||
|
||||
|
|
@ -343,20 +346,248 @@ async function performSync(syncDir: string, lookbackDays: number) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Composio-based Sync ---
|
||||
|
||||
interface ComposioCalendarState {
|
||||
last_sync: string; // ISO string
|
||||
}
|
||||
|
||||
function loadComposioState(stateFile: string): ComposioCalendarState | null {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (data.last_sync) {
|
||||
return { last_sync: data.last_sync };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Calendar] Failed to load composio state:', e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveComposioState(stateFile: string, lastSync: string): void {
|
||||
fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a Composio calendar event as JSON (same format used by Google OAuth path).
|
||||
* The event data from Composio is already structured similarly to Google Calendar API.
|
||||
*/
|
||||
function saveComposioEvent(eventData: Record<string, unknown>, syncDir: string): { changed: boolean; isNew: boolean; title: string } {
|
||||
const eventId = eventData.id as string | undefined;
|
||||
if (!eventId) return { changed: false, isNew: false, title: 'Unknown' };
|
||||
|
||||
const filePath = path.join(syncDir, `${eventId}.json`);
|
||||
const content = JSON.stringify(eventData, null, 2);
|
||||
const exists = fs.existsSync(filePath);
|
||||
|
||||
try {
|
||||
if (exists) {
|
||||
const existing = fs.readFileSync(filePath, 'utf-8');
|
||||
if (existing === content) {
|
||||
return { changed: false, isNew: false, title: (eventData.summary as string) || eventId };
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
return { changed: true, isNew: !exists, title: (eventData.summary as string) || eventId };
|
||||
} catch (e) {
|
||||
console.error(`[Calendar] Error saving event ${eventId}:`, e);
|
||||
return { changed: false, isNew: false, title: (eventData.summary as string) || eventId };
|
||||
}
|
||||
}
|
||||
|
||||
async function performSyncComposio() {
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'composio_state.json');
|
||||
|
||||
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
|
||||
|
||||
const account = composioAccountsRepo.getAccount('googlecalendar');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
console.log('[Calendar] Google Calendar not connected via Composio. Skipping sync.');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedAccountId = account.id;
|
||||
|
||||
// Calculate time window: lookback + 14 days forward
|
||||
const now = new Date();
|
||||
const lookbackMs = COMPOSIO_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
|
||||
const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const timeMin = new Date(now.getTime() - lookbackMs).toISOString();
|
||||
const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString();
|
||||
|
||||
console.log(`[Calendar] Syncing via Composio from ${timeMin} to ${timeMax} (lookback: ${COMPOSIO_LOOKBACK_DAYS} days)...`);
|
||||
|
||||
let run: ServiceRunContext | null = null;
|
||||
const ensureRun = async (): Promise<ServiceRunContext> => {
|
||||
if (!run) {
|
||||
run = await serviceLogger.startRun({
|
||||
service: 'calendar',
|
||||
message: 'Syncing calendar (Composio)',
|
||||
trigger: 'timer',
|
||||
});
|
||||
}
|
||||
return run;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executeAction(
|
||||
'GOOGLECALENDAR_FIND_EVENT',
|
||||
{
|
||||
connected_account_id: connectedAccountId,
|
||||
user_id: 'rowboat-user',
|
||||
version: 'latest',
|
||||
arguments: {
|
||||
calendar_id: 'primary',
|
||||
time_min: timeMin,
|
||||
time_max: timeMax,
|
||||
single_events: true,
|
||||
order_by: 'startTime',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.successful || !result.data) {
|
||||
console.error('[Calendar] Failed to list events via Composio:', result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
// Composio may return events in different structures
|
||||
let events: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (Array.isArray(data.items)) {
|
||||
events = data.items as Array<Record<string, unknown>>;
|
||||
} else if (Array.isArray(data.events)) {
|
||||
events = data.events as Array<Record<string, unknown>>;
|
||||
} else if (Array.isArray(data)) {
|
||||
events = data as unknown as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const currentEventIds = new Set<string>();
|
||||
let newCount = 0;
|
||||
let updatedCount = 0;
|
||||
const changedTitles: string[] = [];
|
||||
|
||||
if (events.length === 0) {
|
||||
console.log('[Calendar] No events found in this window.');
|
||||
} else {
|
||||
console.log(`[Calendar] Found ${events.length} events.`);
|
||||
for (const event of events) {
|
||||
const eventId = event.id as string | undefined;
|
||||
if (eventId) {
|
||||
const saveResult = saveComposioEvent(event, SYNC_DIR);
|
||||
currentEventIds.add(eventId);
|
||||
|
||||
if (saveResult.changed) {
|
||||
await ensureRun();
|
||||
changedTitles.push(saveResult.title);
|
||||
if (saveResult.isNew) {
|
||||
newCount++;
|
||||
} else {
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up events no longer in the window
|
||||
const deletedFiles = cleanUpOldFiles(currentEventIds, SYNC_DIR);
|
||||
let deletedCount = 0;
|
||||
if (deletedFiles.length > 0) {
|
||||
await ensureRun();
|
||||
deletedCount = deletedFiles.length;
|
||||
}
|
||||
|
||||
// Log results if any changes were detected (run was started by ensureRun)
|
||||
if (run) {
|
||||
const r = run as ServiceRunContext;
|
||||
const totalChanges = newCount + updatedCount + deletedCount;
|
||||
const limitedTitles = limitEventItems(changedTitles);
|
||||
await serviceLogger.log({
|
||||
type: 'changes_identified',
|
||||
service: r.service,
|
||||
runId: r.runId,
|
||||
level: 'info',
|
||||
message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`,
|
||||
counts: {
|
||||
newEvents: newCount,
|
||||
updatedEvents: updatedCount,
|
||||
deletedFiles: deletedCount,
|
||||
},
|
||||
items: limitedTitles.items,
|
||||
truncated: limitedTitles.truncated,
|
||||
});
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: r.service,
|
||||
runId: r.runId,
|
||||
level: 'info',
|
||||
message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`,
|
||||
durationMs: Date.now() - r.startedAt,
|
||||
outcome: 'ok',
|
||||
summary: {
|
||||
newEvents: newCount,
|
||||
updatedEvents: updatedCount,
|
||||
deletedFiles: deletedCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Save state
|
||||
saveComposioState(STATE_FILE, new Date().toISOString());
|
||||
console.log(`[Calendar] Composio sync completed. ${newCount} new, ${updatedCount} updated, ${deletedCount} deleted.`);
|
||||
} catch (error) {
|
||||
console.error('[Calendar] Error during Composio sync:', error);
|
||||
const errRun = await ensureRun();
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: errRun.service,
|
||||
runId: errRun.runId,
|
||||
level: 'error',
|
||||
message: 'Calendar sync error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: errRun.service,
|
||||
runId: errRun.runId,
|
||||
level: 'error',
|
||||
message: 'Calendar sync failed',
|
||||
durationMs: Date.now() - errRun.startedAt,
|
||||
outcome: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
console.log("Starting Google Calendar & Notes Sync (TS)...");
|
||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
// Check if credentials are available with required scopes
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES);
|
||||
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping...");
|
||||
const composioMode = await useComposioForGoogleCalendar();
|
||||
if (composioMode) {
|
||||
const isConnected = composioAccountsRepo.isConnected('googlecalendar');
|
||||
if (!isConnected) {
|
||||
console.log('[Calendar] Google Calendar not connected via Composio. Sleeping...');
|
||||
} else {
|
||||
await performSyncComposio();
|
||||
}
|
||||
} else {
|
||||
// Perform one sync
|
||||
await performSync(SYNC_DIR, LOOKBACK_DAYS);
|
||||
// Check if credentials are available with required scopes
|
||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES);
|
||||
|
||||
if (!hasCredentials) {
|
||||
console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping...");
|
||||
} else {
|
||||
// Perform one sync
|
||||
await performSync(SYNC_DIR, LOOKBACK_DAYS);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in main loop:", error);
|
||||
|
|
|
|||
|
|
@ -786,13 +786,9 @@ export async function init() {
|
|||
console.log("Starting Gmail Sync (TS)...");
|
||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||
|
||||
const composioMode = await useComposioForGoogle();
|
||||
if (composioMode) {
|
||||
console.log('[Gmail] Using Composio backend for Gmail sync.');
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const composioMode = await useComposioForGoogle();
|
||||
if (composioMode) {
|
||||
const isConnected = composioAccountsRepo.isConnected('gmail');
|
||||
if (!isConnected) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue