chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
import { ActionDefinition, z } from '@botpress/sdk'
import { baseIdentifierSchema } from './common'
const recordSchema = z
.object({
id: baseIdentifierSchema.describe('The record identifier'),
created_at: z.string().title('Created At').describe('RFC3339 timestamp when the record was created'),
web_url: z.string().title('Web URL').describe('URL of the record in Attio UI'),
values: z
.record(z.any())
.title('Values')
.describe('Map of attribute slug or ID to its value (single value or array of values)'),
})
.title('Record')
// Objects & Attributes
const objectSchema = z
.object({
id: baseIdentifierSchema.optional().describe('The object identifier'),
api_slug: z.string().optional().title('API Slug').describe('The API slug for the object'),
singular_noun: z.string().optional().title('Singular Noun').describe('The singular form of the object name'),
plural_noun: z.string().optional().title('Plural Noun').describe('The plural form of the object name'),
created_at: z.string().optional().title('Created At').describe('RFC3339 timestamp when the object was created'),
})
.title('Object')
const attributeSchema = z
.object({
id: baseIdentifierSchema
.extend({ attribute_id: z.string().optional().title('Attribute ID').describe('The attribute identifier') })
.optional()
.describe('The attribute identifier object'),
title: z.string().optional().title('Title').describe('The title of the attribute'),
description: z.string().nullable().optional().title('Description').describe('The description of the attribute'),
api_slug: z.string().optional().title('API Slug').describe('The API slug for the attribute'),
type: z.string().optional().title('Type').describe('The type of the attribute'),
slug: z.string().optional().title('Slug').describe('The slug for the attribute'),
options: z
.array(
z.object({
id: z.string().optional().title('ID').describe('The option identifier'),
label: z.string().optional().title('Label').describe('The label of the option'),
name: z.string().optional().title('Name').describe('The name of the option'),
value: z.string().optional().title('Value').describe('The value of the option'),
title: z.string().optional().title('Title').describe('The title of the option'),
slug: z.string().optional().title('Slug').describe('The slug of the option'),
})
)
.optional()
.title('Options')
.describe('The list of options for the attribute'),
})
.title('Attribute')
const listRecords: ActionDefinition = {
title: 'List Records',
description: 'List records of an Attio object with optional filters, sorts and pagination',
input: {
schema: z.object({
object: z.string().min(1).title('Object').describe("Object slug or UUID, e.g. 'people'"),
filter: z
.array(
z.object({
attribute: z.string().min(1).title('Attribute').describe('The attribute to filter by'),
value: z.string().min(1).title('Value').describe('The value to filter for'),
})
)
.optional()
.title('Filter')
.describe('Filtering object. See Attio Shorthand Filtering guide'),
sorts: z
.array(
z.object({
direction: z.enum(['asc', 'desc']).title('Direction').describe('The sort direction'),
attribute: z.string().min(1).title('Attribute').describe('The attribute to sort by'),
field: z.string().min(1).title('Field').describe('The field to sort by'),
})
)
.optional()
.title('Sorting')
.describe('Sorting instructions. See Attio sorting guide'),
limit: z.number().optional().title('Limit').describe('Max number of records to return (default 500)'),
offset: z.number().optional().title('Offset').describe('Number of records to skip (default 0)'),
}),
},
output: {
schema: z
.object({
data: z.array(recordSchema).title('Records').describe('List of records'),
})
.title('List Records Response'),
},
}
const getRecord: ActionDefinition = {
title: 'Get Record',
description: 'Get a single record by object and record ID',
input: {
schema: z.object({
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
id: baseIdentifierSchema
.extend({ record_id: z.string().min(1).title('Record ID').describe('Record UUID') })
.describe('The record identifier'),
}),
},
output: {
schema: z
.object({
data: z
.object({
id: baseIdentifierSchema.title('Identifier').describe('The fetched identifier'),
web_url: z.string().title('Web URL').describe('URL of the record in Attio UI'),
values: z.record(z.any()).title('Values').describe('Map of attribute slug/ID to value(s)'),
created_at: z.string().title('Created At').describe('RFC3339 timestamp when the record was created'),
})
.title('Data')
.describe('The fetched record data'),
})
.title('Get Record Response'),
},
}
const createRecord: ActionDefinition = {
title: 'Create Record',
description: 'Create a new record in an Attio object',
input: {
schema: z.object({
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
data: z
.object({
values: z
.array(
z.object({
attribute: z.string().min(1).title('Attribute').describe('The attribute slug or ID'),
value: z.string().min(1).title('Value').describe('The value to set for the attribute'),
})
)
.title('Values')
.describe('Array of attribute slug/ID to value(s)'),
})
.title('Data')
.describe('The record data to create'),
}),
},
output: {
schema: z
.object({
data: recordSchema.title('Record').describe('The created record'),
})
.title('Create Record Response'),
},
}
const updateRecord: ActionDefinition = {
title: 'Update Record',
description: 'Update an existing record by object and record ID',
input: {
schema: z.object({
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
id: baseIdentifierSchema
.extend({ record_id: z.string().min(1).title('Record ID').describe('Record UUID') })
.describe('The record identifier'),
data: z
.object({
values: z
.array(
z.object({
attribute: z.string().min(1).title('Attribute').describe('The attribute slug or ID'),
value: z.string().min(1).title('Value').describe('The value to set for the attribute'),
})
)
.title('Values')
.describe('Array of attribute slug/ID to value(s) to upsert'),
})
.title('Data')
.describe('The record data to update'),
}),
},
output: {
schema: z
.object({
data: recordSchema.title('Record').describe('The updated record'),
})
.title('Update Record Response'),
},
}
const listObjects: ActionDefinition = {
title: 'List Objects',
description: 'List Attio objects in the workspace',
input: { schema: z.object({}) },
output: {
schema: z
.object({
data: z.array(objectSchema).title('Objects').describe('List of objects'),
})
.title('List Objects Response'),
},
}
const getObject: ActionDefinition = {
title: 'Get Object',
description: 'Get a single Attio object by slug or ID',
input: {
schema: z.object({
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
}),
},
output: {
schema: z
.object({
data: objectSchema.title('Object').describe('The requested object'),
})
.title('Get Object Response'),
},
}
const listAttributes: ActionDefinition = {
title: 'List Attributes',
description: 'List attributes for a given Attio object',
input: {
schema: z.object({
object: z.string().min(1).title('Object').describe('Object slug or UUID'),
}),
},
output: {
schema: z
.object({
data: z.array(attributeSchema).title('Attributes').describe('List of attributes'),
})
.title('List Attributes Response'),
},
}
export const actions = {
listRecords,
getRecord,
createRecord,
updateRecord,
listObjects,
getObject,
listAttributes,
} as const
+8
View File
@@ -0,0 +1,8 @@
import { z } from '@botpress/sdk'
export const baseIdentifierSchema = z
.object({
workspace_id: z.string().title('Workspace ID').describe('The Attio workspace ID'),
object_id: z.string().title('Object ID').describe('The Attio object ID'),
})
.title('Record Identifier')
+46
View File
@@ -0,0 +1,46 @@
import { EventDefinition, z } from '@botpress/sdk'
import { baseIdentifierSchema } from './common'
const recordEventSchema = z.object({
event_type: z.string().title('Event Type').describe('The type of event'),
id: baseIdentifierSchema
.extend({ record_id: z.string().title('Record ID').describe('The record identifier') })
.describe('The record identifier object'),
actor: z
.object({
type: z.string().title('Actor Type').describe('The type of actor (e.g., workspace-member)'),
id: z.string().title('Actor ID').describe('The actor identifier'),
})
.title('Actor')
.describe('The actor who triggered the event'),
})
const recordCreated: EventDefinition = {
title: 'Record Created',
description: 'A new record has been created in Attio',
schema: recordEventSchema.extend({
event_type: z.literal('record.created').title('Event Type').describe('The type of event'),
}),
}
const recordUpdated: EventDefinition = {
title: 'Record Updated',
description: 'A record has been updated in Attio',
schema: recordEventSchema.extend({
event_type: z.literal('record.updated').title('Event Type').describe('The type of event'),
}),
}
const recordDeleted: EventDefinition = {
title: 'Record Deleted',
description: 'A record has been deleted in Attio',
schema: recordEventSchema.extend({
event_type: z.literal('record.deleted').title('Event Type').describe('The type of event'),
}),
}
export const events = {
recordCreated,
recordUpdated,
recordDeleted,
}
+3
View File
@@ -0,0 +1,3 @@
export { actions } from './actions'
export { states } from './state'
export { events } from './events'
+16
View File
@@ -0,0 +1,16 @@
import { z, StateDefinition } from '@botpress/sdk'
const attioIntegrationInfo: StateDefinition = {
type: 'integration',
schema: z.object({
attioWebhookId: z
.string()
.nullable()
.title('Attio Webhook ID')
.describe('ID of the webhook created in Attio for this integration'),
}),
}
export const states = {
attioIntegrationInfo,
}
+25
View File
@@ -0,0 +1,25 @@
# Botpress Integration: Attio CRM
## Overview
The Attio integration connects your Botpress bot to Attios workspace data so you can list, retrieve, create, and update records across your objects directly from their chatbot.
## Configuration
To protect your workspace, this integration uses an API token you provide. Create a token in Attio and paste it in your integration configuration.
### Steps
1. In Attio, create an API token with the following scopes:
- `object_configuration:read` - Required to list and retrieve object definitions
- `record:read` - Required to list and retrieve records
- `record:write` - Required to create and update records
2. In Botpress, add the Attio integration to your bot.
3. Paste your API token in the integration configuration and save.
That's it — the integration will validate access using Attio's `/v2/self` endpoint.
## Notes
- Filtering syntax for `List Records` follows Attios filtering guide. Pass your filter object as provided by Attio —
- For create/update, provide `values` using attribute slugs or IDs accepted by Attio.
+24
View File
@@ -0,0 +1,24 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 60.9 50" style="enable-background:new 0 0 60.9 50;" xml:space="preserve">
<metadata>
<sfw xmlns="ns_sfw;">
<slices>
</slices>
<sliceSourceBounds bottomLeftOrigin="true" height="50" width="60.9" x="-102.5" y="-212.1">
</sliceSourceBounds>
</sfw>
</metadata>
<g>
<path d="M60.3,34.8l-5.1-8.1c0,0,0,0,0,0L54.7,26c-0.8-1.2-2.1-1.9-3.5-1.9L43,24L42.5,25l-9.8,15.7l-0.5,0.9l4.1,6.6
c0.8,1.2,2.1,1.9,3.5,1.9h11.5c1.4,0,2.8-0.7,3.5-1.9l0.4-0.6c0,0,0,0,0,0l5.1-8.2C61.1,37.9,61.1,36.2,60.3,34.8L60.3,34.8z
M58.7,38.3l-5.1,8.2c0,0,0,0.1-0.1,0.1c-0.2,0.2-0.4,0.2-0.5,0.2c-0.1,0-0.4,0-0.6-0.3l-5.1-8.2c-0.1-0.1-0.1-0.2-0.2-0.3
c0-0.1-0.1-0.2-0.1-0.3c-0.1-0.4-0.1-0.8,0-1.3c0.1-0.2,0.1-0.4,0.3-0.6l5.1-8.1c0,0,0,0,0,0c0.1-0.2,0.3-0.3,0.4-0.3
c0.1,0,0.1,0,0.1,0c0,0,0,0,0.1,0c0.1,0,0.4,0,0.6,0.3l5.1,8.1C59.2,36.6,59.2,37.5,58.7,38.3L58.7,38.3z">
</path>
<path d="M45.2,15.1c0.8-1.3,0.8-3.1,0-4.4l-5.1-8.1l-0.4-0.7C38.9,0.7,37.6,0,36.2,0H24.7c-1.4,0-2.7,0.7-3.5,1.9L0.6,34.9
C0.2,35.5,0,36.3,0,37c0,0.8,0.2,1.5,0.6,2.2l5.5,8.8C6.9,49.3,8.2,50,9.7,50h11.5c1.4,0,2.8-0.7,3.5-1.9l0.4-0.7c0,0,0,0,0,0
c0,0,0,0,0,0l4.1-6.6l12.1-19.4L45.2,15.1L45.2,15.1z M44,13c0,0.4-0.1,0.8-0.4,1.2L23.5,46.4c-0.2,0.3-0.5,0.3-0.6,0.3
c-0.1,0-0.4,0-0.6-0.3l-5.1-8.2c-0.5-0.7-0.5-1.7,0-2.4L37.4,3.6c0.2-0.3,0.5-0.3,0.6-0.3c0.1,0,0.4,0,0.6,0.3l5.1,8.1
C43.9,12.1,44,12.5,44,13z">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,27 @@
import { IntegrationDefinition, z } from '@botpress/sdk'
import { actions, states, events } from './definitions'
export default new IntegrationDefinition({
name: 'attio',
version: '1.0.3',
title: 'Attio',
readme: 'hub.md',
icon: 'icon.svg',
description: 'Interact with Attio from your chatbot',
configuration: {
schema: z.object({
accessToken: z.string().title('Access Token').describe('The Access token of the Attio integration'),
}),
},
actions,
states,
events,
__advanced: {
useLegacyZuiTransformer: true,
},
attributes: {
category: 'CRM & Sales',
repo: 'botpress',
},
})
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@botpresshub/attio",
"scripts": {
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"build": "bp add -y && bp build",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { listObjects, getObject, listAttributes } from './objects'
import { listRecords, getRecord, createRecord, updateRecord } from './record'
import * as bp from '.botpress'
export default {
listRecords,
getRecord,
createRecord,
updateRecord,
listObjects,
getObject,
listAttributes,
} as const satisfies bp.IntegrationProps['actions']
+53
View File
@@ -0,0 +1,53 @@
//
import { RuntimeError } from '@botpress/client'
import { AttioApiClient } from '../attio-api'
import * as bp from '.botpress'
export const listObjects: bp.IntegrationProps['actions']['listObjects'] = async (props) => {
const { ctx } = props
const accessToken = ctx.configuration.accessToken
const attioApiClient = new AttioApiClient(accessToken)
try {
const data = await attioApiClient.listObjects()
return { data: data.data }
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
export const getObject: bp.IntegrationProps['actions']['getObject'] = async (props) => {
const { ctx, input } = props
const accessToken = ctx.configuration.accessToken
const attioApiClient = new AttioApiClient(accessToken)
const { object } = input
try {
const data = await attioApiClient.getObject(object)
return { data: data.data }
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
export const listAttributes: bp.IntegrationProps['actions']['listAttributes'] = async (props) => {
const { ctx, input } = props
const accessToken = ctx.configuration.accessToken
const attioApiClient = new AttioApiClient(accessToken)
const { object } = input
try {
const data = await attioApiClient.listAttributes(object)
return { data: data.data }
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
+185
View File
@@ -0,0 +1,185 @@
//
import { RuntimeError } from '@botpress/client'
import { AttioApiClient, Attribute } from '../attio-api'
import * as bp from '.botpress'
type AttioRecordIdentifier = {
workspace_id: string
object_id: string
record_id: string
}
type AttioRecord = {
id: AttioRecordIdentifier
created_at: string
web_url: string
values: Record<string, unknown>
}
function _buildAttributeMaps(attributes: Attribute[]) {
const idToSlug: Record<string, string> = {}
const slugToOptionLabelById: Record<string, Record<string, string>> = {}
for (const attr of attributes) {
// Handle new id structure (object with attribute_id) or fallback to old string id
const id = typeof attr.id === 'object' ? attr.id?.attribute_id : String(attr.id ?? '')
const slug = String(attr.slug ?? attr.api_slug ?? id)
if (id) idToSlug[id] = slug
if (Array.isArray(attr.options) && attr.options.length > 0) {
const map: Record<string, string> = {}
for (const opt of attr.options) {
const oid = String(opt.id ?? '')
if (!oid) continue
const label = String(opt.label ?? opt.name ?? opt.value ?? opt.title ?? opt.slug ?? oid)
map[oid] = label
}
slugToOptionLabelById[slug] = map
}
}
return { idToSlug, slugToOptionLabelById }
}
function _mapValueUsingOptions(value: unknown, optionMap: Record<string, string>) {
const mapOne = (v: unknown) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
const candidateId = (v as { id?: string }).id
if (candidateId && optionMap[candidateId]) return optionMap[candidateId]
return v
}
if (typeof v === 'string' && optionMap[v]) return optionMap[v]
return v
}
if (Array.isArray(value)) return value.map((v) => mapOne(v))
return mapOne(value)
}
function _humanizeRecordValues(
record: AttioRecord,
idToSlug: Record<string, string>,
slugToOptionLabelById: Record<string, Record<string, string>>
) {
const src: Record<string, unknown> = record?.values ?? {}
const dst: Record<string, unknown> = {}
for (const key of Object.keys(src)) {
const slug = idToSlug[key] ?? key
const optionMap = slugToOptionLabelById[slug]
const raw = src[key]
const value = optionMap ? _mapValueUsingOptions(raw, optionMap) : raw
dst[slug] = value
}
return { ...record, values: dst }
}
function _mapValues(values: { attribute: string; value: string }[]) {
return values.reduce((acc: Record<string, any>, curr) => {
acc[curr.attribute] = curr.value
return acc
}, {})
}
export const listRecords: bp.IntegrationProps['actions']['listRecords'] = async (props) => {
const { ctx, input } = props
const accessToken = ctx.configuration.accessToken
const attioApiClient = new AttioApiClient(accessToken)
const { object, filter, sorts, limit, offset } = input
const filterMap = filter ? _mapValues(filter) : undefined
try {
const data = await attioApiClient.listRecords(object, { filter: filterMap, sorts, limit, offset })
const attributes = await attioApiClient.listAttributes(object)
const { idToSlug, slugToOptionLabelById } = _buildAttributeMaps(attributes.data)
const humanized = data.data.map((rec) => _humanizeRecordValues(rec, idToSlug, slugToOptionLabelById))
return { data: humanized }
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
export const getRecord: bp.IntegrationProps['actions']['getRecord'] = async (props) => {
const { ctx, input } = props
const accessToken = ctx.configuration.accessToken
const attioApiClient = new AttioApiClient(accessToken)
const { object, id } = input
try {
const data = await attioApiClient.getRecord(object, id.record_id)
const attributes = await attioApiClient.listAttributes(object)
const { idToSlug, slugToOptionLabelById } = _buildAttributeMaps(attributes.data)
const humanized = _humanizeRecordValues(data.data, idToSlug, slugToOptionLabelById)
return { data: humanized }
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
export const createRecord: bp.IntegrationProps['actions']['createRecord'] = async (props) => {
const { ctx, input } = props
const accessToken = ctx.configuration.accessToken
const attioApiClient = new AttioApiClient(accessToken)
const { object, data } = input
const { values } = data
try {
const valuesMap = _mapValues(values)
const data = await attioApiClient.createRecord(object, { data: { values: valuesMap } })
return { data: data.data }
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
export const updateRecord: bp.IntegrationProps['actions']['updateRecord'] = async (props) => {
const { ctx, input } = props
const accessToken = ctx.configuration.accessToken
const attioApiClient = new AttioApiClient(accessToken)
const { object, id, data } = input
const { values } = data
try {
const valuesMap = _mapValues(values)
const data = await attioApiClient.updateRecord(object, id.record_id, { data: { values: valuesMap } })
return { data: data.data }
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
export const listAttributes: bp.IntegrationProps['actions']['listAttributes'] = async (props) => {
const { ctx, input } = props
const accessToken = ctx.configuration.accessToken
const attioApiClient = new AttioApiClient(accessToken)
const { object } = input
try {
const data = await attioApiClient.listAttributes(object)
return { data: data.data }
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new RuntimeError(error.message)
}
}
+212
View File
@@ -0,0 +1,212 @@
// Types for API responses and requests
export type RecordIdentifier = {
workspace_id: string
object_id: string
record_id: string
}
export type AttioRecord = {
id: RecordIdentifier
created_at: string
web_url: string
values: Record<string, unknown>
}
export type AttioObject = {
id?: {
workspace_id: string
object_id: string
}
api_slug?: string
singular_noun?: string
plural_noun?: string
created_at?: string
}
export type Attribute = {
id?: {
workspace_id: string
object_id: string
attribute_id: string
}
title?: string
description?: string | null
api_slug?: string
type?: string
slug?: string
options?: Array<{
id?: string
label?: string
name?: string
value?: string
title?: string
slug?: string
}>
}
export type Sort = {
direction: 'asc' | 'desc'
attribute: string
field: string
}
export type ListRecordsParams = {
filter?: Record<string, string>
sorts?: Sort[]
limit?: number
offset?: number
}
export type CreateRecordData = {
data: {
values: Record<string, unknown>
}
}
export type UpdateRecordData = {
data: {
values: Record<string, unknown>
}
}
export type ApiResponse<T> = {
data: T
}
export type WebhookData = {
event_type: string
filter: Record<string, string> | null
}
export type WebhookCreateData = {
data: {
target_url: string
subscriptions: WebhookData[]
}
}
export type WebhookId = {
workspace_id: string
webhook_id: string
}
export type WebhookResponse = {
id: WebhookId
target_url: string
subscriptions: WebhookData[]
created_at: string
secret: string
}
export type ListRecordsResponse = ApiResponse<AttioRecord[]>
export type GetRecordResponse = ApiResponse<AttioRecord>
export type CreateRecordResponse = ApiResponse<AttioRecord>
export type UpdateRecordResponse = ApiResponse<AttioRecord>
export type ListObjectsResponse = ApiResponse<AttioObject[]>
export type GetObjectResponse = ApiResponse<AttioObject>
export type ListAttributesResponse = ApiResponse<Attribute[]>
export type CreateWebhookResponse = ApiResponse<WebhookResponse>
export class AttioApiClient {
private _accessToken: string
private _baseUrl: string = 'https://api.attio.com/v2'
public constructor(accessToken: string) {
this._accessToken = accessToken
}
private async _makeRequest<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
endpoint: string,
body?: unknown
): Promise<T> {
const url = `${this._baseUrl}${endpoint}`
const headers: Record<string, string> = {
Authorization: `Bearer ${this._accessToken}`,
'Content-Type': 'application/json',
}
const config: RequestInit = {
method,
headers,
}
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
config.body = JSON.stringify(body)
}
const response = await fetch(url, config)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Attio API Error: ${response.status} ${response.statusText} - ${errorText}`)
}
const data = await response.json()
return data
}
// Records API methods
public async listRecords(object: string, params?: ListRecordsParams): Promise<ListRecordsResponse> {
const endpoint = `/objects/${object}/records/query`
const body = {
filter: params?.filter,
sorts: params?.sorts,
limit: params?.limit,
offset: params?.offset,
}
return this._makeRequest<ListRecordsResponse>('POST', endpoint, body)
}
public async getRecord(object: string, recordId: string): Promise<GetRecordResponse> {
const endpoint = `/objects/${object}/records/${recordId}`
return this._makeRequest<GetRecordResponse>('GET', endpoint)
}
public async createRecord(object: string, data: CreateRecordData): Promise<CreateRecordResponse> {
const endpoint = `/objects/${object}/records`
return this._makeRequest<CreateRecordResponse>('POST', endpoint, data)
}
public async updateRecord(object: string, recordId: string, data: UpdateRecordData): Promise<UpdateRecordResponse> {
const endpoint = `/objects/${object}/records/${recordId}`
return this._makeRequest<UpdateRecordResponse>('PUT', endpoint, data)
}
// Objects API methods
public async listObjects(): Promise<ListObjectsResponse> {
const endpoint = '/objects'
return this._makeRequest<ListObjectsResponse>('GET', endpoint)
}
public async getObject(object: string): Promise<GetObjectResponse> {
const endpoint = `/objects/${object}`
return this._makeRequest<GetObjectResponse>('GET', endpoint)
}
// Attributes API methods
public async listAttributes(object: string): Promise<ListAttributesResponse> {
const endpoint = `/objects/${object}/attributes`
return this._makeRequest<ListAttributesResponse>('GET', endpoint)
}
// Webhook API methods
public async createWebhook(data: WebhookCreateData): Promise<CreateWebhookResponse> {
const endpoint = '/webhooks'
return this._makeRequest<CreateWebhookResponse>('POST', endpoint, data)
}
public async deleteWebhook(webhookId: string): Promise<void> {
const endpoint = `/webhooks/${webhookId}`
await this._makeRequest<void>('DELETE', endpoint)
}
// Utility method to test connection
public async testConnection(): Promise<void> {
await this._makeRequest<{ data: unknown }>('GET', '/self')
}
}
+63
View File
@@ -0,0 +1,63 @@
import { z } from '@botpress/sdk'
import { recordEventSchema } from 'src/schemas'
import * as bp from '.botpress'
export const recordCreated = async ({
payload,
client,
logger,
}: {
payload: z.infer<typeof recordEventSchema>
client: bp.Client
logger: bp.Logger
}) => {
await client.createEvent({
type: 'recordCreated',
payload: {
...payload,
event_type: 'record.created',
},
})
logger.forBot().info(`Record created: ${payload.id.record_id}`)
}
export const recordUpdated = async ({
payload,
client,
logger,
}: {
payload: z.infer<typeof recordEventSchema>
client: bp.Client
logger: bp.Logger
}) => {
await client.createEvent({
type: 'recordUpdated',
payload: {
...payload,
event_type: 'record.updated',
},
})
logger.forBot().info(`Record updated: ${payload.id.record_id}`)
}
export const recordDeleted = async ({
payload,
client,
logger,
}: {
payload: z.infer<typeof recordEventSchema>
client: bp.Client
logger: bp.Logger
}) => {
await client.createEvent({
type: 'recordDeleted',
payload: {
...payload,
event_type: 'record.deleted',
},
})
logger.forBot().info(`Record deleted: ${payload.id.record_id}`)
}
+11
View File
@@ -0,0 +1,11 @@
import actions from './actions'
import { register, unregister, handler } from './setup'
import * as bp from '.botpress'
export default new bp.Integration({
register,
unregister,
actions,
channels: {},
handler,
})
+16
View File
@@ -0,0 +1,16 @@
import { z } from '@botpress/sdk'
import { baseIdentifierSchema } from '../definitions/common'
export const recordEventSchema = z.object({
event_type: z.string(),
id: baseIdentifierSchema.extend({ record_id: z.string() }),
actor: z.object({
type: z.string(),
id: z.string(),
}),
})
export const webhookPayloadSchema = z.object({
webhook_id: z.string(),
events: z.array(recordEventSchema),
})
+61
View File
@@ -0,0 +1,61 @@
import { webhookPayloadSchema } from 'src/schemas'
import { recordCreated, recordUpdated, recordDeleted } from '../events'
import * as bp from '.botpress'
export function safeJsonParse(x: any) {
try {
return { data: JSON.parse(x), success: true }
} catch {
return { data: x, success: false }
}
}
export const handler: bp.IntegrationProps['handler'] = async ({ req, logger, client }) => {
if (!req.body) {
const errorMsg = 'Webhook processing failed: empty body'
logger.forBot().error(errorMsg)
return {
status: 400,
body: JSON.stringify({ error: errorMsg }),
}
}
// Parse and validate the webhook payload
const webhookData = safeJsonParse(req.body)
if (!webhookData.success) {
const errorMsg = 'Webhook processing failed: invalid JSON'
logger.forBot().error(errorMsg)
return { status: 400, body: JSON.stringify({ error: errorMsg }) }
}
const parseResult = webhookPayloadSchema.safeParse(webhookData.data)
if (!parseResult.success) {
const errorMsg = `Webhook processing failed: ${parseResult.error.message}`
logger.forBot().error(errorMsg)
return { status: 400, body: JSON.stringify({ error: errorMsg }) }
}
const { webhook_id, events } = parseResult.data
logger.forBot().info(`Processing webhook ${webhook_id} with ${events.length} events`)
// Process each event
for (const attioEvent of events) {
const event = attioEvent.event_type
switch (event) {
case 'record.created':
await recordCreated({ payload: attioEvent, client, logger })
break
case 'record.updated':
await recordUpdated({ payload: attioEvent, client, logger })
break
case 'record.deleted':
await recordDeleted({ payload: attioEvent, client, logger })
break
default:
logger.forBot().warn(`Unsupported event type: ${event}`)
}
}
return { status: 200 }
}
+2
View File
@@ -0,0 +1,2 @@
export { register, unregister } from './setup'
export { handler } from './handler'
+76
View File
@@ -0,0 +1,76 @@
import * as sdk from '@botpress/sdk'
import { AttioApiClient } from '../attio-api'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async ({ ctx, client, webhookUrl, logger }) => {
try {
const accessToken = ctx.configuration.accessToken
const attioClient = new AttioApiClient(accessToken)
// Test the connection using the API client
logger.forBot().info('Testing connection to Attio...')
await attioClient.testConnection()
logger.forBot().info('Connection to Attio successful')
// Check if webhooks already exist
logger.forBot().info('Checking if webhooks already exist...')
const { state } = await client.getOrSetState({
name: 'attioIntegrationInfo',
type: 'integration',
id: ctx.integrationId,
payload: {
attioWebhookId: '',
},
})
if (!state.payload.attioWebhookId) {
logger.forBot().info('Webhooks do not exist. Creating webhooks...')
const webhookResp = await attioClient.createWebhook({
data: {
target_url: webhookUrl,
subscriptions: [
{ event_type: 'record.created', filter: null },
{ event_type: 'record.updated', filter: null },
{ event_type: 'record.deleted', filter: null },
],
},
})
const attioWebhookId = String(webhookResp.data.id.webhook_id)
await client.setState({
type: 'integration',
name: 'attioIntegrationInfo',
id: ctx.integrationId,
payload: { attioWebhookId },
})
}
logger.forBot().info('Webhooks created')
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new sdk.RuntimeError(error.message)
}
}
export const unregister: bp.IntegrationProps['unregister'] = async ({ ctx, client, logger }) => {
try {
const accessToken = ctx.configuration.accessToken
const attioClient = new AttioApiClient(accessToken)
const stateAttioIntegrationInfo = await client.getState({
id: ctx.integrationId,
name: 'attioIntegrationInfo',
type: 'integration',
})
const { state } = stateAttioIntegrationInfo
const { attioWebhookId } = state.payload
if (attioWebhookId) {
await attioClient.deleteWebhook(attioWebhookId)
logger.forBot().info('Webhook successfully deleted')
}
} catch (thrown) {
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
throw new sdk.RuntimeError(error.message)
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config