Files
2026-07-13 13:34:48 +08:00

380 lines
12 KiB
TypeScript

import { IntegrationLogger } from '@botpress/sdk'
import axios from 'axios'
import FormData from 'form-data'
import { DataCenter, getZohoApiBaseUrl, getZohoAuthUrl, isDataCenter } from './misc/data-centers'
import * as bp from '.botpress'
const logger = new IntegrationLogger()
const OAUTH_CLIENT_ID = bp.secrets.CLIENT_ID
const OAUTH_CLIENT_SECRET = bp.secrets.CLIENT_SECRET
// Retry once after refreshing the access token. If Zoho still returns 401,
// the credentials are likely revoked, mis-scoped, or tied to the wrong data center.
const MAX_AUTH_RETRIES = 1
type AuthMode = 'oauth' | 'manual'
type StoredCredentials = {
accessToken: string
refreshToken?: string
dataCenter?: DataCenter
apiDomain?: string
expiresAt?: number
}
type LegacyConfiguration = {
clientId: string
clientSecret: string
refreshToken: string
dataCenter: DataCenter
}
const _getErrorMessage = (error: unknown): string => {
if (axios.isAxiosError(error)) {
return error.response?.data?.message ?? error.message
}
return error instanceof Error ? error.message : 'Unknown error'
}
const _getErrorLogData = (error: unknown): unknown => {
if (axios.isAxiosError(error)) {
return error.response?.data ?? error.message
}
return error instanceof Error ? error.message : error
}
const _getLegacyConfiguration = (configuration: unknown): LegacyConfiguration | null => {
if (!configuration || typeof configuration !== 'object') {
return null
}
const maybeConfiguration = configuration as Record<string, unknown>
const { clientId, clientSecret, refreshToken, dataCenter } = maybeConfiguration
if (
typeof clientId !== 'string' ||
typeof clientSecret !== 'string' ||
typeof refreshToken !== 'string' ||
typeof dataCenter !== 'string' ||
!isDataCenter(dataCenter)
) {
return null
}
return { clientId, clientSecret, refreshToken, dataCenter }
}
export class ZohoApi {
private _refreshToken: string
private _clientId: string
private _clientSecret: string
private _dataCenter: DataCenter
private _baseUrl: string
private _ctx: bp.Context
private _bpClient: bp.Client
private _authMode: AuthMode
public constructor(
refreshToken: string,
clientId: string,
clientSecret: string,
dataCenter: DataCenter,
ctx: bp.Context,
bpClient: bp.Client,
authMode: AuthMode = 'manual',
apiDomain?: string
) {
this._refreshToken = refreshToken
this._clientId = clientId
this._clientSecret = clientSecret
this._dataCenter = dataCenter
this._ctx = ctx
this._bpClient = bpClient
this._authMode = authMode
this._baseUrl = apiDomain ?? getZohoApiBaseUrl(dataCenter)
}
/** Retrieves stored credentials from Botpress state */
private async _getStoredCredentials(): Promise<StoredCredentials | null> {
try {
const { state } = await this._bpClient.getState({
id: this._ctx.integrationId,
name: 'credentials',
type: 'integration',
})
if (!state?.payload?.accessToken) {
logger.forBot().error('No credentials found in state')
return null
}
return {
accessToken: state.payload.accessToken,
refreshToken: state.payload.refreshToken,
dataCenter: state.payload.dataCenter,
apiDomain: state.payload.apiDomain,
expiresAt: state.payload.expiresAt,
}
} catch (error) {
logger.forBot().error('Error retrieving credentials from state:', error)
return null
}
}
private async _makeRequest(
endpoint: string,
method: string = 'GET',
data: any = null,
params: any = {},
retryCount: number = 0
): Promise<any> {
try {
const creds = await this._getStoredCredentials()
if (!creds) {
logger.forBot().error('Error retrieving credentials.')
throw new Error('Error grabbing credentials.')
}
const headers: Record<string, string> = {
Authorization: `Bearer ${creds.accessToken}`,
Accept: 'application/json',
}
if (method !== 'GET' && method !== 'DELETE') {
headers['Content-Type'] = 'application/json'
}
logger.forBot().info(`Making request to ${method} ${this._baseUrl}${endpoint}`)
logger.forBot().info('Params:', params)
const response = await axios({
method,
url: `${this._baseUrl}${endpoint}`,
headers,
data,
params,
})
return { success: true, message: 'Request successful', data: response.data }
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401 && retryCount < MAX_AUTH_RETRIES) {
logger.forBot().warn('Access token expired. Refreshing...', error)
await this.refreshAccessToken()
return this._makeRequest(endpoint, method, data, params, retryCount + 1)
}
logger.forBot().error(`Error in ${method} ${endpoint}:`, _getErrorLogData(error))
return { success: false, message: _getErrorMessage(error), data: null }
}
}
private async _makeFileUploadRequest(endpoint: string, formData: FormData, retryCount: number = 0): Promise<any> {
try {
const creds = await this._getStoredCredentials()
if (!creds) {
logger.forBot().error('Error retrieving credentials.')
throw new Error('Error grabbing credentials.')
}
const headers = {
Authorization: `Bearer ${creds.accessToken}`,
...formData.getHeaders(),
}
logger.forBot().info(`Uploading file to ${this._baseUrl}${endpoint}`)
const response = await axios.post(`${this._baseUrl}${endpoint}`, formData, { headers })
return { success: true, message: 'File uploaded successfully', data: response.data }
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401 && retryCount < MAX_AUTH_RETRIES) {
logger.forBot().warn('Access token expired. Refreshing...', error)
await this.refreshAccessToken()
return this._makeFileUploadRequest(endpoint, formData, retryCount + 1)
}
logger.forBot().error(`Error in file upload ${endpoint}:`, _getErrorLogData(error))
return { success: false, message: _getErrorMessage(error), data: null }
}
}
public async refreshAccessToken() {
try {
const requestData = new URLSearchParams()
requestData.append('client_id', this._clientId)
requestData.append('client_secret', this._clientSecret)
requestData.append('refresh_token', this._refreshToken)
requestData.append('grant_type', 'refresh_token')
const response = await axios.post(`${getZohoAuthUrl(this._dataCenter)}/oauth/v2/token`, requestData.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
const currentCredentials = await this._getStoredCredentials()
await this._bpClient.setState({
id: this._ctx.integrationId,
type: 'integration',
name: 'credentials',
payload: {
...currentCredentials,
accessToken: response.data.access_token,
refreshToken: this._authMode === 'oauth' ? this._refreshToken : currentCredentials?.refreshToken,
dataCenter: this._authMode === 'oauth' ? this._dataCenter : currentCredentials?.dataCenter,
apiDomain: response.data.api_domain ?? currentCredentials?.apiDomain,
expiresAt: response.data.expires_in
? Date.now() + response.data.expires_in * 1000
: currentCredentials?.expiresAt,
},
})
logger.forBot().info('Access token refreshed successfully.')
} catch (error: unknown) {
logger.forBot().error(_getErrorLogData(error))
logger.forBot().error('Error refreshing access token:', _getErrorLogData(error))
throw new Error('Authentication error. Please reauthorize the integration.')
}
}
public async makeApiCall(endpoint: string, method: string = 'GET', data: any = null, rawParams: any = {}) {
const params = JSON.parse(rawParams)
return this._makeRequest(endpoint, method, data, params)
}
public async insertRecord(module: string, rawData: string) {
const data = JSON.parse(rawData)
return this._makeRequest(`/crm/v7/${module}`, 'POST', { data })
}
public async getRecords(module: string, rawParams: string = '{}') {
const params = JSON.parse(rawParams)
return this._makeRequest(`/crm/v7/${module}`, 'GET', null, params)
}
public async getRecordById(module: string, recordId: string, params: any = {}) {
return this._makeRequest(`/crm/v7/${module}/${recordId}`, 'GET', null, params)
}
public async updateRecord(module: string, recordId: string, rawData: string) {
const data = JSON.parse(rawData)
return this._makeRequest(`/crm/v7/${module}/${recordId}`, 'PUT', { data })
}
public async deleteRecord(module: string, recordId: string) {
return this._makeRequest(`/crm/v7/${module}/${recordId}`, 'DELETE')
}
public async searchRecords(module: string, criteria: string) {
return this._makeRequest(`/crm/v7/${module}/search`, 'GET', null, { criteria })
}
public async getOrganizationDetails() {
return this._makeRequest('/crm/v7/org', 'GET')
}
public async getUsers(rawParams?: string) {
const params = rawParams ? JSON.parse(rawParams) : {}
return this._makeRequest('/crm/v7/users', 'GET', null, params)
}
public async downloadFileBuffer(fileUrl: string): Promise<Blob> {
try {
const response = await axios.get(fileUrl, {
responseType: 'arraybuffer',
})
const contentType = response.headers['content-type'] || 'application/octet-stream'
return new Blob([response.data], { type: contentType })
} catch (error) {
logger.forBot().error('Error downloading the file:', error)
throw error
}
}
public async uploadFile(fileUrl: string) {
try {
const file = await this.downloadFileBuffer(fileUrl)
const fileName = fileUrl.split('/').pop() || 'uploaded_file'
const buffer = Buffer.from(await file.arrayBuffer())
const formData = new FormData()
formData.append('file', buffer, fileName)
return this._makeFileUploadRequest('/crm/v7/files', formData)
} catch (error) {
logger.forBot().error('Error uploading file:', error)
throw error
}
}
public async getFile(fileId: string) {
return this._makeRequest('/crm/v7/files', 'GET', null, { id: fileId })
}
public async getAppointments(rawParams: string = '{}') {
const params = JSON.parse(rawParams)
return this._makeRequest('/crm/v7/Appointments__s', 'GET', null, params)
}
public async getAppointmentById(appointmentId: string) {
return this._makeRequest(`/crm/v7/Appointments__s/${appointmentId}`)
}
public async createAppointment(rawData: string) {
const data = JSON.parse(rawData)
return this._makeRequest('/crm/v7/Appointments__s', 'POST', { data })
}
public async updateAppointment(appointmentId: string, rawData: string) {
const data = JSON.parse(rawData)
return this._makeRequest(`/crm/v7/Appointments__s/${appointmentId}`, 'PUT', { data })
}
public async deleteAppointment(appointmentId: string) {
return this._makeRequest(`/crm/v7/Appointments__s/${appointmentId}`, 'DELETE')
}
public async sendMail(module: string, recordId: string, rawData: string) {
const data = JSON.parse(rawData)
return this._makeRequest(`/crm/v7/${module}/${recordId}/actions/send_mail`, 'POST', { data })
}
}
export const getClient = async (ctx: bp.Context, bpClient: bp.Client): Promise<ZohoApi> => {
const manualConfiguration =
ctx.configurationType === 'manual' ? ctx.configuration : _getLegacyConfiguration(ctx.configuration)
if (manualConfiguration) {
return new ZohoApi(
manualConfiguration.refreshToken,
manualConfiguration.clientId,
manualConfiguration.clientSecret,
manualConfiguration.dataCenter,
ctx,
bpClient,
'manual'
)
}
const { state } = await bpClient.getState({
id: ctx.integrationId,
name: 'credentials',
type: 'integration',
})
const credentials = state.payload
if (!credentials.refreshToken || !credentials.dataCenter) {
throw new Error('Zoho OAuth credentials not found. Please reconnect the integration.')
}
return new ZohoApi(
credentials.refreshToken,
OAUTH_CLIENT_ID,
OAUTH_CLIENT_SECRET,
credentials.dataCenter,
ctx,
bpClient,
'oauth',
credentials.apiDomain
)
}