Files
simstudioai--sim/apps/sim/connectors/google-sheets/google-sheets.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

380 lines
11 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { googleSheetsConnectorMeta } from '@/connectors/google-sheets/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { parseTagDate } from '@/connectors/utils'
const logger = createLogger('GoogleSheetsConnector')
const SHEETS_API_BASE = 'https://sheets.googleapis.com/v4/spreadsheets'
const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3/files'
const MAX_ROWS = 10000
const CONCURRENCY = 3
interface SheetProperties {
sheetId: number
title: string
index: number
gridProperties?: {
rowCount?: number
columnCount?: number
}
}
interface SpreadsheetMetadata {
spreadsheetId: string
properties: {
title: string
locale?: string
}
sheets: { properties: SheetProperties }[]
}
/**
* Formats sheet data into an LLM-friendly text representation.
* Each row is labeled with its index and columns are identified by header names.
*/
function formatSheetContent(headers: string[], rows: string[][]): string {
if (headers.length === 0) return ''
const lines: string[] = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
lines.push(`Row ${i + 1}:`)
for (let j = 0; j < headers.length; j++) {
const value = j < row.length ? row[j] : ''
lines.push(` ${headers[j]}: ${value}`)
}
lines.push('')
}
return lines.join('\n').trim()
}
/**
* Fetches all values from a single sheet tab.
*/
async function fetchSheetValues(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
): Promise<string[][]> {
const range = `'${sheetTitle.replace(/'/g, "''")}'!A1:ZZ${MAX_ROWS}`
const url = `${SHEETS_API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}?majorDimension=ROWS&valueRenderOption=FORMATTED_VALUE`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch sheet values for "${sheetTitle}": ${response.status}`)
}
const data = await response.json()
return (data.values || []) as string[][]
}
/**
* Fetches spreadsheet metadata (title, sheet names, grid properties).
*/
async function fetchSpreadsheetMetadata(
accessToken: string,
spreadsheetId: string
): Promise<SpreadsheetMetadata> {
const url = `${SHEETS_API_BASE}/${encodeURIComponent(spreadsheetId)}?fields=spreadsheetId,properties.title,properties.locale,sheets.properties`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch spreadsheet metadata: ${response.status}`)
}
return (await response.json()) as SpreadsheetMetadata
}
/**
* Fetches the spreadsheet's modifiedTime from the Drive API.
*/
async function fetchSpreadsheetModifiedTime(
accessToken: string,
spreadsheetId: string
): Promise<string | undefined> {
try {
const url = `${DRIVE_API_BASE}/${encodeURIComponent(spreadsheetId)}?fields=modifiedTime&supportsAllDrives=true`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
logger.warn('Failed to fetch modifiedTime from Drive API', { status: response.status })
return undefined
}
const data = (await response.json()) as { modifiedTime?: string }
return data.modifiedTime
} catch (error) {
logger.warn('Error fetching modifiedTime from Drive API', {
error: toError(error).message,
})
return undefined
}
}
/**
* Converts a single sheet tab into an ExternalDocument.
*/
async function sheetToDocument(
accessToken: string,
spreadsheetId: string,
spreadsheetTitle: string,
sheet: SheetProperties,
modifiedTime?: string
): Promise<ExternalDocument | null> {
try {
const values = await fetchSheetValues(accessToken, spreadsheetId, sheet.title)
if (values.length === 0) {
logger.info(`Skipping empty sheet: ${sheet.title}`)
return null
}
const headers = values[0].map((h, idx) =>
typeof h === 'string' && h.trim() ? h.trim() : `Column ${idx + 1}`
)
const dataRows = values.slice(1)
if (dataRows.length === 0) {
logger.info(`Skipping header-only sheet: ${sheet.title}`)
return null
}
const content = formatSheetContent(headers, dataRows)
if (!content.trim()) {
return null
}
const rowCount = dataRows.length
return {
externalId: `${spreadsheetId}__sheet__${sheet.sheetId}`,
title: `${spreadsheetTitle} - ${sheet.title}`,
content,
mimeType: 'text/plain',
sourceUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit#gid=${sheet.sheetId}`,
contentHash: `gsheets:${spreadsheetId}:${sheet.sheetId}:${modifiedTime ?? ''}`,
metadata: {
spreadsheetId,
spreadsheetTitle,
sheetTitle: sheet.title,
sheetId: sheet.sheetId,
rowCount,
columnCount: headers.length,
...(modifiedTime ? { modifiedTime } : {}),
},
}
} catch (error) {
logger.warn(`Failed to extract content from sheet: ${sheet.title}`, {
error: toError(error).message,
})
return null
}
}
export const googleSheetsConnector: ConnectorConfig = {
...googleSheetsConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
_cursor?: string,
_syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const spreadsheetId = (sourceConfig.spreadsheetId as string)?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
logger.info('Fetching spreadsheet metadata', { spreadsheetId })
const [metadata, modifiedTime] = await Promise.all([
fetchSpreadsheetMetadata(accessToken, spreadsheetId),
fetchSpreadsheetModifiedTime(accessToken, spreadsheetId),
])
const sheetFilter = (sourceConfig.sheetFilter as string) || 'all'
let sheets = metadata.sheets.map((s) => s.properties)
if (sheetFilter === 'first' && sheets.length > 0) {
sheets = [sheets[0]]
}
logger.info('Processing sheets', {
spreadsheetTitle: metadata.properties.title,
sheetCount: sheets.length,
})
const documents: ExternalDocument[] = sheets.map((sheet) => ({
externalId: `${spreadsheetId}__sheet__${sheet.sheetId}`,
title: `${metadata.properties.title} - ${sheet.title}`,
content: '',
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit#gid=${sheet.sheetId}`,
contentHash: `gsheets:${spreadsheetId}:${sheet.sheetId}:${modifiedTime ?? ''}`,
metadata: {
spreadsheetId,
spreadsheetTitle: metadata.properties.title,
sheetTitle: sheet.title,
sheetId: sheet.sheetId,
rowCount: sheet.gridProperties?.rowCount,
columnCount: sheet.gridProperties?.columnCount,
...(modifiedTime ? { modifiedTime } : {}),
},
}))
return {
documents,
hasMore: false,
}
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const parts = externalId.split('__sheet__')
if (parts.length !== 2) {
logger.warn('Invalid external ID format', { externalId })
return null
}
const spreadsheetId = parts[0]
const sheetId = Number(parts[1])
if (Number.isNaN(sheetId)) {
logger.warn('Invalid sheet ID in external ID', { externalId })
return null
}
let metadata: SpreadsheetMetadata
let modifiedTime: string | undefined
try {
;[metadata, modifiedTime] = await Promise.all([
fetchSpreadsheetMetadata(accessToken, spreadsheetId),
fetchSpreadsheetModifiedTime(accessToken, spreadsheetId),
])
} catch (error) {
const message = toError(error).message
if (message.includes('404')) {
logger.info('Spreadsheet not found (possibly deleted)', { spreadsheetId })
return null
}
throw error
}
const sheetEntry = metadata.sheets.find((s) => s.properties.sheetId === sheetId)
if (!sheetEntry) {
logger.info('Sheet not found in spreadsheet', { spreadsheetId, sheetId })
return null
}
const doc = await sheetToDocument(
accessToken,
spreadsheetId,
metadata.properties.title,
sheetEntry.properties,
modifiedTime
)
if (!doc) return null
return { ...doc, contentDeferred: false }
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const spreadsheetId = (sourceConfig.spreadsheetId as string)?.trim()
if (!spreadsheetId) {
return { valid: false, error: 'Spreadsheet ID is required' }
}
try {
const url = `${SHEETS_API_BASE}/${encodeURIComponent(spreadsheetId)}?fields=spreadsheetId,properties.title`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
if (response.status === 404) {
return {
valid: false,
error: 'Spreadsheet not found. Check the ID and ensure it is shared with your account.',
}
}
if (response.status === 403) {
return {
valid: false,
error: 'Access denied. Ensure the spreadsheet is shared with your Google account.',
}
}
return { valid: false, error: `Failed to access spreadsheet: ${response.status}` }
}
return { valid: true }
} catch (error) {
const message = getErrorMessage(error, 'Failed to validate configuration')
return { valid: false, error: message }
}
},
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.sheetTitle === 'string') {
result.sheetTitle = metadata.sheetTitle
}
if (typeof metadata.rowCount === 'number') {
result.rowCount = metadata.rowCount
}
if (typeof metadata.columnCount === 'number') {
result.columnCount = metadata.columnCount
}
const lastModified = parseTagDate(metadata.modifiedTime)
if (lastModified) {
result.lastModified = lastModified
}
return result
},
}