Skip to content

Commit aac9e74

Browse files
waleedlatif1claude
andauthored
feat(knowledge): add 10 new knowledge base connectors (#3430)
* feat(knowledge): add 10 new knowledge base connectors Add connectors for Dropbox, OneDrive, SharePoint, Slack, Google Docs, Asana, HubSpot, Salesforce, WordPress, and Webflow. Each connector implements listDocuments, getDocument, validateConfig with proper pagination, content hashing, and tag definitions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(connectors): address audit findings across 5 connectors OneDrive: fix encodeURIComponent breaking folder paths with slashes, add recursive folder traversal via folder queue in cursor state. Slack: add missing requiredScopes. Asana: pass retryOptions as 3rd arg to fetchWithRetry instead of spreading into RequestInit; add missing requiredScopes. HubSpot: add missing requiredScopes; fix sort property to use hs_lastmodifieddate for non-contact object types. Google Docs: remove orphaned title tag that was never populated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(connectors): add missing requiredScopes to OneDrive and HubSpot OneDrive: add requiredScopes: ['Files.Read'] HubSpot: add missing crm.objects.tickets.read scope Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(connectors): lint fixes * fix(connectors): slice documents to respect max limit on last page * fix(connectors): use per-segment encodeURIComponent for SharePoint folder paths encodeURI does not encode #, ?, &, + or = which are valid in folder names but break the Microsoft Graph URL. Apply the same per-segment encoding fix already used in the OneDrive connector. * fix(connectors): address PR review findings - Slack: remove private_channel from conversations.list types param since requiredScopes only cover public channels (channels:read, channels:history). Adding groups:read/groups:history would force all users to grant private channel access unnecessarily. - OneDrive/SharePoint: add .htm to supported extensions and handle it in content processing (htmlToPlainText), matching Dropbox. - Salesforce: guard getDocument for KnowledgeArticleVersion to skip records that are no longer PublishStatus='Online', preventing un-published articles from being re-synced. * fix(connectors): pre-download size check and remove dead parameter - OneDrive/SharePoint: add file size check against MAX_FILE_SIZE before downloading, matching Dropbox's behavior. Prevents OOM on large files. - Slack: remove unused syncContext parameter from fetchChannelMessages. * fix(connectors): slack getDocument user cache & wordpress scope reduction - Slack: pass a local syncContext to formatMessages in getDocument so resolveUserName caches user lookups across messages. Without this, every message triggered a fresh users.info API call. - WordPress: replace 'global' scope with 'posts' and 'sites' following principle of least privilege. The connector only reads posts and validates site existence. * fix(connectors): revert wordpress scope and slack local cache changes - WordPress: revert requiredScopes to ['global'] — the scope check does literal string matching, so ['posts', 'sites'] would always fail since auth.ts requests 'global' from WordPress.com OAuth. Reducing scope requires changing both auth.ts and the connector. - Slack: remove local syncContext from getDocument — the perf impact of uncached users.info calls is negligible for typical channels (bounded by unique users, not message count). * fix(connectors): align requiredScopes with auth.ts registrations The scope check in getMissingRequiredScopes does literal string matching against the OAuth token's granted scopes. requiredScopes must match what auth.ts actually requests (since that's what the provider returns). - HubSpot: use 'tickets' (legacy scope in auth.ts) instead of 'crm.objects.tickets.read' (v3 granular scope not requested) - Google Docs: use 'drive' (what auth.ts requests) instead of 'documents.readonly' and 'drive.readonly' (never requested, so never in the granted set) * fix(connectors): align Google Drive requiredScopes with auth.ts Google Drive connector required 'drive.readonly' but auth.ts requests 'drive' (the superset). Since scope validation does literal matching, this caused a spurious 'Additional permissions required' warning. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d6b97fe commit aac9e74

22 files changed

Lines changed: 4254 additions & 1 deletion

File tree

apps/sim/connectors/asana/asana.ts

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
import { createLogger } from '@sim/logger'
2+
import { AsanaIcon } from '@/components/icons'
3+
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
4+
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
5+
import { computeContentHash, joinTagArray, parseTagDate } from '@/connectors/utils'
6+
7+
const logger = createLogger('AsanaConnector')
8+
9+
const ASANA_API = 'https://app.asana.com/api/1.0'
10+
11+
const TASK_OPT_FIELDS =
12+
'name,notes,completed,completed_at,modified_at,assignee.name,tags.name,permalink_url'
13+
14+
/**
15+
* Asana API response shape for paginated endpoints.
16+
*/
17+
interface AsanaPageResponse {
18+
data: AsanaTask[]
19+
next_page: { offset: string; uri: string } | null
20+
}
21+
22+
/**
23+
* Minimal Asana task shape used by this connector.
24+
*/
25+
interface AsanaTask {
26+
gid: string
27+
name: string
28+
notes?: string
29+
completed: boolean
30+
completed_at?: string
31+
modified_at?: string
32+
assignee?: { name: string }
33+
tags?: { name: string }[]
34+
permalink_url?: string
35+
}
36+
37+
/**
38+
* Asana workspace shape.
39+
*/
40+
interface AsanaWorkspace {
41+
gid: string
42+
name: string
43+
}
44+
45+
/**
46+
* Asana project shape.
47+
*/
48+
interface AsanaProject {
49+
gid: string
50+
name: string
51+
}
52+
53+
/**
54+
* Makes a GET request to the Asana REST API.
55+
*/
56+
async function asanaGet<T>(
57+
accessToken: string,
58+
path: string,
59+
retryOptions?: Parameters<typeof fetchWithRetry>[2]
60+
): Promise<T> {
61+
const response = await fetchWithRetry(
62+
`${ASANA_API}${path}`,
63+
{
64+
method: 'GET',
65+
headers: {
66+
Authorization: `Bearer ${accessToken}`,
67+
Accept: 'application/json',
68+
},
69+
},
70+
retryOptions
71+
)
72+
73+
if (!response.ok) {
74+
const errorText = await response.text()
75+
logger.error('Asana API request failed', { status: response.status, path, error: errorText })
76+
throw new Error(`Asana API error: ${response.status}`)
77+
}
78+
79+
return (await response.json()) as T
80+
}
81+
82+
/**
83+
* Builds a formatted text document from an Asana task.
84+
*/
85+
function buildTaskContent(task: AsanaTask): string {
86+
const parts: string[] = []
87+
88+
parts.push(task.name || 'Untitled')
89+
90+
if (task.assignee?.name) parts.push(`Assignee: ${task.assignee.name}`)
91+
92+
parts.push(`Completed: ${task.completed ? 'Yes' : 'No'}`)
93+
94+
const tagNames = task.tags?.map((t) => t.name).filter(Boolean)
95+
if (tagNames && tagNames.length > 0) {
96+
parts.push(`Labels: ${tagNames.join(', ')}`)
97+
}
98+
99+
if (task.notes) {
100+
parts.push('')
101+
parts.push(task.notes)
102+
}
103+
104+
return parts.join('\n')
105+
}
106+
107+
/**
108+
* Fetches all project GIDs in a workspace, used when no specific project is configured.
109+
*/
110+
async function listWorkspaceProjects(
111+
accessToken: string,
112+
workspaceGid: string
113+
): Promise<AsanaProject[]> {
114+
const projects: AsanaProject[] = []
115+
let offset: string | undefined
116+
117+
// eslint-disable-next-line no-constant-condition
118+
while (true) {
119+
const offsetParam = offset ? `&offset=${offset}` : ''
120+
const result = await asanaGet<{ data: AsanaProject[]; next_page: { offset: string } | null }>(
121+
accessToken,
122+
`/projects?workspace=${workspaceGid}&limit=100${offsetParam}`
123+
)
124+
projects.push(...result.data)
125+
if (!result.next_page) break
126+
offset = result.next_page.offset
127+
}
128+
129+
return projects
130+
}
131+
132+
export const asanaConnector: ConnectorConfig = {
133+
id: 'asana',
134+
name: 'Asana',
135+
description: 'Sync tasks from Asana into your knowledge base',
136+
version: '1.0.0',
137+
icon: AsanaIcon,
138+
139+
oauth: {
140+
required: true,
141+
provider: 'asana',
142+
requiredScopes: ['default'],
143+
},
144+
145+
configFields: [
146+
{
147+
id: 'workspace',
148+
title: 'Workspace GID',
149+
type: 'short-input',
150+
placeholder: 'e.g. 1234567890',
151+
required: true,
152+
},
153+
{
154+
id: 'project',
155+
title: 'Project GID',
156+
type: 'short-input',
157+
placeholder: 'e.g. 9876543210 (leave empty for all projects)',
158+
required: false,
159+
},
160+
{
161+
id: 'maxTasks',
162+
title: 'Max Tasks',
163+
type: 'short-input',
164+
placeholder: 'e.g. 500 (default: unlimited)',
165+
required: false,
166+
},
167+
],
168+
169+
listDocuments: async (
170+
accessToken: string,
171+
sourceConfig: Record<string, unknown>,
172+
cursor?: string,
173+
syncContext?: Record<string, unknown>
174+
): Promise<ExternalDocumentList> => {
175+
const workspaceGid = sourceConfig.workspace as string
176+
const projectGid = (sourceConfig.project as string) || ''
177+
const maxTasks = sourceConfig.maxTasks ? Number(sourceConfig.maxTasks) : 0
178+
const pageSize = maxTasks > 0 ? Math.min(maxTasks, 100) : 100
179+
180+
/**
181+
* Cursor format:
182+
* - For a single project: the offset string directly, or undefined
183+
* - For all projects: JSON-encoded { projectIndex, offset }
184+
*/
185+
let projectGids: string[]
186+
let projectIndex = 0
187+
let offset: string | undefined
188+
189+
if (projectGid) {
190+
projectGids = [projectGid]
191+
} else {
192+
if (!syncContext?.projectGids) {
193+
logger.info('Fetching all projects in workspace', { workspaceGid })
194+
const projects = await listWorkspaceProjects(accessToken, workspaceGid)
195+
if (syncContext) syncContext.projectGids = projects.map((p) => p.gid)
196+
projectGids = projects.map((p) => p.gid)
197+
} else {
198+
projectGids = syncContext.projectGids as string[]
199+
}
200+
}
201+
202+
if (cursor) {
203+
try {
204+
const parsed = JSON.parse(cursor) as { projectIndex: number; offset?: string }
205+
projectIndex = parsed.projectIndex
206+
offset = parsed.offset
207+
} catch {
208+
offset = cursor
209+
}
210+
}
211+
212+
logger.info('Listing Asana tasks', {
213+
workspaceGid,
214+
projectCount: projectGids.length,
215+
projectIndex,
216+
offset,
217+
pageSize,
218+
})
219+
220+
const documents: ExternalDocument[] = []
221+
let nextCursor: string | undefined
222+
let hasMore = false
223+
224+
while (projectIndex < projectGids.length) {
225+
const currentProjectGid = projectGids[projectIndex]
226+
const offsetParam = offset ? `&offset=${offset}` : ''
227+
228+
const result = await asanaGet<AsanaPageResponse>(
229+
accessToken,
230+
`/tasks?project=${currentProjectGid}&opt_fields=${TASK_OPT_FIELDS}&limit=${pageSize}${offsetParam}`
231+
)
232+
233+
for (const task of result.data) {
234+
const content = buildTaskContent(task)
235+
const contentHash = await computeContentHash(content)
236+
const tagNames = task.tags?.map((t) => t.name).filter(Boolean) || []
237+
238+
documents.push({
239+
externalId: task.gid,
240+
title: task.name || 'Untitled',
241+
content,
242+
mimeType: 'text/plain',
243+
sourceUrl: task.permalink_url || undefined,
244+
contentHash,
245+
metadata: {
246+
project: currentProjectGid,
247+
assignee: task.assignee?.name,
248+
completed: task.completed,
249+
lastModified: task.modified_at,
250+
labels: tagNames,
251+
},
252+
})
253+
}
254+
255+
if (result.next_page) {
256+
nextCursor = JSON.stringify({ projectIndex, offset: result.next_page.offset })
257+
hasMore = true
258+
break
259+
}
260+
261+
projectIndex++
262+
offset = undefined
263+
264+
if (projectIndex < projectGids.length) {
265+
nextCursor = JSON.stringify({ projectIndex, offset: undefined })
266+
hasMore = true
267+
break
268+
}
269+
}
270+
271+
const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0
272+
if (maxTasks > 0) {
273+
const remaining = maxTasks - previouslyFetched
274+
if (documents.length > remaining) {
275+
documents.splice(remaining)
276+
}
277+
}
278+
279+
const totalFetched = previouslyFetched + documents.length
280+
if (syncContext) syncContext.totalDocsFetched = totalFetched
281+
const hitLimit = maxTasks > 0 && totalFetched >= maxTasks
282+
283+
if (hitLimit) {
284+
hasMore = false
285+
nextCursor = undefined
286+
}
287+
288+
return {
289+
documents,
290+
nextCursor: hasMore ? nextCursor : undefined,
291+
hasMore,
292+
}
293+
},
294+
295+
getDocument: async (
296+
accessToken: string,
297+
_sourceConfig: Record<string, unknown>,
298+
externalId: string
299+
): Promise<ExternalDocument | null> => {
300+
try {
301+
const result = await asanaGet<{ data: AsanaTask }>(
302+
accessToken,
303+
`/tasks/${externalId}?opt_fields=${TASK_OPT_FIELDS}`
304+
)
305+
const task = result.data
306+
307+
if (!task) return null
308+
309+
const content = buildTaskContent(task)
310+
const contentHash = await computeContentHash(content)
311+
const tagNames = task.tags?.map((t) => t.name).filter(Boolean) || []
312+
313+
return {
314+
externalId: task.gid,
315+
title: task.name || 'Untitled',
316+
content,
317+
mimeType: 'text/plain',
318+
sourceUrl: task.permalink_url || undefined,
319+
contentHash,
320+
metadata: {
321+
assignee: task.assignee?.name,
322+
completed: task.completed,
323+
lastModified: task.modified_at,
324+
labels: tagNames,
325+
},
326+
}
327+
} catch (error) {
328+
logger.error('Failed to get Asana task', {
329+
externalId,
330+
error: error instanceof Error ? error.message : String(error),
331+
})
332+
return null
333+
}
334+
},
335+
336+
validateConfig: async (
337+
accessToken: string,
338+
sourceConfig: Record<string, unknown>
339+
): Promise<{ valid: boolean; error?: string }> => {
340+
const workspaceGid = sourceConfig.workspace as string | undefined
341+
if (!workspaceGid) {
342+
return { valid: false, error: 'Workspace GID is required' }
343+
}
344+
345+
const maxTasks = sourceConfig.maxTasks as string | undefined
346+
if (maxTasks && (Number.isNaN(Number(maxTasks)) || Number(maxTasks) <= 0)) {
347+
return { valid: false, error: 'Max tasks must be a positive number' }
348+
}
349+
350+
try {
351+
await asanaGet<{ data: AsanaWorkspace }>(
352+
accessToken,
353+
`/workspaces/${workspaceGid}`,
354+
VALIDATE_RETRY_OPTIONS
355+
)
356+
return { valid: true }
357+
} catch (error) {
358+
const message = error instanceof Error ? error.message : 'Failed to validate configuration'
359+
return { valid: false, error: message }
360+
}
361+
},
362+
363+
tagDefinitions: [
364+
{ id: 'project', displayName: 'Project', fieldType: 'text' },
365+
{ id: 'assignee', displayName: 'Assignee', fieldType: 'text' },
366+
{ id: 'completed', displayName: 'Completed', fieldType: 'boolean' },
367+
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
368+
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
369+
],
370+
371+
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
372+
const result: Record<string, unknown> = {}
373+
374+
if (typeof metadata.project === 'string') result.project = metadata.project
375+
if (typeof metadata.assignee === 'string') result.assignee = metadata.assignee
376+
if (typeof metadata.completed === 'boolean') result.completed = metadata.completed
377+
378+
const lastModified = parseTagDate(metadata.lastModified)
379+
if (lastModified) result.lastModified = lastModified
380+
381+
const labels = joinTagArray(metadata.labels)
382+
if (labels) result.labels = labels
383+
384+
return result
385+
},
386+
}

apps/sim/connectors/asana/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { asanaConnector } from '@/connectors/asana/asana'

0 commit comments

Comments
 (0)