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
@@ -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')
})
})
+178
View File
@@ -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)
}