chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { AxiosError, AxiosHeaders } from 'axios'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { parseJsonObject, parseError } from './client'
|
||||
|
||||
describe('parseJsonObject', () => {
|
||||
test('returns empty object when value is undefined', () => {
|
||||
expect(parseJsonObject(undefined, 'field')).toEqual({})
|
||||
})
|
||||
|
||||
test('returns empty object when value is empty string', () => {
|
||||
expect(parseJsonObject('', 'field')).toEqual({})
|
||||
})
|
||||
|
||||
test('parses a valid JSON object', () => {
|
||||
expect(parseJsonObject('{"key":"value","num":42}', 'field')).toEqual({ key: 'value', num: 42 })
|
||||
})
|
||||
|
||||
test('parses a nested JSON object', () => {
|
||||
const input = '{"user":{"name":"Alice","tags":["a","b"]}}'
|
||||
expect(parseJsonObject(input, 'field')).toEqual({ user: { name: 'Alice', tags: ['a', 'b'] } })
|
||||
})
|
||||
|
||||
test('throws on invalid JSON syntax', () => {
|
||||
expect(() => parseJsonObject('{invalid', 'myField')).toThrow()
|
||||
})
|
||||
|
||||
test('throws when JSON is an array', () => {
|
||||
expect(() => parseJsonObject('[1,2,3]', 'myField')).toThrow('myField must be a valid JSON object')
|
||||
})
|
||||
|
||||
test('throws when JSON is a string', () => {
|
||||
expect(() => parseJsonObject('"hello"', 'myField')).toThrow('myField must be a valid JSON object')
|
||||
})
|
||||
|
||||
test('throws when JSON is a number', () => {
|
||||
expect(() => parseJsonObject('42', 'myField')).toThrow('myField must be a valid JSON object')
|
||||
})
|
||||
|
||||
test('throws when JSON is null', () => {
|
||||
expect(() => parseJsonObject('null', 'myField')).toThrow('myField must be a valid JSON object')
|
||||
})
|
||||
|
||||
test('throws when JSON is a boolean', () => {
|
||||
expect(() => parseJsonObject('true', 'myField')).toThrow('myField must be a valid JSON object')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseError', () => {
|
||||
test('returns message from a standard Error', () => {
|
||||
expect(parseError(new Error('something broke'))).toBe('something broke')
|
||||
})
|
||||
|
||||
test('returns string error as-is', () => {
|
||||
expect(parseError('raw string error')).toBe('raw string error')
|
||||
})
|
||||
|
||||
test('returns number error as string', () => {
|
||||
expect(parseError(404)).toBe('404')
|
||||
})
|
||||
|
||||
test('returns boolean error as string', () => {
|
||||
expect(parseError(false)).toBe('false')
|
||||
})
|
||||
|
||||
test('returns fallback for undefined', () => {
|
||||
expect(parseError(undefined)).toBe('An unexpected error occurred')
|
||||
})
|
||||
|
||||
test('returns fallback for null', () => {
|
||||
expect(parseError(null)).toBe('An unexpected error occurred')
|
||||
})
|
||||
|
||||
test('extracts message from object with message property', () => {
|
||||
expect(parseError({ message: 'custom error' })).toBe('custom error')
|
||||
})
|
||||
|
||||
test('handles AxiosError with response data string', () => {
|
||||
const error = new AxiosError(
|
||||
'Request failed',
|
||||
'400',
|
||||
undefined,
|
||||
{},
|
||||
{
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: 'Bad request body',
|
||||
}
|
||||
)
|
||||
|
||||
expect(parseError(error)).toBe('Request failed with status 400: Bad request body')
|
||||
})
|
||||
|
||||
test('handles AxiosError with response data object', () => {
|
||||
const error = new AxiosError(
|
||||
'Request failed',
|
||||
'422',
|
||||
undefined,
|
||||
{},
|
||||
{
|
||||
status: 422,
|
||||
statusText: 'Unprocessable Entity',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: { error: 'validation_error', details: 'invalid field' },
|
||||
}
|
||||
)
|
||||
|
||||
expect(parseError(error)).toBe(
|
||||
'Request failed with status 422: {"error":"validation_error","details":"invalid field"}'
|
||||
)
|
||||
})
|
||||
|
||||
test('handles AxiosError with no response (network error)', () => {
|
||||
const error = new AxiosError('Network Error', 'ERR_NETWORK', undefined, {})
|
||||
|
||||
expect(parseError(error)).toBe('No response received: Network Error')
|
||||
})
|
||||
|
||||
test('handles AxiosError with no request and no response', () => {
|
||||
const error = new AxiosError('Config error')
|
||||
|
||||
expect(parseError(error)).toBe('Axios error: Config error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import axios, { AxiosInstance, isAxiosError } from 'axios'
|
||||
|
||||
export type EventParams = Record<string, unknown>
|
||||
|
||||
type UserPropertyValue = string | number | boolean | null
|
||||
|
||||
type DebugResponse = {
|
||||
validationMessages?: Array<{
|
||||
description?: string
|
||||
fieldPath?: string
|
||||
validationCode?: string
|
||||
}>
|
||||
}
|
||||
|
||||
type CollectPayload = {
|
||||
client_id: string
|
||||
user_id?: string
|
||||
events: Array<{
|
||||
name: string
|
||||
params?: EventParams
|
||||
}>
|
||||
user_properties?: Record<string, { value: UserPropertyValue }>
|
||||
}
|
||||
|
||||
export class GoogleAnalyticsClient {
|
||||
private readonly _client: AxiosInstance
|
||||
|
||||
public constructor(
|
||||
private readonly _measurementId: string,
|
||||
private readonly _apiSecret: string
|
||||
) {
|
||||
this._client = axios.create({
|
||||
baseURL: 'https://www.google-analytics.com',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public async validateConfiguration(): Promise<void> {
|
||||
const { data } = await this._client.post<DebugResponse>(
|
||||
'/debug/mp/collect',
|
||||
{
|
||||
client_id: 'botpress-validation',
|
||||
events: [
|
||||
{
|
||||
name: 'botpress_configuration_validation',
|
||||
params: {
|
||||
engagement_time_msec: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
params: this._buildQueryParams(),
|
||||
}
|
||||
)
|
||||
|
||||
const validationMessages = data.validationMessages ?? []
|
||||
if (validationMessages.length > 0) {
|
||||
const details = validationMessages
|
||||
.map(({ fieldPath, description, validationCode }) =>
|
||||
[fieldPath, description, validationCode].filter(Boolean).join(': ')
|
||||
)
|
||||
.join(', ')
|
||||
|
||||
throw new Error(details || 'Google Analytics rejected the configuration')
|
||||
}
|
||||
}
|
||||
|
||||
public async trackEvent(userId: string, eventName: string, params: EventParams = {}): Promise<void> {
|
||||
await this._collect({
|
||||
client_id: userId,
|
||||
user_id: userId,
|
||||
events: [
|
||||
{
|
||||
name: eventName,
|
||||
params: {
|
||||
engagement_time_msec: 1,
|
||||
...params,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
public async updateUserProfile(userId: string, userProfile: EventParams): Promise<void> {
|
||||
await this._collect({
|
||||
client_id: userId,
|
||||
user_id: userId,
|
||||
user_properties: buildUserProperties(userProfile),
|
||||
events: [
|
||||
{
|
||||
name: 'update_user_profile',
|
||||
params: {
|
||||
engagement_time_msec: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
private async _collect(payload: CollectPayload): Promise<void> {
|
||||
await this._client.post('/mp/collect', payload, {
|
||||
params: this._buildQueryParams(),
|
||||
})
|
||||
}
|
||||
|
||||
private _buildQueryParams(): { measurement_id: string; api_secret: string } {
|
||||
return {
|
||||
measurement_id: this._measurementId,
|
||||
api_secret: this._apiSecret,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseJsonObject(value: string | undefined, fieldName: string): EventParams {
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error(`${fieldName} must be a valid JSON object`)
|
||||
}
|
||||
|
||||
return parsed as EventParams
|
||||
}
|
||||
|
||||
export function parseError(error: unknown): string {
|
||||
if (isAxiosError(error)) {
|
||||
if (error.response) {
|
||||
const responseData =
|
||||
typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data)
|
||||
|
||||
return `Request failed with status ${error.response.status}: ${responseData || error.message}`
|
||||
}
|
||||
|
||||
if (error.request) {
|
||||
return `No response received: ${error.message}`
|
||||
}
|
||||
|
||||
return `Axios error: ${error.message}`
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (typeof error === 'string' || typeof error === 'number' || typeof error === 'boolean') {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
return parseError(error.message)
|
||||
}
|
||||
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
|
||||
function buildUserProperties(userProfile: EventParams): Record<string, { value: UserPropertyValue }> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(userProfile).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
value: toUserPropertyValue(value),
|
||||
},
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function toUserPropertyValue(value: unknown): UserPropertyValue {
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null) {
|
||||
return value
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { GoogleAnalyticsClient, parseError, parseJsonObject } from './client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default new bp.Integration({
|
||||
register: async ({ ctx, logger }) => {
|
||||
try {
|
||||
await getClient(ctx).validateConfiguration()
|
||||
logger.forBot().info('Google Analytics integration configured successfully')
|
||||
} catch (error) {
|
||||
throw new RuntimeError(`Failed to configure Google Analytics integration: ${parseError(error)}`)
|
||||
}
|
||||
},
|
||||
unregister: async ({ logger }) => {
|
||||
logger.forBot().info('Google Analytics integration unregistered')
|
||||
},
|
||||
actions: {
|
||||
trackNode: async ({ ctx, input, logger }) => {
|
||||
logger.forBot().info(`Tracking Google Analytics page view for node "${input.nodeId}"`)
|
||||
|
||||
try {
|
||||
await getClient(ctx).trackEvent(input.userId, 'page_view', {
|
||||
page_title: input.nodeId,
|
||||
})
|
||||
logger.forBot().info(`Tracked Google Analytics page view for node "${input.nodeId}"`)
|
||||
return {}
|
||||
} catch (error) {
|
||||
throw new RuntimeError(`Failed to track node "${input.nodeId}" in Google Analytics: ${parseError(error)}`)
|
||||
}
|
||||
},
|
||||
trackEvent: async ({ ctx, input, logger }) => {
|
||||
logger.forBot().info(`Tracking Google Analytics event "${input.eventName}"`)
|
||||
|
||||
try {
|
||||
const eventPayload = parseJsonObject(input.eventPayload, 'eventPayload')
|
||||
|
||||
await getClient(ctx).trackEvent(input.userId, input.eventName, eventPayload)
|
||||
logger.forBot().info(`Tracked Google Analytics event "${input.eventName}"`)
|
||||
return {}
|
||||
} catch (error) {
|
||||
throw new RuntimeError(`Failed to track event "${input.eventName}" in Google Analytics: ${parseError(error)}`)
|
||||
}
|
||||
},
|
||||
updateUserProfile: async ({ ctx, input, logger }) => {
|
||||
logger.forBot().info(`Updating Google Analytics user profile for "${input.userId}"`)
|
||||
|
||||
try {
|
||||
const userProfile = parseJsonObject(input.userProfile, 'userProfile')
|
||||
|
||||
await getClient(ctx).updateUserProfile(input.userId, userProfile)
|
||||
logger.forBot().info(`Updated Google Analytics user profile for "${input.userId}"`)
|
||||
return {}
|
||||
} catch (error) {
|
||||
throw new RuntimeError(
|
||||
`Failed to update user profile for "${input.userId}" in Google Analytics: ${parseError(error)}`
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
channels: {},
|
||||
handler: async () => {},
|
||||
})
|
||||
|
||||
function getClient(ctx: bp.Context): GoogleAnalyticsClient {
|
||||
const { measurementId, apiSecret } = ctx.configuration
|
||||
|
||||
if (!measurementId || !apiSecret) {
|
||||
throw new RuntimeError('Google Analytics Measurement ID and API Secret are required.')
|
||||
}
|
||||
|
||||
return new GoogleAnalyticsClient(measurementId, apiSecret)
|
||||
}
|
||||
Reference in New Issue
Block a user