chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
## What It Is
|
||||
|
||||
This integration enables you to connect your Botpress bot with Google Analytics, allowing you to update user profiles, track node views, and custom events directly within your bot's flows.
|
||||
|
||||
## How It Works
|
||||
|
||||
The integration works by using actions to interact with Google Analytics via the Measurement Protocol. Here's a brief overview of the actions:
|
||||
|
||||
1. **Update User Profile**: Sends user profile updates to Google Analytics.
|
||||
2. **Track Node**: Tracks when a specific node within your bot is triggered, sending event data to Google Analytics.
|
||||
3. **Track Event**: Sends custom event data to Google Analytics, including optional properties defined in the payload.
|
||||
|
||||
These actions utilize Google Analytics Measurement ID and an API Secret for secure data transmission.
|
||||
|
||||
## Integration Features
|
||||
|
||||
### Actions
|
||||
|
||||
- **Update User Profile**
|
||||
|
||||
- Sends updates to user profile information to Google Analytics.
|
||||
- Inputs: User ID, User Profile (optional JSON string of user metadata)
|
||||
|
||||
- **Track Node**
|
||||
|
||||
- Sends tracking data for node activation within the bot to Google Analytics.
|
||||
- Inputs: User ID, Node ID
|
||||
|
||||
- **Track Event**
|
||||
- Sends data for a custom event to Google Analytics.
|
||||
- Inputs: User ID, Event Name, Event Payload (optional JSON string of event properties)
|
||||
|
||||
### Configuration
|
||||
|
||||
- **Measurement ID**: This ID is required to send data to Google Analytics. You can find this ID in your Google Analytics dashboard under Admin > Data Streams.
|
||||
- **API Secret**: Used to secure data transmission. Can be obtained from the same section as the Measurement ID under Measurement Protocol API secrets.
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Google Analytics Setup
|
||||
|
||||
1. Log into your Google Analytics dashboard.
|
||||
2. Create or select an existing GA4 property.
|
||||
3. Configure or check your data stream to obtain the Measurement ID and API Secret.
|
||||
|
||||
### Botpress Setup
|
||||
|
||||
1. Click `Install` on the top right and select your bot.
|
||||
2. Follow the popup instructions to configure your integration.
|
||||
3. Enter your Google Analytics Measurement ID and, if using, the API Secret into the appropriate fields.
|
||||
4. Enable the integration to save your settings.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><g transform="matrix(.363638 0 0 .363636 -3.272763 -2.909091)"><path d="M130 29v132c0 14.77 10.2 23 21 23 10 0 21-7 21-23V30c0-13.54-10-22-21-22s-21 9.33-21 21z" fill="#f9ab00"/><g fill="#e37400"><path d="M75 96v65c0 14.77 10.2 23 21 23 10 0 21-7 21-23V97c0-13.54-10-22-21-22s-21 9.33-21 21z"/><circle cx="41" cy="163" r="21"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 404 B |
@@ -0,0 +1,80 @@
|
||||
import { IntegrationDefinition, z } from '@botpress/sdk'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'google-analytics',
|
||||
title: 'Google Analytics',
|
||||
description: 'Track bot events and user activity in Google Analytics.',
|
||||
icon: 'icon.svg',
|
||||
version: '1.0.0',
|
||||
readme: 'hub.md',
|
||||
configuration: {
|
||||
schema: z.object({
|
||||
measurementId: z
|
||||
.string()
|
||||
.min(1, 'Measurement ID is required')
|
||||
.title('Measurement ID')
|
||||
.describe('The Measurement ID for your Google Analytics 4 property'),
|
||||
apiSecret: z
|
||||
.string()
|
||||
.secret()
|
||||
.min(1, 'API Secret is required')
|
||||
.title('API Secret')
|
||||
.describe('The API Secret for your Google Analytics 4 property'),
|
||||
}),
|
||||
},
|
||||
actions: {
|
||||
updateUserProfile: {
|
||||
title: 'Update User Profile',
|
||||
description: "Update a user's profile information in Google Analytics.",
|
||||
input: {
|
||||
schema: z.object({
|
||||
userId: z.string().min(1).title('User ID').describe('The user ID of the profile you want to update'),
|
||||
userProfile: z
|
||||
.string()
|
||||
.title('User Profile')
|
||||
.describe("JSON object string containing the user's metadata, such as email or name")
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
},
|
||||
trackNode: {
|
||||
title: 'Track Node',
|
||||
description: 'Track a node execution as a page view in Google Analytics.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
userId: z.string().min(1).title('User ID').describe('The user ID to track'),
|
||||
nodeId: z.string().min(1).title('Node ID').describe('A unique ID representing the node being tracked'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
},
|
||||
trackEvent: {
|
||||
title: 'Track Event',
|
||||
description: 'Track a custom event in Google Analytics.',
|
||||
input: {
|
||||
schema: z.object({
|
||||
userId: z.string().min(1).title('User ID').describe('The user ID to track'),
|
||||
eventName: z.string().min(1).title('Event Name').describe('The event name to track'),
|
||||
eventPayload: z
|
||||
.string()
|
||||
.title('Event Payload')
|
||||
.describe('JSON object string containing the properties of the event')
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {},
|
||||
attributes: {
|
||||
category: 'Marketing & Email',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@botpresshub/google-analytics",
|
||||
"description": "Google Analytics integration for Botpress",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"axios": "^1.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import config from '../../vitest.config'
|
||||
|
||||
export default config
|
||||
Reference in New Issue
Block a user