diff --git a/sim/app/api/auth/oauth/confluence/pages/route.ts b/sim/app/api/auth/oauth/confluence/pages/route.ts new file mode 100644 index 0000000000..441af50168 --- /dev/null +++ b/sim/app/api/auth/oauth/confluence/pages/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server' + +export async function POST(request: Request) { + try { + const { domain, accessToken, title, limit = 50 } = await request.json() + + if (!domain) { + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + if (!accessToken) { + return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) + } + + // Build the URL with query parameters + const baseUrl = `https://${domain}/wiki/api/v2/pages` + const queryParams = new URLSearchParams() + + if (limit) { + queryParams.append('limit', limit.toString()) + } + + if (title) { + queryParams.append('title', title) + } + + const queryString = queryParams.toString() + const url = queryString ? `${baseUrl}?${queryString}` : baseUrl + + console.log(`Fetching Confluence pages from: ${url}`) + + // Make the request to Confluence API + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + console.error(`Confluence API error: ${response.status} ${response.statusText}`) + let errorMessage + + try { + const errorData = await response.json() + errorMessage = errorData.message || `Failed to fetch Confluence pages (${response.status})` + console.error('Error details:', errorData) + } catch (e) { + errorMessage = `Failed to fetch Confluence pages: ${response.status} ${response.statusText}` + } + + return NextResponse.json({ error: errorMessage }, { status: response.status }) + } + + const data = await response.json() + console.log('Confluence API response:', JSON.stringify(data, null, 2).substring(0, 300) + '...') + console.log(`Found ${data.results?.length || 0} pages`) + + if (data.results && data.results.length > 0) { + console.log('First few pages:') + data.results.slice(0, 3).forEach((page: any) => { + console.log(`- ${page.id}: ${page.title}`) + }) + } + + return NextResponse.json({ + files: data.results.map((page: any) => ({ + id: page.id, + name: page.title, + mimeType: 'confluence/page', + url: page._links?.webui || '', + modifiedTime: page.version?.createdAt || '', + spaceId: page.spaceId, + webViewLink: page._links?.webui || '', + })), + }) + } catch (error) { + console.error('Error fetching Confluence pages:', error) + return NextResponse.json( + { error: (error as Error).message || 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 42c844ac31..430186acac 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -36,14 +36,18 @@ export interface OAuthRequiredModalProps { const SCOPE_DESCRIPTIONS: Record = { 'https://www.googleapis.com/auth/gmail.send': 'Send emails on your behalf', // 'https://www.googleapis.com/auth/gmail.readonly': 'View and read your email messages', - 'https://www.googleapis.com/auth/drive': 'View and manage your Google Drive files', + // 'https://www.googleapis.com/auth/drive': 'View and manage your Google Drive files', 'https://www.googleapis.com/auth/drive.file': 'View and manage your Google Drive files', - 'https://www.googleapis.com/auth/documents': 'View and manage your Google Docs', + // 'https://www.googleapis.com/auth/documents': 'View and manage your Google Docs', 'https://www.googleapis.com/auth/calendar': 'View and manage your calendar', 'https://www.googleapis.com/auth/userinfo.email': 'View your email address', 'https://www.googleapis.com/auth/userinfo.profile': 'View your basic profile info', 'https://www.googleapis.com/auth/spreadsheets': 'View and manage your Google Sheets', 'read:confluence-content.all': 'Read Confluence content', + 'read:page:confluence': 'Read Confluence pages', + 'write:confluence-content': 'Write Confluence content', + 'read:me': 'Read your profile information', + offline_access: 'Access your account when you are not using the application', repo: 'Access your repositories', workflow: 'Manage repository workflows', 'user:email': 'Access your email address', diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx new file mode 100644 index 0000000000..1e682aa19a --- /dev/null +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx @@ -0,0 +1,582 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Check, ChevronDown, ExternalLink, RefreshCw, Search, X } from 'lucide-react' +import { ConfluenceIcon } from '@/components/icons' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Credential, + getProviderIdFromServiceId, + getServiceIdFromScopes, + OAuthProvider, +} from '@/lib/oauth' +import { saveToStorage } from '@/stores/workflows/persistence' +import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' + +export interface ConfluenceFileInfo { + id: string + name: string + mimeType: string + webViewLink?: string + modifiedTime?: string + spaceId?: string + url?: string +} + +interface ConfluenceFileSelectorProps { + value: string + onChange: (value: string, fileInfo?: ConfluenceFileInfo) => void + provider: OAuthProvider + requiredScopes?: string[] + label?: string + disabled?: boolean + serviceId?: string + domain: string + showPreview?: boolean + onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void +} + +export function ConfluenceFileSelector({ + value, + onChange, + provider, + requiredScopes = [], + label = 'Select Confluence page', + disabled = false, + serviceId, + domain, + showPreview = true, + onFileInfoChange, +}: ConfluenceFileSelectorProps) { + const [open, setOpen] = useState(false) + const [credentials, setCredentials] = useState([]) + const [files, setFiles] = useState([]) + const [selectedCredentialId, setSelectedCredentialId] = useState('') + const [selectedFileId, setSelectedFileId] = useState(value) + const [selectedFile, setSelectedFile] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [showOAuthModal, setShowOAuthModal] = useState(false) + const initialFetchRef = useRef(false) + const [error, setError] = useState(null) + + // Handle search with debounce + const searchTimeoutRef = useRef(null) + + const handleSearch = (value: string) => { + // Clear any existing timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + + // Set a new timeout + searchTimeoutRef.current = setTimeout(() => { + if (value.length > 2) { + fetchFiles(value) + } else if (value.length === 0) { + fetchFiles() + } + }, 500) // 500ms debounce + } + + // Clean up the timeout on unmount + useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + } + }, []) + + // Determine the appropriate service ID based on provider and scopes + const getServiceId = (): string => { + if (serviceId) return serviceId + return getServiceIdFromScopes(provider, requiredScopes) + } + + // Determine the appropriate provider ID based on service and scopes + const getProviderId = (): string => { + const effectiveServiceId = getServiceId() + return getProviderIdFromServiceId(effectiveServiceId) + } + + // Fetch available credentials for this provider + const fetchCredentials = useCallback(async () => { + setIsLoading(true) + try { + const providerId = getProviderId() + const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`) + + if (response.ok) { + const data = await response.json() + setCredentials(data.credentials) + + // Auto-select logic for credentials + if (data.credentials.length > 0) { + // If we already have a selected credential ID, check if it's valid + if ( + selectedCredentialId && + data.credentials.some((cred: Credential) => cred.id === selectedCredentialId) + ) { + // Keep the current selection + } else { + // Otherwise, select the default or first credential + const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault) + if (defaultCred) { + setSelectedCredentialId(defaultCred.id) + } else if (data.credentials.length === 1) { + setSelectedCredentialId(data.credentials[0].id) + } + } + } + } + } catch (error) { + console.error('Error fetching credentials:', error) + } finally { + setIsLoading(false) + } + }, [provider, getProviderId, selectedCredentialId]) + + // Fetch page info when we have a selected file ID + const fetchPageInfo = useCallback( + async (pageId: string) => { + if (!selectedCredentialId || !domain) return + + // Validate domain format + const trimmedDomain = domain.trim().toLowerCase() + if (!trimmedDomain.includes('.')) { + setError( + 'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)' + ) + return + } + + setIsLoading(true) + setError(null) + + try { + // Get the access token from the selected credential + const tokenResponse = await fetch('/api/auth/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + credentialId: selectedCredentialId, + }), + }) + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json() + throw new Error(errorData.error || 'Failed to get access token') + } + + const tokenData = await tokenResponse.json() + const accessToken = tokenData.accessToken + + // Use the access token to fetch the page info + const response = await fetch(`/api/auth/oauth/confluence/page`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + domain, + accessToken, + pageId, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to fetch page info') + } + + const data = await response.json() + if (data.file) { + setSelectedFile(data.file) + onFileInfoChange?.(data.file) + } + } catch (error) { + console.error('Error fetching page info:', error) + setError((error as Error).message) + } finally { + setIsLoading(false) + } + }, + [selectedCredentialId, domain, onFileInfoChange] + ) + + // Fetch pages from Confluence + const fetchFiles = useCallback( + async (searchQuery?: string) => { + if (!selectedCredentialId || !domain) return + + // Validate domain format + const trimmedDomain = domain.trim().toLowerCase() + if (!trimmedDomain.includes('.')) { + setError( + 'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)' + ) + setFiles([]) + setIsLoading(false) + return + } + + setIsLoading(true) + setError(null) + + try { + // Get the access token from the selected credential + const tokenResponse = await fetch('/api/auth/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + credentialId: selectedCredentialId, + }), + }) + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json() + console.error('Access token error:', errorData) + + // If there's a token error, we might need to reconnect the account + setError('Authentication failed. Please reconnect your Confluence account.') + setIsLoading(false) + return + } + + const tokenData = await tokenResponse.json() + const accessToken = tokenData.accessToken + + if (!accessToken) { + console.error('No access token returned') + setError('Authentication failed. Please reconnect your Confluence account.') + setIsLoading(false) + return + } + + // Simply fetch pages directly using the endpoint + const response = await fetch('/api/auth/oauth/confluence/pages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + domain, + accessToken, + title: searchQuery || undefined, + limit: 50, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + console.error('Confluence API error:', errorData) + throw new Error(errorData.error || 'Failed to fetch pages') + } + + const data = await response.json() + console.log(`Received ${data.files?.length || 0} files from API`) + setFiles(data.files || []) + + // If we have a selected file ID, find the file info + if (selectedFileId) { + const fileInfo = data.files.find((file: ConfluenceFileInfo) => file.id === selectedFileId) + if (fileInfo) { + setSelectedFile(fileInfo) + onFileInfoChange?.(fileInfo) + } else if (!searchQuery && selectedFileId) { + // If we can't find the file in the list, try to fetch it directly + fetchPageInfo(selectedFileId) + } + } + } catch (error) { + console.error('Error fetching pages:', error) + setError((error as Error).message) + setFiles([]) + } finally { + setIsLoading(false) + } + }, + [selectedCredentialId, domain, selectedFileId, onFileInfoChange, fetchPageInfo] + ) + + // Fetch credentials on initial mount + useEffect(() => { + if (!initialFetchRef.current) { + fetchCredentials() + initialFetchRef.current = true + } + }, [fetchCredentials]) + + // Only fetch files when the dropdown is opened, not on credential selection + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + + // Only fetch files when opening the dropdown and if we have valid credentials and domain + if (isOpen && selectedCredentialId && domain && domain.includes('.')) { + fetchFiles() + } + } + + // Update selected file when value changes externally + useEffect(() => { + if (value !== selectedFileId) { + setSelectedFileId(value) + + // Find file info if we have files loaded + if (files.length > 0) { + const fileInfo = files.find((file) => file.id === value) || null + setSelectedFile(fileInfo) + onFileInfoChange?.(fileInfo) + } else if (value && !selectedFile && selectedCredentialId && domain && domain.includes('.')) { + // If we don't have files loaded yet but have a value, try to fetch the file info + // Only make the API call if we have everything we need and a proper domain + fetchPageInfo(value) + } + } + }, [value, files, selectedFile, selectedCredentialId, domain, onFileInfoChange, fetchPageInfo]) + + // Handle file selection + const handleSelectFile = (file: ConfluenceFileInfo) => { + setSelectedFileId(file.id) + setSelectedFile(file) + onChange(file.id, file) + onFileInfoChange?.(file) + setOpen(false) + } + + // Handle adding a new credential + const handleAddCredential = () => { + const effectiveServiceId = getServiceId() + const providerId = getProviderId() + + // Store information about the required connection + saveToStorage('pending_service_id', effectiveServiceId) + saveToStorage('pending_oauth_scopes', requiredScopes) + saveToStorage('pending_oauth_return_url', window.location.href) + saveToStorage('pending_oauth_provider_id', providerId) + + // Show the OAuth modal + setShowOAuthModal(true) + setOpen(false) + } + + // Clear selection + const handleClearSelection = () => { + setSelectedFileId('') + setSelectedFile(null) + onChange('', undefined) + onFileInfoChange?.(null) + } + + return ( + <> +
+ + + + + + {/* Current account indicator */} + {selectedCredentialId && credentials.length > 0 && ( +
+
+ + + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + +
+ {credentials.length > 1 && ( + + )} +
+ )} + + + + + + {isLoading ? ( +
+ + Loading pages... +
+ ) : error ? ( +
+

{error}

+
+ ) : credentials.length === 0 ? ( +
+

No accounts connected.

+

+ Connect a Confluence account to continue. +

+
+ ) : ( +
+

No pages found.

+

+ Try a different search or account. +

+
+ )} +
+ + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + +
+ Switch Account +
+ {credentials.map((cred) => ( + setSelectedCredentialId(cred.id)} + > +
+ + {cred.name} +
+ {cred.id === selectedCredentialId && } +
+ ))} +
+ )} + + {/* Files list */} + {files.length > 0 && ( + +
+ Pages +
+ {files.map((file) => ( + handleSelectFile(file)} + > +
+ + {file.name} +
+ {file.id === selectedFileId && } +
+ ))} +
+ )} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + +
+ + Connect Confluence account +
+
+
+ )} +
+
+
+
+ + {/* File preview */} + {showPreview && selectedFile && ( +
+
+ +
+
+
+ +
+
+
+

{selectedFile.name}

+ {selectedFile.modifiedTime && ( + + {new Date(selectedFile.modifiedTime).toLocaleDateString()} + + )} +
+ {selectedFile.webViewLink ? ( + e.stopPropagation()} + > + Open in Confluence + + + ) : ( + <> + )} +
+
+
+ )} +
+ + {showOAuthModal && ( + setShowOAuthModal(false)} + provider={provider} + toolName="Confluence" + requiredScopes={requiredScopes} + serviceId={getServiceId()} + /> + )} + + ) +} diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx index 91974e280c..e8944bacd7 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { SubBlockConfig } from '@/blocks/types' +import { ConfluenceFileInfo, ConfluenceFileSelector } from './components/confluence-file-selector' import { FileInfo, GoogleDrivePicker } from './components/google-drive-picker' interface FileSelectorInputProps { @@ -14,7 +15,15 @@ interface FileSelectorInputProps { export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileSelectorInputProps) { const { getValue, setValue } = useSubBlockStore() const [selectedFileId, setSelectedFileId] = useState('') - const [fileInfo, setFileInfo] = useState(null) + const [fileInfo, setFileInfo] = useState(null) + + // Get provider-specific values + const provider = subBlock.provider || 'google-drive' + const isConfluence = provider === 'confluence' + + // For Confluence, we need the domain and credentials + const domain = isConfluence ? (getValue(blockId, 'domain') as string) || '' : '' + const credentials = isConfluence ? (getValue(blockId, 'credential') as string) || '' : '' // Get the current value from the store useEffect(() => { @@ -25,20 +34,40 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS }, [blockId, subBlock.id, getValue]) // Handle file selection - const handleFileChange = (fileId: string, info?: FileInfo) => { + const handleFileChange = (fileId: string, info?: any) => { setSelectedFileId(fileId) setFileInfo(info || null) setValue(blockId, subBlock.id, fileId) } + // For Google Drive const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '' const apiKey = process.env.NEXT_PUBLIC_GOOGLE_API_KEY || '' + // Render the appropriate picker based on provider + if (isConfluence) { + return ( + void} + /> + ) + } + + // Default to Google Drive picker return ( { const blockType = useWorkflowStore.getState().blocks[blockId]?.type if (!blockType) return false diff --git a/sim/blocks/blocks/confluence.ts b/sim/blocks/blocks/confluence.ts index e1abdda7d6..6fa99a481b 100644 --- a/sim/blocks/blocks/confluence.ts +++ b/sim/blocks/blocks/confluence.ts @@ -1,8 +1,10 @@ import { ConfluenceIcon } from '@/components/icons' -import { ConfluenceRetrieveResponse } from '@/tools/confluence/types' +import { ConfluenceRetrieveResponse, ConfluenceUpdateResponse } from '@/tools/confluence/types' import { BlockConfig } from '../types' -export const ConfluenceBlock: BlockConfig = { +type ConfluenceResponse = ConfluenceRetrieveResponse | ConfluenceUpdateResponse + +export const ConfluenceBlock: BlockConfig = { type: 'confluence', name: 'Confluence', description: 'Interact with Confluence', @@ -12,12 +14,23 @@ export const ConfluenceBlock: BlockConfig = { bgColor: '#E0E0E0', icon: ConfluenceIcon, subBlocks: [ + // Operation selector + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Read Page', id: 'read' }, + { label: 'Update Page', id: 'update' }, + ], + }, { id: 'domain', title: 'Domain', type: 'short-input', layout: 'full', - placeholder: 'Enter Confluence domain (e.g., yourcompany.atlassian.net)', + placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)', }, { id: 'credential', @@ -26,21 +39,56 @@ export const ConfluenceBlock: BlockConfig = { layout: 'full', provider: 'confluence', serviceId: 'confluence', - requiredScopes: ['read:confluence-content.all', 'read:me', 'offline_access'], + requiredScopes: [ + 'read:page:confluence', + 'read:confluence-content.all', + 'write:confluence-content', + 'read:me', + 'offline_access', + ], placeholder: 'Select Confluence account', }, + // Use file-selector component for page selection { id: 'pageId', - title: 'Page ID', + title: 'Select Page', + type: 'file-selector', + layout: 'full', + provider: 'confluence', + serviceId: 'confluence', + placeholder: 'Select Confluence page', + }, + // Update page fields + { + id: 'title', + title: 'New Title', type: 'short-input', layout: 'full', - placeholder: 'Enter the confluence page ID (e.g., 12340)', + placeholder: 'Enter new title for the page', + condition: { field: 'operation', value: 'update' }, + }, + { + id: 'content', + title: 'New Content', + type: 'long-input', + layout: 'full', + placeholder: 'Enter new content for the page', + condition: { field: 'operation', value: 'update' }, }, ], tools: { - access: ['confluence_retrieve'], + access: ['confluence_retrieve', 'confluence_update'], config: { - tool: () => 'confluence_retrieve', + tool: (params) => { + switch (params.operation) { + case 'read': + return 'confluence_retrieve' + case 'update': + return 'confluence_update' + default: + return 'confluence_retrieve' + } + }, params: (params) => { const { credential, ...rest } = params @@ -52,9 +100,13 @@ export const ConfluenceBlock: BlockConfig = { }, }, inputs: { + operation: { type: 'string', required: true }, domain: { type: 'string', required: true }, credential: { type: 'string', required: true }, pageId: { type: 'string', required: true }, + // Update operation inputs + title: { type: 'string', required: false }, + content: { type: 'string', required: false }, }, outputs: { response: { @@ -63,6 +115,7 @@ export const ConfluenceBlock: BlockConfig = { pageId: 'string', content: 'string', title: 'string', + success: 'boolean', }, }, }, diff --git a/sim/blocks/blocks/drive.ts b/sim/blocks/blocks/drive.ts index a47eb7493b..204cc3c2fc 100644 --- a/sim/blocks/blocks/drive.ts +++ b/sim/blocks/blocks/drive.ts @@ -41,7 +41,7 @@ export const GoogleDriveBlock: BlockConfig = { layout: 'full', provider: 'google-drive', serviceId: 'google-drive', - requiredScopes: ['https://www.googleapis.com/auth/drive'], + requiredScopes: ['https://www.googleapis.com/auth/drive.file'], placeholder: 'Select Google Drive account', }, // Upload Fields diff --git a/sim/lib/auth.ts b/sim/lib/auth.ts index aec1040654..5e317b0079 100644 --- a/sim/lib/auth.ts +++ b/sim/lib/auth.ts @@ -208,7 +208,6 @@ export const auth = betterAuth({ scopes: [ 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/drive.file', ], prompt: 'consent', @@ -305,7 +304,13 @@ export const auth = betterAuth({ authorizationUrl: 'https://auth.atlassian.com/authorize', tokenUrl: 'https://auth.atlassian.com/oauth/token', userInfoUrl: 'https://api.atlassian.com/me', - scopes: ['read:confluence-content.all', 'read:me', 'offline_access'], + scopes: [ + 'read:page:confluence', + 'read:confluence-content.all', + 'read:me', + 'offline_access', + 'write:confluence-content', + ], responseType: 'code', pkce: true, accessType: 'offline', diff --git a/sim/lib/oauth.ts b/sim/lib/oauth.ts index cca381c98e..d2c1e1c0ff 100644 --- a/sim/lib/oauth.ts +++ b/sim/lib/oauth.ts @@ -75,10 +75,7 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'google-drive', icon: (props) => GoogleDriveIcon(props), baseProviderIcon: (props) => GoogleIcon(props), - scopes: [ - 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/drive.file', - ], + scopes: ['https://www.googleapis.com/auth/drive.file'], }, 'google-docs': { id: 'google-docs', @@ -185,7 +182,13 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'confluence', icon: (props) => ConfluenceIcon(props), baseProviderIcon: (props) => ConfluenceIcon(props), - scopes: ['read:confluence-content.all', 'read:me', 'offline_access'], + scopes: [ + 'read:page:confluence', + 'read:confluence-content.all', + 'read:me', + 'offline_access', + 'write:confluence-content', + ], }, }, defaultService: 'confluence', diff --git a/sim/tools/confluence/index.ts b/sim/tools/confluence/index.ts new file mode 100644 index 0000000000..adf634eaed --- /dev/null +++ b/sim/tools/confluence/index.ts @@ -0,0 +1,4 @@ +export * from './types' +export { confluenceRetrieveTool } from './retrieve' +export { confluenceListTool } from './list' +export { confluenceUpdateTool } from './update' diff --git a/sim/tools/confluence/list.ts b/sim/tools/confluence/list.ts new file mode 100644 index 0000000000..0dd5fe3a54 --- /dev/null +++ b/sim/tools/confluence/list.ts @@ -0,0 +1,104 @@ +import { ToolConfig } from '../types' +import { ConfluenceListParams, ConfluenceListResponse } from './types' + +export const confluenceListTool: ToolConfig = { + id: 'confluence_list', + name: 'Confluence List Pages', + description: 'List pages from Confluence using the Confluence API.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'confluence', + additionalScopes: [ + 'read:page:confluence', + 'read:confluence-content.all', + 'read:me', + 'offline_access', + ], + }, + + params: { + accessToken: { + type: 'string', + required: true, + description: 'OAuth access token for Confluence', + }, + domain: { + type: 'string', + required: true, + requiredForToolCall: true, + description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)', + }, + limit: { + type: 'number', + required: false, + description: 'Maximum number of pages to return (default: 25, max: 100)', + }, + spaceKey: { + type: 'string', + required: false, + description: 'Filter pages by space key', + }, + title: { + type: 'string', + required: false, + description: 'Filter pages by title', + }, + }, + + request: { + url: (params: ConfluenceListParams) => { + const baseUrl = `https://${params.domain}/wiki/api/v2/pages` + const queryParams = new URLSearchParams() + + if (params.limit) { + queryParams.append('limit', params.limit.toString()) + } + + if (params.spaceKey) { + queryParams.append('space-id', params.spaceKey) + } + + if (params.title) { + queryParams.append('title', params.title) + } + + const queryString = queryParams.toString() + return queryString ? `${baseUrl}?${queryString}` : baseUrl + }, + method: 'GET', + headers: (params: ConfluenceListParams) => { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.message || 'Confluence API error') + } + + return { + success: true, + output: { + ts: new Date().toISOString(), + pages: data.results.map((page: any) => ({ + id: page.id, + title: page.title, + spaceKey: page.spaceKey, + url: page._links?.webui || '', + lastModified: page.version?.when || '', + })), + }, + } + }, + + transformError: (error: any) => { + const message = error.message || 'Confluence list pages failed' + return message + }, +} diff --git a/sim/tools/confluence/retrieve.ts b/sim/tools/confluence/retrieve.ts index f461ed65e6..adf063caea 100644 --- a/sim/tools/confluence/retrieve.ts +++ b/sim/tools/confluence/retrieve.ts @@ -14,7 +14,12 @@ export const confluenceRetrieveTool: ToolConfig< oauth: { required: true, provider: 'confluence', - additionalScopes: ['read:confluence-content.all', 'read:me', 'offline_access'], + additionalScopes: [ + 'read:page:confluence', + 'read:confluence-content.all', + 'read:me', + 'offline_access', + ], }, params: { @@ -38,7 +43,7 @@ export const confluenceRetrieveTool: ToolConfig< request: { url: (params: ConfluenceRetrieveParams) => { - return `https://${params.domain}/wiki/rest/api/content/${params.pageId}?expand=body.view` + return `https://${params.domain}/wiki/api/v2/pages/${params.pageId}?expand=body.view` }, method: 'GET', headers: (params: ConfluenceRetrieveParams) => { diff --git a/sim/tools/confluence/types.ts b/sim/tools/confluence/types.ts index 49266a6520..4b1f291855 100644 --- a/sim/tools/confluence/types.ts +++ b/sim/tools/confluence/types.ts @@ -14,3 +14,44 @@ export interface ConfluenceRetrieveResponse extends ToolResponse { title: string } } + +export interface ConfluenceListParams { + accessToken: string + domain: string + limit?: number + spaceKey?: string + title?: string +} + +export interface ConfluencePage { + id: string + title: string + spaceKey?: string + url?: string + lastModified?: string +} + +export interface ConfluenceListResponse extends ToolResponse { + output: { + ts: string + pages: ConfluencePage[] + } +} + +export interface ConfluenceUpdateParams { + accessToken: string + domain: string + pageId: string + title?: string + content?: string + version?: number +} + +export interface ConfluenceUpdateResponse extends ToolResponse { + output: { + ts: string + pageId: string + title: string + success: boolean + } +} diff --git a/sim/tools/confluence/update.ts b/sim/tools/confluence/update.ts new file mode 100644 index 0000000000..b0597a575a --- /dev/null +++ b/sim/tools/confluence/update.ts @@ -0,0 +1,112 @@ +import { ToolConfig } from '../types' +import { ConfluenceUpdateParams, ConfluenceUpdateResponse } from './types' + +export const confluenceUpdateTool: ToolConfig = { + id: 'confluence_update', + name: 'Confluence Update', + description: 'Update a Confluence page using the Confluence API.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'confluence', + additionalScopes: [ + 'read:confluence-content.all', + 'write:confluence-content', + 'read:me', + 'offline_access', + ], + }, + + params: { + accessToken: { + type: 'string', + required: true, + description: 'OAuth access token for Confluence', + }, + domain: { + type: 'string', + required: true, + requiredForToolCall: true, + description: 'Your Confluence domain (e.g., yourcompany.atlassian.net)', + }, + pageId: { + type: 'string', + required: true, + description: 'Confluence page ID to update', + }, + title: { + type: 'string', + required: false, + description: 'New title for the page', + }, + content: { + type: 'string', + required: false, + description: 'New content for the page in Confluence storage format', + }, + version: { + type: 'number', + required: false, + description: 'Version number of the page (required for preventing conflicts)', + }, + }, + + request: { + url: (params: ConfluenceUpdateParams) => { + return `https://${params.domain}/wiki/api/v2/pages/${params.pageId}` + }, + method: 'PUT', + headers: (params: ConfluenceUpdateParams) => { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + } + }, + body: (params: ConfluenceUpdateParams) => { + const body: Record = {} + + if (params.title) { + body.title = params.title + } + + if (params.content) { + body.body = { + representation: 'storage', + value: params.content, + } + } + + if (params.version) { + body.version = { + number: params.version, + message: 'Updated via Sim Studio', + } + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.message || 'Confluence API error') + } + + return { + success: true, + output: { + ts: new Date().toISOString(), + pageId: data.id, + title: data.title, + success: true, + }, + } + }, + + transformError: (error: any) => { + const message = error.message || 'Confluence update failed' + return message + }, +} diff --git a/sim/tools/drive/download.ts b/sim/tools/drive/download.ts index d513ddec81..a9e7e975e5 100644 --- a/sim/tools/drive/download.ts +++ b/sim/tools/drive/download.ts @@ -9,7 +9,7 @@ export const downloadTool: ToolConfig = { guesty_guest: guestyGuestTool, perplexity_chat: perplexityChatTool, confluence_retrieve: confluenceRetrieveTool, + confluence_list: confluenceListTool, + confluence_update: confluenceUpdateTool, } // Get a tool by its ID