mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-27 20:29:44 +02:00
use composio for google optionally
This commit is contained in:
parent
d2bb11f104
commit
17bb625ab9
7 changed files with 740 additions and 20 deletions
|
|
@ -4,6 +4,7 @@ import * as composioClient from '@x/core/dist/composio/client.js';
|
||||||
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
|
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
|
||||||
import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js';
|
import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||||
|
|
||||||
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
|
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
|
||||||
|
|
||||||
|
|
@ -152,6 +153,9 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
||||||
|
|
||||||
if (accountStatus.status === 'ACTIVE') {
|
if (accountStatus.status === 'ACTIVE') {
|
||||||
emitComposioEvent({ toolkitSlug, success: true });
|
emitComposioEvent({ toolkitSlug, success: true });
|
||||||
|
if (toolkitSlug === 'gmail') {
|
||||||
|
triggerGmailSync();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
emitComposioEvent({
|
emitComposioEvent({
|
||||||
toolkitSlug,
|
toolkitSlug,
|
||||||
|
|
@ -266,6 +270,13 @@ export function listConnected(): { toolkits: string[] } {
|
||||||
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
|
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Composio should be used for Google services (Gmail, etc.)
|
||||||
|
*/
|
||||||
|
export function useComposioForGoogle(): { enabled: boolean } {
|
||||||
|
return { enabled: composioClient.useComposioForGoogle() };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a Composio action
|
* Execute a Composio action
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -543,6 +543,9 @@ export function setupIpcHandlers() {
|
||||||
'composio:execute-action': async (_event, args) => {
|
'composio:execute-action': async (_event, args) => {
|
||||||
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
|
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
|
||||||
},
|
},
|
||||||
|
'composio:use-composio-for-google': async () => {
|
||||||
|
return composioHandler.useComposioForGoogle();
|
||||||
|
},
|
||||||
// Agent schedule handlers
|
// Agent schedule handlers
|
||||||
'agent-schedule:getConfig': async () => {
|
'agent-schedule:getConfig': async () => {
|
||||||
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
|
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { Separator } from "@/components/ui/separator"
|
||||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||||
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
|
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||||
|
|
||||||
interface ProviderState {
|
interface ProviderState {
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
|
|
@ -54,6 +55,10 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
||||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||||
|
|
||||||
|
// Composio API key state
|
||||||
|
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||||
|
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||||
|
|
||||||
// Slack state (agent-slack CLI)
|
// Slack state (agent-slack CLI)
|
||||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||||
const [slackLoading, setSlackLoading] = useState(true)
|
const [slackLoading, setSlackLoading] = useState(true)
|
||||||
|
|
@ -64,7 +69,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
||||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Load available providers on mount
|
// Composio/Gmail state
|
||||||
|
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||||
|
const [gmailConnected, setGmailConnected] = useState(false)
|
||||||
|
const [gmailLoading, setGmailLoading] = useState(true)
|
||||||
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
|
|
||||||
|
// Load available providers and composio-for-google flag on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadProviders() {
|
async function loadProviders() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -78,7 +89,16 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
||||||
setProvidersLoading(false)
|
setProvidersLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function loadComposioForGoogleFlag() {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||||
|
setUseComposioForGoogle(result.enabled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check composio-for-google flag:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
loadProviders()
|
loadProviders()
|
||||||
|
loadComposioForGoogleFlag()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load Granola config
|
// Load Granola config
|
||||||
|
|
@ -150,6 +170,80 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load Gmail connection status
|
||||||
|
const refreshGmailStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailLoading(true)
|
||||||
|
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
|
||||||
|
setGmailConnected(result.isConnected)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Gmail status:', error)
|
||||||
|
setGmailConnected(false)
|
||||||
|
} finally {
|
||||||
|
setGmailLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Connect to Gmail via Composio
|
||||||
|
const startGmailConnect = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailConnecting(true)
|
||||||
|
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' })
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error || 'Failed to connect to Gmail')
|
||||||
|
setGmailConnecting(false)
|
||||||
|
}
|
||||||
|
// Success will be handled by composio:didConnect event
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to Gmail:', error)
|
||||||
|
toast.error('Failed to connect to Gmail')
|
||||||
|
setGmailConnecting(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle Gmail connect button click
|
||||||
|
const handleConnectGmail = useCallback(async () => {
|
||||||
|
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||||
|
if (!configResult.configured) {
|
||||||
|
setComposioApiKeyTarget('gmail')
|
||||||
|
setComposioApiKeyOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await startGmailConnect()
|
||||||
|
}, [startGmailConnect])
|
||||||
|
|
||||||
|
// Disconnect from Gmail
|
||||||
|
const handleDisconnectGmail = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailLoading(true)
|
||||||
|
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' })
|
||||||
|
if (result.success) {
|
||||||
|
setGmailConnected(false)
|
||||||
|
toast.success('Disconnected from Gmail')
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to disconnect from Gmail')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect from Gmail:', error)
|
||||||
|
toast.error('Failed to disconnect from Gmail')
|
||||||
|
} finally {
|
||||||
|
setGmailLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle Composio API key submission
|
||||||
|
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||||
|
setComposioApiKeyOpen(false)
|
||||||
|
toast.success('Composio API key saved')
|
||||||
|
await startGmailConnect()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save Composio API key:', error)
|
||||||
|
toast.error('Failed to save API key')
|
||||||
|
}
|
||||||
|
}, [startGmailConnect])
|
||||||
|
|
||||||
// Save selected Slack workspaces
|
// Save selected Slack workspaces
|
||||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||||
|
|
@ -193,6 +287,11 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
||||||
// Refresh Slack config
|
// Refresh Slack config
|
||||||
refreshSlackConfig()
|
refreshSlackConfig()
|
||||||
|
|
||||||
|
// Refresh Gmail Composio status if enabled
|
||||||
|
if (useComposioForGoogle) {
|
||||||
|
refreshGmailStatus()
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh OAuth providers
|
// Refresh OAuth providers
|
||||||
if (providers.length === 0) return
|
if (providers.length === 0) return
|
||||||
|
|
||||||
|
|
@ -229,7 +328,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
||||||
}
|
}
|
||||||
|
|
||||||
setProviderStates(newStates)
|
setProviderStates(newStates)
|
||||||
}, [providers, refreshGranolaConfig, refreshSlackConfig])
|
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle])
|
||||||
|
|
||||||
// Refresh statuses when popover opens or providers list changes
|
// Refresh statuses when popover opens or providers list changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -273,6 +372,30 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [refreshAllStatuses])
|
}, [refreshAllStatuses])
|
||||||
|
|
||||||
|
// Listen for Composio connection events (Gmail)
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||||
|
const { toolkitSlug, success, error } = 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return cleanup
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||||
setProviderStates(prev => ({
|
setProviderStates(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -516,13 +639,63 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email & Calendar Section - Google */}
|
{/* Email & Calendar Section */}
|
||||||
{providers.includes('google') && (
|
{(useComposioForGoogle || providers.includes('google')) && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1.5">
|
<div className="px-2 py-1.5">
|
||||||
<span className="text-xs font-medium text-muted-foreground">Email & Calendar</span>
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{useComposioForGoogle ? 'Email' : 'Email & Calendar'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')}
|
{useComposioForGoogle ? (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||||
|
<Mail className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-medium truncate">Gmail</span>
|
||||||
|
{gmailLoading ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
Sync emails
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{gmailLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
) : gmailConnected ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDisconnectGmail}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConnectGmail}
|
||||||
|
disabled={gmailConnecting}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{gmailConnecting ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Connect"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
||||||
|
)}
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -652,6 +825,12 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
<ComposioApiKeyModal
|
||||||
|
open={composioApiKeyOpen}
|
||||||
|
onOpenChange={setComposioApiKeyOpen}
|
||||||
|
onSubmit={handleComposioApiKeySubmit}
|
||||||
|
isSubmitting={gmailConnecting}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { cn } from "@/lib/utils"
|
||||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||||
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||||
|
|
||||||
interface ProviderState {
|
interface ProviderState {
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
|
|
@ -78,6 +79,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||||
|
|
||||||
|
// Composio API key state
|
||||||
|
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||||
|
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
|
||||||
|
|
||||||
// Slack state (agent-slack CLI)
|
// Slack state (agent-slack CLI)
|
||||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||||
const [slackLoading, setSlackLoading] = useState(true)
|
const [slackLoading, setSlackLoading] = useState(true)
|
||||||
|
|
@ -88,6 +93,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Composio/Gmail state
|
||||||
|
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
|
||||||
|
const [gmailConnected, setGmailConnected] = useState(false)
|
||||||
|
const [gmailLoading, setGmailLoading] = useState(true)
|
||||||
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
|
|
||||||
const updateProviderConfig = useCallback(
|
const updateProviderConfig = useCallback(
|
||||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
|
|
@ -115,7 +126,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
.filter(([, state]) => state.isConnected)
|
.filter(([, state]) => state.isConnected)
|
||||||
.map(([provider]) => provider)
|
.map(([provider]) => provider)
|
||||||
|
|
||||||
// Load available providers on mount
|
// Load available providers and composio-for-google flag on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
|
|
@ -131,7 +142,16 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
setProvidersLoading(false)
|
setProvidersLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function loadComposioForGoogleFlag() {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
|
||||||
|
setUseComposioForGoogle(result.enabled)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check composio-for-google flag:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
loadProviders()
|
loadProviders()
|
||||||
|
loadComposioForGoogleFlag()
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Load LLM models catalog on open
|
// Load LLM models catalog on open
|
||||||
|
|
@ -254,6 +274,60 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load Gmail connection status
|
||||||
|
const refreshGmailStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailLoading(true)
|
||||||
|
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
|
||||||
|
setGmailConnected(result.isConnected)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Gmail status:', error)
|
||||||
|
setGmailConnected(false)
|
||||||
|
} finally {
|
||||||
|
setGmailLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Connect to Gmail via Composio
|
||||||
|
const startGmailConnect = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailConnecting(true)
|
||||||
|
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' })
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error || 'Failed to connect to Gmail')
|
||||||
|
setGmailConnecting(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to Gmail:', error)
|
||||||
|
toast.error('Failed to connect to Gmail')
|
||||||
|
setGmailConnecting(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle Gmail connect button click
|
||||||
|
const handleConnectGmail = useCallback(async () => {
|
||||||
|
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||||
|
if (!configResult.configured) {
|
||||||
|
setComposioApiKeyTarget('gmail')
|
||||||
|
setComposioApiKeyOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await startGmailConnect()
|
||||||
|
}, [startGmailConnect])
|
||||||
|
|
||||||
|
// Handle Composio API key submission
|
||||||
|
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||||
|
setComposioApiKeyOpen(false)
|
||||||
|
toast.success('Composio API key saved')
|
||||||
|
await startGmailConnect()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save Composio API key:', error)
|
||||||
|
toast.error('Failed to save API key')
|
||||||
|
}
|
||||||
|
}, [startGmailConnect])
|
||||||
|
|
||||||
// Save selected Slack workspaces
|
// Save selected Slack workspaces
|
||||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||||
|
|
@ -341,6 +415,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
// Refresh Slack config
|
// Refresh Slack config
|
||||||
refreshSlackConfig()
|
refreshSlackConfig()
|
||||||
|
|
||||||
|
// Refresh Gmail Composio status if enabled
|
||||||
|
if (useComposioForGoogle) {
|
||||||
|
refreshGmailStatus()
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh OAuth providers
|
// Refresh OAuth providers
|
||||||
if (providers.length === 0) return
|
if (providers.length === 0) return
|
||||||
|
|
||||||
|
|
@ -368,7 +447,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setProviderStates(newStates)
|
setProviderStates(newStates)
|
||||||
}, [providers, refreshGranolaConfig, refreshSlackConfig])
|
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle])
|
||||||
|
|
||||||
// Refresh statuses when modal opens or providers list changes
|
// Refresh statuses when modal opens or providers list changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -402,6 +481,30 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Listen for Composio connection events (Gmail)
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||||
|
const { toolkitSlug, success, error } = 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return cleanup
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||||
setProviderStates(prev => ({
|
setProviderStates(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -544,6 +647,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Render Gmail Composio row
|
||||||
|
const renderGmailRow = () => (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||||
|
<Mail className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-medium truncate">Gmail</span>
|
||||||
|
{gmailLoading ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
Sync emails
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{gmailLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
) : gmailConnected ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
<span>Connected</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConnectGmail}
|
||||||
|
disabled={gmailConnecting}
|
||||||
|
>
|
||||||
|
{gmailConnecting ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Connect"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
// Render Slack row
|
// Render Slack row
|
||||||
const renderSlackRow = () => (
|
const renderSlackRow = () => (
|
||||||
<div className="rounded-md px-3 py-3 hover:bg-accent">
|
<div className="rounded-md px-3 py-3 hover:bg-accent">
|
||||||
|
|
@ -835,13 +982,18 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Email & Calendar Section */}
|
{/* Email / Email & Calendar Section */}
|
||||||
{providers.includes('google') && (
|
{(useComposioForGoogle || providers.includes('google')) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email & Calendar</span>
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{useComposioForGoogle ? 'Email' : 'Email & Calendar'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{renderOAuthProvider('google', 'Google', <Mail className="size-5" />, 'Sync emails and calendar events')}
|
{useComposioForGoogle
|
||||||
|
? renderGmailRow()
|
||||||
|
: renderOAuthProvider('google', 'Google', <Mail className="size-5" />, 'Sync emails and calendar events')
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -878,7 +1030,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
|
|
||||||
// Step 2: Completion
|
// Step 2: Completion
|
||||||
const renderCompletionStep = () => {
|
const renderCompletionStep = () => {
|
||||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled
|
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled || gmailConnected
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
|
|
@ -901,6 +1053,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
<div className="rounded-lg border bg-muted/50 p-4">
|
<div className="rounded-lg border bg-muted/50 p-4">
|
||||||
<p className="text-sm font-medium mb-2">Connected accounts:</p>
|
<p className="text-sm font-medium mb-2">Connected accounts:</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
{gmailConnected && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<CheckCircle2 className="size-4 text-green-600" />
|
||||||
|
<span>Gmail (Email)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{connectedProviders.includes('google') && (
|
{connectedProviders.includes('google') && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<CheckCircle2 className="size-4 text-green-600" />
|
<CheckCircle2 className="size-4 text-green-600" />
|
||||||
|
|
@ -945,6 +1103,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
onSubmit={handleGoogleClientIdSubmit}
|
onSubmit={handleGoogleClientIdSubmit}
|
||||||
isSubmitting={providerStates.google?.isConnecting ?? false}
|
isSubmitting={providerStates.google?.isConnecting ?? false}
|
||||||
/>
|
/>
|
||||||
|
<ComposioApiKeyModal
|
||||||
|
open={composioApiKeyOpen}
|
||||||
|
onOpenChange={setComposioApiKeyOpen}
|
||||||
|
onSubmit={handleComposioApiKeySubmit}
|
||||||
|
isSubmitting={gmailConnecting}
|
||||||
|
/>
|
||||||
<Dialog open={open} onOpenChange={() => {}}>
|
<Dialog open={open} onOpenChange={() => {}}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"
|
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
*/
|
*/
|
||||||
const ZComposioConfig = z.object({
|
const ZComposioConfig = z.object({
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
|
use_composio_for_google: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ComposioConfig = z.infer<typeof ZComposioConfig>;
|
type ComposioConfig = z.infer<typeof ZComposioConfig>;
|
||||||
|
|
@ -103,6 +104,14 @@ export async function isConfigured(): Promise<boolean> {
|
||||||
return !!getApiKey();
|
return !!getApiKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Composio should be used for Google services (Gmail, etc.)
|
||||||
|
*/
|
||||||
|
export function useComposioForGoogle(): boolean {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.use_composio_for_google === true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an API call to Composio
|
* Make an API call to Composio
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { WorkDir } from '../config/config.js';
|
||||||
import { GoogleClientFactory } from './google-client-factory.js';
|
import { GoogleClientFactory } from './google-client-factory.js';
|
||||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
|
import { executeAction, useComposioForGoogle } from '../composio/client.js';
|
||||||
|
import { composioAccountsRepo } from '../composio/repo.js';
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||||
|
|
@ -440,20 +442,366 @@ async function performSync() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Composio-based Sync ---
|
||||||
|
|
||||||
|
const COMPOSIO_LOOKBACK_DAYS = 7;
|
||||||
|
|
||||||
|
interface ComposioSyncState {
|
||||||
|
last_sync: string; // ISO string
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadComposioState(stateFile: string): ComposioSyncState | 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('[Gmail] 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseDate(dateStr: string): Date | null {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedMessage {
|
||||||
|
from: string;
|
||||||
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMessageData(messageData: Record<string, unknown>): ParsedMessage {
|
||||||
|
const headers = messageData.payload && typeof messageData.payload === 'object'
|
||||||
|
? (messageData.payload as Record<string, unknown>).headers as Array<{ name: string; value: string }> | undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown');
|
||||||
|
const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown');
|
||||||
|
const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)');
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
if (messageData.payload && typeof messageData.payload === 'object') {
|
||||||
|
body = extractBodyFromPayload(messageData.payload as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
if (typeof messageData.body === 'string') {
|
||||||
|
body = messageData.body;
|
||||||
|
} else if (typeof messageData.snippet === 'string') {
|
||||||
|
body = messageData.snippet;
|
||||||
|
} else if (typeof messageData.text === 'string') {
|
||||||
|
body = messageData.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body && (body.includes('<html') || body.includes('<div') || body.includes('<p'))) {
|
||||||
|
body = nhm.translate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
body = body.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { from, date, subject, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBodyFromPayload(payload: Record<string, unknown>): string {
|
||||||
|
const parts = payload.parts as Array<Record<string, unknown>> | undefined;
|
||||||
|
|
||||||
|
if (parts) {
|
||||||
|
for (const part of parts) {
|
||||||
|
const mimeType = part.mimeType as string | undefined;
|
||||||
|
const bodyData = part.body && typeof part.body === 'object'
|
||||||
|
? (part.body as Record<string, unknown>).data as string | undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) {
|
||||||
|
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
|
||||||
|
if (mimeType === 'text/html') {
|
||||||
|
return nhm.translate(decoded);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.parts) {
|
||||||
|
const result = extractBodyFromPayload(part as Record<string, unknown>);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyData = payload.body && typeof payload.body === 'object'
|
||||||
|
? (payload.body as Record<string, unknown>).data as string | undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (bodyData) {
|
||||||
|
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
|
||||||
|
const mimeType = payload.mimeType as string | undefined;
|
||||||
|
if (mimeType === 'text/html') {
|
||||||
|
return nhm.translate(decoded);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<string | null> {
|
||||||
|
let threadResult;
|
||||||
|
try {
|
||||||
|
threadResult = await executeAction(
|
||||||
|
'GMAIL_FETCH_MESSAGE_BY_THREAD_ID',
|
||||||
|
connectedAccountId,
|
||||||
|
{ thread_id: threadId, user_id: 'me' }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadResult.success || !threadResult.data) {
|
||||||
|
console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = threadResult.data as Record<string, unknown>;
|
||||||
|
const messages = data.messages as Array<Record<string, unknown>> | undefined;
|
||||||
|
|
||||||
|
let newestDate: Date | null = null;
|
||||||
|
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
const parsed = parseMessageData(data);
|
||||||
|
const mdContent = `# ${parsed.subject}\n\n` +
|
||||||
|
`**Thread ID:** ${threadId}\n` +
|
||||||
|
`**Message Count:** 1\n\n---\n\n` +
|
||||||
|
`### From: ${parsed.from}\n` +
|
||||||
|
`**Date:** ${parsed.date}\n\n` +
|
||||||
|
`${parsed.body}\n\n---\n\n`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||||
|
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
|
||||||
|
newestDate = tryParseDate(parsed.date);
|
||||||
|
} else {
|
||||||
|
const firstParsed = parseMessageData(messages[0]);
|
||||||
|
let mdContent = `# ${firstParsed.subject}\n\n`;
|
||||||
|
mdContent += `**Thread ID:** ${threadId}\n`;
|
||||||
|
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const parsed = parseMessageData(msg);
|
||||||
|
mdContent += `### From: ${parsed.from}\n`;
|
||||||
|
mdContent += `**Date:** ${parsed.date}\n\n`;
|
||||||
|
mdContent += `${parsed.body}\n\n`;
|
||||||
|
mdContent += `---\n\n`;
|
||||||
|
|
||||||
|
const msgDate = tryParseDate(parsed.date);
|
||||||
|
if (msgDate && (!newestDate || msgDate > newestDate)) {
|
||||||
|
newestDate = msgDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||||
|
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newestDate) return null;
|
||||||
|
return new Date(newestDate.getTime() + 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSyncComposio() {
|
||||||
|
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
|
||||||
|
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
|
||||||
|
if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const account = composioAccountsRepo.getAccount('gmail');
|
||||||
|
if (!account || account.status !== 'ACTIVE') {
|
||||||
|
console.log('[Gmail] Gmail not connected via Composio. Skipping sync.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedAccountId = account.id;
|
||||||
|
|
||||||
|
const state = loadComposioState(STATE_FILE);
|
||||||
|
let afterEpochSeconds: number;
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
afterEpochSeconds = Math.floor(new Date(state.last_sync).getTime() / 1000);
|
||||||
|
console.log(`[Gmail] Syncing messages since ${state.last_sync}...`);
|
||||||
|
} else {
|
||||||
|
const pastDate = new Date();
|
||||||
|
pastDate.setDate(pastDate.getDate() - COMPOSIO_LOOKBACK_DAYS);
|
||||||
|
afterEpochSeconds = Math.floor(pastDate.getTime() / 1000);
|
||||||
|
console.log(`[Gmail] First sync - fetching last ${COMPOSIO_LOOKBACK_DAYS} days...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let run: ServiceRunContext | null = null;
|
||||||
|
const ensureRun = async () => {
|
||||||
|
if (!run) {
|
||||||
|
run = await serviceLogger.startRun({
|
||||||
|
service: 'gmail',
|
||||||
|
message: 'Syncing Gmail (Composio)',
|
||||||
|
trigger: 'timer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allThreadIds: string[] = [];
|
||||||
|
let pageToken: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
query: `after:${afterEpochSeconds}`,
|
||||||
|
max_results: 20,
|
||||||
|
user_id: 'me',
|
||||||
|
};
|
||||||
|
if (pageToken) {
|
||||||
|
params.page_token = pageToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeAction(
|
||||||
|
'GMAIL_LIST_THREADS',
|
||||||
|
connectedAccountId,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
console.error('[Gmail] Failed to list threads:', result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data as Record<string, unknown>;
|
||||||
|
const threads = data.threads as Array<Record<string, unknown>> | undefined;
|
||||||
|
|
||||||
|
if (threads && threads.length > 0) {
|
||||||
|
for (const thread of threads) {
|
||||||
|
const threadId = thread.id as string | undefined;
|
||||||
|
if (threadId) {
|
||||||
|
allThreadIds.push(threadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = data.nextPageToken as string | undefined;
|
||||||
|
} while (pageToken);
|
||||||
|
|
||||||
|
if (allThreadIds.length === 0) {
|
||||||
|
console.log('[Gmail] No new threads.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`);
|
||||||
|
|
||||||
|
await ensureRun();
|
||||||
|
const limitedThreads = limitEventItems(allThreadIds);
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'changes_identified',
|
||||||
|
service: run!.service,
|
||||||
|
runId: run!.runId,
|
||||||
|
level: 'info',
|
||||||
|
message: `Found ${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'} to sync`,
|
||||||
|
counts: { threads: allThreadIds.length },
|
||||||
|
items: limitedThreads.items,
|
||||||
|
truncated: limitedThreads.truncated,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process oldest first so high-water mark advances chronologically
|
||||||
|
allThreadIds.reverse();
|
||||||
|
|
||||||
|
let highWaterMark: string | null = state?.last_sync ?? null;
|
||||||
|
let processedCount = 0;
|
||||||
|
for (const threadId of allThreadIds) {
|
||||||
|
try {
|
||||||
|
const newestInThread = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR);
|
||||||
|
processedCount++;
|
||||||
|
|
||||||
|
if (newestInThread) {
|
||||||
|
if (!highWaterMark || new Date(newestInThread) > new Date(highWaterMark)) {
|
||||||
|
highWaterMark = newestInThread;
|
||||||
|
}
|
||||||
|
saveComposioState(STATE_FILE, highWaterMark);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'run_complete',
|
||||||
|
service: run!.service,
|
||||||
|
runId: run!.runId,
|
||||||
|
level: 'info',
|
||||||
|
message: `Gmail sync complete: ${processedCount}/${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'}`,
|
||||||
|
durationMs: Date.now() - run!.startedAt,
|
||||||
|
outcome: 'ok',
|
||||||
|
summary: { threads: processedCount },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Gmail] Error during sync:', error);
|
||||||
|
await ensureRun();
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'error',
|
||||||
|
service: run!.service,
|
||||||
|
runId: run!.runId,
|
||||||
|
level: 'error',
|
||||||
|
message: 'Gmail sync error',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'run_complete',
|
||||||
|
service: run!.service,
|
||||||
|
runId: run!.runId,
|
||||||
|
level: 'error',
|
||||||
|
message: 'Gmail sync failed',
|
||||||
|
durationMs: Date.now() - run!.startedAt,
|
||||||
|
outcome: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
console.log("Starting Gmail Sync (TS)...");
|
console.log("Starting Gmail Sync (TS)...");
|
||||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||||
|
|
||||||
|
const composioMode = useComposioForGoogle();
|
||||||
|
if (composioMode) {
|
||||||
|
console.log('[Gmail] Using Composio backend for Gmail sync.');
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
// Check if credentials are available with required scopes
|
if (composioMode) {
|
||||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
|
const isConnected = composioAccountsRepo.isConnected('gmail');
|
||||||
|
if (!isConnected) {
|
||||||
if (!hasCredentials) {
|
console.log('[Gmail] Gmail not connected via Composio. Sleeping...');
|
||||||
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");
|
} else {
|
||||||
|
await performSyncComposio();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Perform one sync
|
// Check if credentials are available with required scopes
|
||||||
await performSync();
|
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
|
||||||
|
|
||||||
|
if (!hasCredentials) {
|
||||||
|
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");
|
||||||
|
} else {
|
||||||
|
// Perform one sync
|
||||||
|
await performSync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in main loop:", error);
|
console.error("Error in main loop:", error);
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,12 @@ const ipcSchemas = {
|
||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
'composio:use-composio-for-google': {
|
||||||
|
req: z.null(),
|
||||||
|
res: z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
'composio:didConnect': {
|
'composio:didConnect': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
toolkitSlug: z.string(),
|
toolkitSlug: z.string(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue