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,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+51
View File
@@ -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.
+1
View File
@@ -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')
})
})
+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)
}
@@ -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