improvement(tools): added file selector for confluence, wip. removed unneccessary restricted google scopes

This commit is contained in:
Waleed Latif
2025-03-25 15:13:19 -07:00
parent d5e1560b47
commit 34b2e4389c
18 changed files with 1059 additions and 27 deletions
@@ -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 }
)
}
}
@@ -36,14 +36,18 @@ export interface OAuthRequiredModalProps {
const SCOPE_DESCRIPTIONS: Record<string, string> = {
'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',
@@ -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<Credential[]>([])
const [files, setFiles] = useState<ConfluenceFileInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedFileId, setSelectedFileId] = useState(value)
const [selectedFile, setSelectedFile] = useState<ConfluenceFileInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
const [error, setError] = useState<string | null>(null)
// Handle search with debounce
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(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<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('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 (
<>
<div className="space-y-2">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled || !domain}
>
{selectedFile ? (
<div className="flex items-center gap-2 overflow-hidden">
<ConfluenceIcon className="h-4 w-4" />
<span className="font-normal truncate">{selectedFile.name}</span>
</div>
) : (
<div className="flex items-center gap-2">
<ConfluenceIcon className="h-4 w-4" />
<span className="text-muted-foreground">{label}</span>
</div>
)}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[300px]" align="start">
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className="px-3 py-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2">
<ConfluenceIcon className="h-4 w-4" />
<span className="text-xs text-muted-foreground">
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder="Search pages..." onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className="flex items-center justify-center p-4">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="ml-2">Loading pages...</span>
</div>
) : error ? (
<div className="p-4 text-center">
<p className="text-sm text-destructive">{error}</p>
</div>
) : credentials.length === 0 ? (
<div className="p-4 text-center">
<p className="text-sm font-medium">No accounts connected.</p>
<p className="text-xs text-muted-foreground">
Connect a Confluence account to continue.
</p>
</div>
) : (
<div className="p-4 text-center">
<p className="text-sm font-medium">No pages found.</p>
<p className="text-xs text-muted-foreground">
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className="flex items-center gap-2">
<ConfluenceIcon className="h-4 w-4" />
<span className="font-normal">{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Files list */}
{files.length > 0 && (
<CommandGroup>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Pages
</div>
{files.map((file) => (
<CommandItem
key={file.id}
value={`file-${file.id}-${file.name}`}
onSelect={() => handleSelectFile(file)}
>
<div className="flex items-center gap-2 overflow-hidden">
<ConfluenceIcon className="h-4 w-4" />
<span className="font-normal truncate">{file.name}</span>
</div>
{file.id === selectedFileId && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className="flex items-center gap-2 text-primary">
<ConfluenceIcon className="h-4 w-4" />
<span>Connect Confluence account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* File preview */}
{showPreview && selectedFile && (
<div className="mt-2 rounded-md border border-muted bg-muted/10 p-2 relative">
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 hover:bg-muted"
onClick={handleClearSelection}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-3 pr-4">
<div className="flex-shrink-0 flex items-center justify-center h-6 w-6 bg-muted/20 rounded">
<ConfluenceIcon className="h-4 w-4" />
</div>
<div className="overflow-hidden flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-xs font-medium truncate">{selectedFile.name}</h4>
{selectedFile.modifiedTime && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
</span>
)}
</div>
{selectedFile.webViewLink ? (
<a
href={selectedFile.webViewLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<span>Open in Confluence</span>
<ExternalLink className="h-3 w-3" />
</a>
) : (
<></>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName="Confluence"
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}
@@ -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<string>('')
const [fileInfo, setFileInfo] = useState<FileInfo | null>(null)
const [fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(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 (
<ConfluenceFileSelector
value={selectedFileId}
onChange={handleFileChange}
domain={domain}
provider="confluence"
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Confluence page'}
disabled={disabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
/>
)
}
// Default to Google Drive picker
return (
<GoogleDrivePicker
value={selectedFileId}
onChange={handleFileChange}
provider={subBlock.provider || 'google-drive'}
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select file'}
disabled={disabled}
@@ -1,5 +1,6 @@
import { Label } from '@/components/ui/label'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { getBlock } from '@/blocks/index'
import { SubBlockConfig } from '@/blocks/types'
@@ -33,6 +34,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
e.stopPropagation()
}
const { getValue } = useSubBlockStore()
const isFieldRequired = () => {
const blockType = useWorkflowStore.getState().blocks[blockId]?.type
if (!blockType) return false
+61 -8
View File
@@ -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<ConfluenceRetrieveResponse> = {
type ConfluenceResponse = ConfluenceRetrieveResponse | ConfluenceUpdateResponse
export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
type: 'confluence',
name: 'Confluence',
description: 'Interact with Confluence',
@@ -12,12 +14,23 @@ export const ConfluenceBlock: BlockConfig<ConfluenceRetrieveResponse> = {
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<ConfluenceRetrieveResponse> = {
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<ConfluenceRetrieveResponse> = {
},
},
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<ConfluenceRetrieveResponse> = {
pageId: 'string',
content: 'string',
title: 'string',
success: 'boolean',
},
},
},
+1 -1
View File
@@ -41,7 +41,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
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
+7 -2
View File
@@ -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',
+8 -5
View File
@@ -75,10 +75,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
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<string, OAuthProviderConfig> = {
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',
+4
View File
@@ -0,0 +1,4 @@
export * from './types'
export { confluenceRetrieveTool } from './retrieve'
export { confluenceListTool } from './list'
export { confluenceUpdateTool } from './update'
+104
View File
@@ -0,0 +1,104 @@
import { ToolConfig } from '../types'
import { ConfluenceListParams, ConfluenceListResponse } from './types'
export const confluenceListTool: ToolConfig<ConfluenceListParams, ConfluenceListResponse> = {
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
},
}
+7 -2
View File
@@ -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) => {
+41
View File
@@ -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
}
}
+112
View File
@@ -0,0 +1,112 @@
import { ToolConfig } from '../types'
import { ConfluenceUpdateParams, ConfluenceUpdateResponse } from './types'
export const confluenceUpdateTool: ToolConfig<ConfluenceUpdateParams, ConfluenceUpdateResponse> = {
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<string, any> = {}
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
},
}
+1 -1
View File
@@ -9,7 +9,7 @@ export const downloadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveDownload
oauth: {
required: true,
provider: 'google-drive',
additionalScopes: ['https://www.googleapis.com/auth/drive'],
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
},
params: {
accessToken: { type: 'string', required: true },
+1 -1
View File
@@ -16,7 +16,7 @@ export const exportTool: ToolConfig<
oauth: {
required: true,
provider: 'google-drive',
additionalScopes: ['https://www.googleapis.com/auth/drive'],
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
},
params: {
accessToken: { type: 'string', required: true },
+1 -1
View File
@@ -9,7 +9,7 @@ export const listTool: ToolConfig<GoogleDriveToolParams, GoogleDriveListResponse
oauth: {
required: true,
provider: 'google-drive',
additionalScopes: ['https://www.googleapis.com/auth/drive'],
additionalScopes: ['https://www.googleapis.com/auth/drive.file'],
},
params: {
accessToken: { type: 'string', required: true },
+3 -1
View File
@@ -1,6 +1,7 @@
import { createLogger } from '@/lib/logs/console-logger'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import { confluenceListTool, confluenceRetrieveTool, confluenceUpdateTool } from './confluence'
import { docsCreateTool, docsReadTool, docsWriteTool } from './docs'
import { driveDownloadTool, driveListTool, driveUploadTool } from './drive'
import { exaAnswerTool, exaFindSimilarLinksTool, exaGetContentsTool, exaSearchTool } from './exa'
@@ -40,7 +41,6 @@ import { visionTool } from './vision/vision'
import { whatsappSendMessageTool } from './whatsapp'
import { xReadTool, xSearchTool, xUserTool, xWriteTool } from './x'
import { youtubeSearchTool } from './youtube/search'
import { confluenceRetrieveTool } from './confluence/retrieve'
const logger = createLogger('Tools')
@@ -100,6 +100,8 @@ export const tools: Record<string, ToolConfig> = {
guesty_guest: guestyGuestTool,
perplexity_chat: perplexityChatTool,
confluence_retrieve: confluenceRetrieveTool,
confluence_list: confluenceListTool,
confluence_update: confluenceUpdateTool,
}
// Get a tool by its ID