Skip to content

Latest commit

 

History

History
642 lines (533 loc) · 15.1 KB

File metadata and controls

642 lines (533 loc) · 15.1 KB
layout default
title Chapter 4: Custom Actions & Code
parent Botpress Tutorial
nav_order 4

Chapter 4: Custom Actions & Code

Welcome to Chapter 4: Custom Actions & Code. In this part of Botpress Tutorial: Open Source Conversational AI Platform, you will build an intuitive mental model first, then move into concrete implementation details and practical production tradeoffs.

This chapter teaches you how to extend Botpress functionality with custom actions, JavaScript/TypeScript code, and external API integrations.

🎯 Custom Actions

Creating Actions

// actions/greeting.ts
import { ActionDefinition, ActionContext } from '@botpress/sdk'

export const greeting: ActionDefinition = {
  name: 'greeting',
  description: 'Generate personalized greeting',
  input: {
    schema: {
      type: 'object',
      properties: {
        name: { type: 'string' },
        timeOfDay: { type: 'string', enum: ['morning', 'afternoon', 'evening'] }
      },
      required: ['name']
    }
  },
  output: {
    schema: {
      type: 'object',
      properties: {
        message: { type: 'string' }
      }
    }
  },
  handler: async (context: ActionContext) => {
    const { name, timeOfDay = 'morning' } = context.input

    const greetings = {
      morning: 'Good morning',
      afternoon: 'Good afternoon',
      evening: 'Good evening'
    }

    const message = `${greetings[timeOfDay]} ${name}! How can I help you today?`

    return { message }
  }
}

Using Actions in Flows

// In your flow configuration
{
  type: "action",
  action: "greeting",
  args: {
    name: "{{user.firstName}}",
    timeOfDay: "morning"
  },
  output: {
    greetingMessage: "{{action.message}}"
  }
}

🔧 JavaScript/TypeScript Integration

Basic Code Actions

// actions/calculate.ts
export const calculate: ActionDefinition = {
  name: 'calculate',
  input: {
    schema: {
      type: 'object',
      properties: {
        operation: { type: 'string', enum: ['add', 'subtract', 'multiply', 'divide'] },
        a: { type: 'number' },
        b: { type: 'number' }
      },
      required: ['operation', 'a', 'b']
    }
  },
  output: {
    schema: {
      type: 'object',
      properties: {
        result: { type: 'number' }
      }
    }
  },
  handler: async ({ input }) => {
    const { operation, a, b } = input
    let result: number

    switch (operation) {
      case 'add':
        result = a + b
        break
      case 'subtract':
        result = a - b
        break
      case 'multiply':
        result = a * b
        break
      case 'divide':
        result = a / b
        break
      default:
        throw new Error('Invalid operation')
    }

    return { result }
  }
}

Advanced Code Features

// actions/weather.ts
import axios from 'axios'

export const getWeather: ActionDefinition = {
  name: 'getWeather',
  input: {
    schema: {
      type: 'object',
      properties: {
        city: { type: 'string' },
        unit: { type: 'string', enum: ['celsius', 'fahrenheit'], default: 'celsius' }
      },
      required: ['city']
    }
  },
  output: {
    schema: {
      type: 'object',
      properties: {
        temperature: { type: 'number' },
        condition: { type: 'string' },
        city: { type: 'string' }
      }
    }
  },
  handler: async ({ input }) => {
    try {
      const { city, unit = 'celsius' } = input
      const apiKey = process.env.WEATHER_API_KEY

      const response = await axios.get(
        `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${apiKey}&units=${unit === 'celsius' ? 'metric' : 'imperial'}`
      )

      const { main, weather } = response.data

      return {
        temperature: main.temp,
        condition: weather[0].description,
        city: response.data.name
      }
    } catch (error) {
      throw new Error(`Weather API error: ${error.message}`)
    }
  }
}

🌐 External API Integration

REST API Calls

// actions/apiCall.ts
import axios from 'axios'

export const apiCall: ActionDefinition = {
  name: 'apiCall',
  input: {
    schema: {
      type: 'object',
      properties: {
        url: { type: 'string' },
        method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], default: 'GET' },
        headers: { type: 'object' },
        body: { type: 'object' }
      },
      required: ['url']
    }
  },
  output: {
    schema: {
      type: 'object',
      properties: {
        status: { type: 'number' },
        data: { type: 'object' },
        headers: { type: 'object' }
      }
    }
  },
  handler: async ({ input }) => {
    const { url, method = 'GET', headers = {}, body } = input

    try {
      const response = await axios({
        method,
        url,
        headers,
        data: body
      })

      return {
        status: response.status,
        data: response.data,
        headers: response.headers
      }
    } catch (error) {
      return {
        status: error.response?.status || 500,
        data: { error: error.message },
        headers: {}
      }
    }
  }
}

Database Integration

// actions/database.ts
import { Client } from 'pg'

export const queryDatabase: ActionDefinition = {
  name: 'queryDatabase',
  input: {
    schema: {
      type: 'object',
      properties: {
        query: { type: 'string' },
        params: { type: 'array' }
      },
      required: ['query']
    }
  },
  output: {
    schema: {
      type: 'object',
      properties: {
        rows: { type: 'array' },
        rowCount: { type: 'number' }
      }
    }
  },
  handler: async ({ input }) => {
    const client = new Client({
      connectionString: process.env.DATABASE_URL
    })

    try {
      await client.connect()
      const result = await client.query(input.query, input.params || [])

      return {
        rows: result.rows,
        rowCount: result.rowCount
      }
    } finally {
      await client.end()
    }
  }
}

🔄 Middleware and Hooks

Before Message Hooks

// hooks/beforeMessage.ts
import { HookDefinition } from '@botpress/sdk'

export const beforeMessage: HookDefinition = {
  name: 'beforeMessage',
  description: 'Process message before bot handles it',
  handler: async ({ message, user, conversation }) => {
    // Log message
    console.log(`Message from ${user.id}: ${message.payload.text}`)

    // Add timestamp
    message.payload.timestamp = new Date().toISOString()

    // Check for spam
    if (isSpam(message.payload.text)) {
      message.payload.isSpam = true
    }

    return message
  }
}

function isSpam(text: string): boolean {
  const spamPatterns = [
    /free money/i,
    /click here/i,
    /urgent/i
  ]

  return spamPatterns.some(pattern => pattern.test(text))
}

After Message Hooks

// hooks/afterMessage.ts
export const afterMessage: HookDefinition = {
  name: 'afterMessage',
  description: 'Process after bot sends message',
  handler: async ({ message, user, conversation }) => {
    // Analytics tracking
    await trackMessage(user.id, message.payload.text)

    // Auto-save conversation
    await saveConversation(conversation.id, message)

    // Trigger notifications if needed
    if (message.payload.text.includes('urgent')) {
      await sendNotification(user.id, 'Urgent message sent')
    }
  }
}

🎨 Advanced Custom Features

Custom Components

// components/CustomCard.tsx
import React from 'react'

interface CustomCardProps {
  title: string
  description: string
  imageUrl?: string
  buttons: Array<{ title: string; payload: string }>
}

export const CustomCard: React.FC<CustomCardProps> = ({
  title,
  description,
  imageUrl,
  buttons
}) => {
  return (
    <div className="custom-card">
      {imageUrl && <img src={imageUrl} alt={title} />}
      <h3>{title}</h3>
      <p>{description}</p>
      <div className="buttons">
        {buttons.map((button, index) => (
          <button key={index} onClick={() => sendPayload(button.payload)}>
            {button.title}
          </button>
        ))}
      </div>
    </div>
  )
}

File Upload Handling

// actions/handleFile.ts
export const handleFile: ActionDefinition = {
  name: 'handleFile',
  input: {
    schema: {
      type: 'object',
      properties: {
        file: { type: 'object' },
        allowedTypes: { type: 'array', items: { type: 'string' } }
      },
      required: ['file']
    }
  },
  output: {
    schema: {
      type: 'object',
      properties: {
        fileUrl: { type: 'string' },
        fileType: { type: 'string' },
        fileSize: { type: 'number' }
      }
    }
  },
  handler: async ({ input }) => {
    const { file, allowedTypes = [] } = input

    // Validate file type
    if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
      throw new Error(`File type ${file.type} not allowed`)
    }

    // Upload file
    const fileUrl = await uploadToStorage(file)

    return {
      fileUrl,
      fileType: file.type,
      fileSize: file.size
    }
  }
}

🧪 Testing Custom Code

Unit Tests

// __tests__/actions/calculate.test.ts
import { calculate } from '../actions/calculate'

describe('Calculate Action', () => {
  test('adds two numbers', async () => {
    const result = await calculate.handler({
      input: { operation: 'add', a: 5, b: 3 }
    })

    expect(result.result).toBe(8)
  })

  test('handles division by zero', async () => {
    await expect(
      calculate.handler({
        input: { operation: 'divide', a: 10, b: 0 }
      })
    ).rejects.toThrow('Division by zero')
  })
})

Integration Tests

// __tests__/integration/weather.test.ts
import { getWeather } from '../actions/weather'

describe('Weather Integration', () => {
  test('fetches weather data', async () => {
    const result = await getWeather.handler({
      input: { city: 'London' }
    })

    expect(result).toHaveProperty('temperature')
    expect(result).toHaveProperty('condition')
    expect(result.city.toLowerCase()).toBe('london')
  })
})

🚀 Performance Optimization

Caching

// actions/cachedApi.ts
import NodeCache from 'node-cache'

const cache = new NodeCache({ stdTTL: 300 }) // 5 minutes

export const cachedApiCall: ActionDefinition = {
  name: 'cachedApiCall',
  handler: async ({ input }) => {
    const cacheKey = JSON.stringify(input)
    const cached = cache.get(cacheKey)

    if (cached) {
      return cached
    }

    const result = await makeApiCall(input)
    cache.set(cacheKey, result)

    return result
  }
}

Async Processing

// actions/asyncProcess.ts
export const asyncProcess: ActionDefinition = {
  name: 'asyncProcess',
  handler: async ({ input }) => {
    // Start async process
    const jobId = await queueProcess(input)

    // Return immediately with job ID
    return {
      jobId,
      status: 'processing',
      message: 'Your request is being processed'
    }
  }
}

// Check status later
export const checkProcessStatus: ActionDefinition = {
  name: 'checkProcessStatus',
  handler: async ({ input: { jobId } }) => {
    const status = await getJobStatus(jobId)
    return status
  }
}

🔒 Security Best Practices

Input Validation

// actions/secureAction.ts
import validator from 'validator'

export const secureAction: ActionDefinition = {
  name: 'secureAction',
  handler: async ({ input }) => {
    const { email, url } = input

    // Validate email
    if (!validator.isEmail(email)) {
      throw new Error('Invalid email format')
    }

    // Sanitize URL
    const safeUrl = validator.escape(url)

    return { email, safeUrl }
  }
}

Rate Limiting

// middleware/rateLimit.ts
import rateLimit from 'express-rate-limit'

export const createRateLimit = (windowMs: number, max: number) => {
  return rateLimit({
    windowMs,
    max,
    message: 'Too many requests from this IP',
    standardHeaders: true,
    legacyHeaders: false
  })
}

📝 Chapter Summary

  • ✅ Created custom actions with TypeScript
  • ✅ Integrated external APIs and databases
  • ✅ Implemented middleware and hooks
  • ✅ Built custom UI components
  • ✅ Added comprehensive testing
  • ✅ Optimized performance with caching
  • ✅ Applied security best practices

Key Takeaways:

  • Custom actions extend Botpress functionality
  • TypeScript provides type safety and better IDE support
  • External integrations enable complex workflows
  • Hooks allow preprocessing and postprocessing
  • Testing ensures code reliability
  • Caching improves performance
  • Security validation protects against attacks

What Problem Does This Solve?

Most teams struggle here because the hard part is not writing more code, but deciding clear boundaries for input, message, result so behavior stays predictable as complexity grows.

In practical terms, this chapter helps you avoid three common failures:

  • coupling core logic too tightly to one implementation path
  • missing the handoff boundaries between setup, execution, and validation
  • shipping changes without clear rollback or observability strategy

After working through this chapter, you should be able to reason about Chapter 4: Custom Actions & Code as an operating subsystem inside Botpress Tutorial: Open Source Conversational AI Platform, with explicit contracts for inputs, state transitions, and outputs.

Use the implementation notes around name, object, handler as your checklist when adapting these patterns to your own repository.

How it Works Under the Hood

Under the hood, Chapter 4: Custom Actions & Code usually follows a repeatable control path:

  1. Context bootstrap: initialize runtime config and prerequisites for input.
  2. Input normalization: shape incoming data so message receives stable contracts.
  3. Core execution: run the main logic branch and propagate intermediate state through result.
  4. Policy and safety checks: enforce limits, auth scopes, and failure boundaries.
  5. Output composition: return canonical result payloads for downstream consumers.
  6. Operational telemetry: emit logs/metrics needed for debugging and performance tuning.

When debugging, walk this sequence in order and confirm each stage has explicit success/failure conditions.

Source Walkthrough

Use the following upstream sources to verify implementation details while reading this chapter:

Suggested trace strategy:

  • search upstream code for input and message to map concrete implementation paths
  • compare docs claims against actual runtime/config code before reusing patterns in production

Chapter Connections